mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-24 07:10:21 -05:00
463704f054
Unify buttons, cards, headers, toasts, and form treatments across the app so screens feel consistent and are easier to scan on desktop and mobile. Update the broader template set to use the shared UI primitives and responsive spacing patterns introduced in this refresh.
400 lines
16 KiB
HTML
400 lines
16 KiB
HTML
{% extends "base.html" %}
|
|
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge, empty_state %}
|
|
|
|
{% block content %}
|
|
{% set breadcrumbs = [
|
|
{'text': 'Quotes'}
|
|
] %}
|
|
|
|
{{ page_header(
|
|
icon_class='fas fa-file-contract',
|
|
title_text='Quotes',
|
|
subtitle_text='Manage client quotes',
|
|
breadcrumbs=breadcrumbs,
|
|
actions_html='<div class="flex gap-2"><a href="' + url_for("quotes.list_quotes", analytics='true' if not show_analytics else 'false') + '" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">' + ('Hide Analytics' if show_analytics else 'Show Analytics') + '</a>' + ('<a href="' + url_for("quotes.create_quote") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Quote</a>' if (current_user.is_admin or has_permission('create_quotes')) else '') + '</div>'
|
|
) }}
|
|
|
|
{% if show_analytics and analytics %}
|
|
<!-- Analytics Dashboard -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
<div class="bg-card-light dark:bg-card-dark p-4 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
|
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{ _('Total Quotes') }}</h3>
|
|
<p class="text-2xl font-bold">{{ analytics.total_quotes }}</p>
|
|
</div>
|
|
<div class="bg-card-light dark:bg-card-dark p-4 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
|
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{ _('Total Value') }}</h3>
|
|
<p class="text-2xl font-bold">{{ "%.2f"|format(analytics.total_value) }} EUR</p>
|
|
</div>
|
|
<div class="bg-card-light dark:bg-card-dark p-4 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
|
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{ _('Acceptance Rate') }}</h3>
|
|
<p class="text-2xl font-bold">{{ analytics.acceptance_rate }}%</p>
|
|
</div>
|
|
<div class="bg-card-light dark:bg-card-dark p-4 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
|
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{ _('Avg Quote Value') }}</h3>
|
|
<p class="text-2xl font-bold">{{ "%.2f"|format(analytics.avg_value) }} EUR</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
|
<div class="bg-card-light dark:bg-card-dark p-4 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
|
|
<h3 class="text-lg font-semibold mb-4">{{ _('Quotes by Status') }}</h3>
|
|
<div class="space-y-2">
|
|
{% for status, count in analytics.quotes_by_status.items() %}
|
|
<div class="flex justify-between items-center">
|
|
<span class="capitalize">{{ _(status) }}</span>
|
|
<span class="font-medium">{{ count }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
<div class="bg-card-light dark:bg-card-dark p-4 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
|
|
<h3 class="text-lg font-semibold mb-4">{{ _('Top Clients by Quotes') }}</h3>
|
|
<div class="space-y-2">
|
|
{% for client_data in analytics.quotes_by_client %}
|
|
<div class="flex justify-between items-center">
|
|
<span>{{ client_data.name }}</span>
|
|
<span class="font-medium">{{ client_data.count }} ({{ "%.2f"|format(client_data.total) }} EUR)</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-border-dark shadow-sm mb-6">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h2 class="text-lg font-semibold">{{ _('Filter Quotes') }}</h2>
|
|
<button type="button" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" id="toggleFilters" onclick="toggleFilterVisibility()" title="{{ _('Toggle Filters') }}">
|
|
<i class="fas fa-chevron-up" id="filterToggleIcon"></i>
|
|
</button>
|
|
</div>
|
|
<div id="filterBody">
|
|
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4" id="quotesFilterForm" data-filter-form>
|
|
<div>
|
|
<label for="quotes-filter-search" class="form-label">{{ _('Search') }}</label>
|
|
<input type="text" name="search" id="quotes-filter-search" value="{{ search or '' }}" class="form-input" placeholder="{{ _('Search quotes...') }}">
|
|
</div>
|
|
<div>
|
|
<label for="status" class="form-label">{{ _('Status') }}</label>
|
|
<select name="status" id="status" class="form-input">
|
|
<option value="all" {% if status == 'all' %}selected{% endif %}>{{ _('All') }}</option>
|
|
<option value="draft" {% if status == 'draft' %}selected{% endif %}>{{ _('Draft') }}</option>
|
|
<option value="sent" {% if status == 'sent' %}selected{% endif %}>{{ _('Sent') }}</option>
|
|
<option value="accepted" {% if status == 'accepted' %}selected{% endif %}>{{ _('Accepted') }}</option>
|
|
<option value="rejected" {% if status == 'rejected' %}selected{% endif %}>{{ _('Rejected') }}</option>
|
|
<option value="expired" {% if status == 'expired' %}selected{% endif %}>{{ _('Expired') }}</option>
|
|
</select>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-border-dark shadow-sm overflow-visible" id="quotesContainer">
|
|
{% include 'quotes/_quotes_list.html' %}
|
|
</div>
|
|
|
|
{% endblock %}
|
|
|
|
{% block scripts_extra %}
|
|
<style>
|
|
.filter-collapsed { display: none !important; }
|
|
.filter-toggle-transition { transition: all 0.3s ease-in-out; }
|
|
</style>
|
|
<script>
|
|
function toggleFilterVisibility() {
|
|
const filterBody = document.getElementById('filterBody');
|
|
const toggleIcon = document.getElementById('filterToggleIcon');
|
|
const toggleButton = document.getElementById('toggleFilters');
|
|
if (!filterBody || !toggleIcon || !toggleButton) return;
|
|
|
|
const isCollapsed = filterBody.classList.contains('filter-collapsed');
|
|
|
|
if (isCollapsed) {
|
|
filterBody.classList.remove('filter-collapsed');
|
|
toggleIcon.classList.remove('fa-chevron-down');
|
|
toggleIcon.classList.add('fa-chevron-up');
|
|
toggleButton.title = '{{ _('Hide Filters') }}';
|
|
localStorage.setItem('quoteListFiltersVisible', 'true');
|
|
} else {
|
|
filterBody.classList.add('filter-collapsed');
|
|
toggleIcon.classList.remove('fa-chevron-up');
|
|
toggleIcon.classList.add('fa-chevron-down');
|
|
toggleButton.title = '{{ _('Show Filters') }}';
|
|
localStorage.setItem('quoteListFiltersVisible', 'false');
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const filterBody = document.getElementById('filterBody');
|
|
const toggleIcon = document.getElementById('filterToggleIcon');
|
|
const toggleButton = document.getElementById('toggleFilters');
|
|
if (!filterBody || !toggleIcon || !toggleButton) return;
|
|
|
|
const filtersVisible = localStorage.getItem('quoteListFiltersVisible');
|
|
if (filtersVisible === 'false') {
|
|
filterBody.classList.add('filter-collapsed');
|
|
toggleIcon.classList.remove('fa-chevron-up');
|
|
toggleIcon.classList.add('fa-chevron-down');
|
|
toggleButton.title = '{{ _('Show Filters') }}';
|
|
} else {
|
|
filterBody.classList.remove('filter-collapsed');
|
|
toggleIcon.classList.remove('fa-chevron-down');
|
|
toggleIcon.classList.add('fa-chevron-up');
|
|
toggleButton.title = '{{ _('Hide Filters') }}';
|
|
}
|
|
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
|
|
});
|
|
|
|
// Quotes Filter Handler - AJAX filtering
|
|
(function() {
|
|
'use strict';
|
|
|
|
let filterTimeout = null;
|
|
let searchTimeout = null;
|
|
|
|
function getFilterParams() {
|
|
const form = document.getElementById('quotesFilterForm');
|
|
if (!form) return {};
|
|
|
|
const params = {};
|
|
|
|
// Get search input value directly
|
|
const searchInput = form.querySelector('input[name="search"]');
|
|
if (searchInput) {
|
|
const searchValue = searchInput.value.trim();
|
|
if (searchValue) {
|
|
params.search = searchValue;
|
|
}
|
|
}
|
|
|
|
// Get status
|
|
const statusSelect = form.querySelector('[name="status"]');
|
|
if (statusSelect) {
|
|
const statusValue = statusSelect.value;
|
|
if (statusValue && statusValue !== 'all') {
|
|
params.status = statusValue;
|
|
} else {
|
|
params.status = 'all';
|
|
}
|
|
} else {
|
|
params.status = 'all';
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
function buildFilterUrl() {
|
|
const params = getFilterParams();
|
|
const queryString = new URLSearchParams(params).toString();
|
|
return `/quotes?${queryString}`;
|
|
}
|
|
|
|
function applyFilters() {
|
|
const url = buildFilterUrl();
|
|
const container = document.getElementById('quotesListContainer');
|
|
|
|
if (!container) {
|
|
console.error('quotesListContainer not found');
|
|
return;
|
|
}
|
|
|
|
// Show loading state
|
|
container.style.opacity = '0.5';
|
|
container.style.pointerEvents = 'none';
|
|
|
|
// Update URL
|
|
if (window.history && window.history.pushState) {
|
|
window.history.pushState({}, '', url);
|
|
}
|
|
|
|
// Fetch filtered results
|
|
fetch(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Accept': 'text/html'
|
|
},
|
|
credentials: 'same-origin'
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
return response.text();
|
|
})
|
|
.then(html => {
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = html.trim();
|
|
|
|
const newContainer = tempDiv.querySelector('#quotesListContainer');
|
|
|
|
if (newContainer) {
|
|
container.innerHTML = newContainer.innerHTML;
|
|
} else {
|
|
const match = html.trim().match(/<div[^>]*id=["']quotesListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
|
|
if (match && match[1]) {
|
|
container.innerHTML = match[1];
|
|
} else {
|
|
container.innerHTML = html;
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Filter error:', error);
|
|
container.style.opacity = '';
|
|
container.style.pointerEvents = '';
|
|
if (window.toastManager) {
|
|
window.toastManager.show('Failed to filter quotes. Please refresh the page.', 'error');
|
|
} else if (window.showToast) {
|
|
window.showToast('Failed to filter quotes. Please refresh the page.', 'error');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
container.style.opacity = '';
|
|
container.style.pointerEvents = '';
|
|
});
|
|
}
|
|
|
|
function debouncedApplyFilters(delay = 100) {
|
|
if (filterTimeout) {
|
|
clearTimeout(filterTimeout);
|
|
}
|
|
filterTimeout = setTimeout(applyFilters, delay);
|
|
}
|
|
|
|
function debouncedSearch(delay = 500) {
|
|
if (searchTimeout) {
|
|
clearTimeout(searchTimeout);
|
|
}
|
|
searchTimeout = setTimeout(applyFilters, delay);
|
|
}
|
|
|
|
// Initialize when DOM is ready
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const form = document.getElementById('quotesFilterForm');
|
|
if (!form) {
|
|
console.error('Quotes filter form not found');
|
|
return;
|
|
}
|
|
|
|
// Auto-submit on dropdown changes
|
|
form.querySelectorAll('select').forEach(select => {
|
|
select.addEventListener('change', () => {
|
|
debouncedApplyFilters(100);
|
|
});
|
|
});
|
|
|
|
// Auto-submit on search input (debounced)
|
|
const searchInput = form.querySelector('input[name="search"]');
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', () => {
|
|
debouncedSearch(500);
|
|
});
|
|
|
|
// Submit on Enter
|
|
searchInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (searchTimeout) {
|
|
clearTimeout(searchTimeout);
|
|
}
|
|
applyFilters();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Prevent form submission (use AJAX instead)
|
|
form.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (searchTimeout) {
|
|
clearTimeout(searchTimeout);
|
|
}
|
|
if (filterTimeout) {
|
|
clearTimeout(filterTimeout);
|
|
}
|
|
applyFilters();
|
|
});
|
|
});
|
|
})();
|
|
|
|
{% if current_user.is_admin or has_permission('create_quotes') %}
|
|
function toggleAllQuotes(){
|
|
const selectAll = document.getElementById('selectAll');
|
|
document.querySelectorAll('.quote-checkbox').forEach(cb => cb.checked = !!(selectAll && selectAll.checked));
|
|
updateQuotesBulkState();
|
|
}
|
|
function updateQuotesBulkState(){
|
|
const selected = document.querySelectorAll('.quote-checkbox:checked').length;
|
|
const btn = document.getElementById('bulkActionsBtn');
|
|
const cnt = document.getElementById('selectedCount');
|
|
if (cnt) cnt.textContent = selected;
|
|
if (btn) btn.disabled = selected === 0;
|
|
}
|
|
function closeAllMenus(){
|
|
document.querySelectorAll('.bulk-menu').forEach(m => m.classList.add('hidden'));
|
|
}
|
|
function openMenu(triggerEl, menuId){
|
|
const menu = document.getElementById(menuId);
|
|
if (!menu) return;
|
|
const willOpen = menu.classList.contains('hidden');
|
|
closeAllMenus();
|
|
if (!willOpen) return;
|
|
const rect = triggerEl.getBoundingClientRect();
|
|
const menuHeight = menu.offsetHeight || 200;
|
|
const spaceBelow = window.innerHeight - rect.bottom;
|
|
const spaceAbove = rect.top;
|
|
if (spaceBelow < menuHeight + 16 && spaceAbove > menuHeight + 16){
|
|
menu.style.bottom = 'calc(100% + 8px)';
|
|
} else {
|
|
menu.style.top = 'calc(100% + 8px)';
|
|
}
|
|
menu.classList.remove('hidden');
|
|
}
|
|
document.addEventListener('click', function(e){
|
|
const insideTrigger = e.target.closest('#bulkActionsBtn');
|
|
const insideMenu = e.target.closest('#quotesBulkMenu');
|
|
if (!insideTrigger && !insideMenu){ closeAllMenus(); }
|
|
});
|
|
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeAllMenus(); });
|
|
|
|
function showBulkAction(action){
|
|
const count = document.querySelectorAll('.quote-checkbox:checked').length;
|
|
if (count === 0) return false;
|
|
|
|
const actions = {
|
|
'duplicate': { label: '{{ _('Duplicate') }}', msg: `{{ _('Are you sure you want to duplicate') }} ${count} {{ _('quote(s)?') }}` },
|
|
'mark_sent': { label: '{{ _('Mark as Sent') }}', msg: `{{ _('Are you sure you want to mark') }} ${count} {{ _('quote(s) as sent?') }}` },
|
|
'delete': { label: '{{ _('Delete') }}', msg: `{{ _('Are you sure you want to delete') }} ${count} {{ _('quote(s)?') }}`, variant: 'danger' }
|
|
};
|
|
|
|
const actionData = actions[action];
|
|
if (!actionData) return false;
|
|
|
|
if (window.showConfirm){
|
|
window.showConfirm(actionData.msg, { title: actionData.label, confirmText: actionData.label, variant: actionData.variant || 'default' }).then(function(ok){
|
|
if (ok) submitBulkAction(action);
|
|
});
|
|
} else if (confirm(actionData.msg)) {
|
|
submitBulkAction(action);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function submitBulkAction(action){
|
|
const form = document.getElementById('bulk-action-form');
|
|
document.getElementById('bulkActionValue').value = action;
|
|
form.querySelectorAll('input[name="quote_ids[]"]').forEach(n => n.remove());
|
|
document.querySelectorAll('.quote-checkbox:checked').forEach(cb => {
|
|
const i = document.createElement('input');
|
|
i.type='hidden';
|
|
i.name='quote_ids[]';
|
|
i.value=cb.value;
|
|
form.appendChild(i);
|
|
});
|
|
form.submit();
|
|
}
|
|
{% endif %}
|
|
</script>
|
|
{% endblock %}
|