perf(contracts): optimize queries, add server-side pagination, search, thematic/contract filters and redesign UI

This commit is contained in:
kdeterme 2026-10-10 14:39:40 +02:00
parent 34e9743cc3
commit 0732f89274
11 changed files with 2088 additions and 289 deletions

View file

@ -2399,3 +2399,93 @@ msgstr "Map color"
msgid "Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745)."
msgstr "Display color of the lot on the map (hexadecimal code, e.g. #28a745)."
#: contracts/templates/contracts/contracts_index.html
msgid "Filtrer les commandes et marchés"
msgstr "Filter orders and contracts"
#: contracts/templates/contracts/contracts_index.html
msgid "Sélectionnez les critères de recherche"
msgstr "Select search criteria"
#: contracts/templates/contracts/contracts_index.html
msgid "Aucun"
msgstr "None"
#: contracts/templates/contracts/contracts_index.html
msgid "Marchés"
msgstr "Contracts"
#: contracts/templates/contracts/contracts_index.html
msgid "Filtrer la liste des marchés..."
msgstr "Filter contract list..."
#: contracts/templates/contracts/contracts_index.html
msgid "Période de commande"
msgstr "Order period"
#: contracts/templates/contracts/contracts_index.html
msgid "Rechercher une commande, un marché, une description, un prestataire..."
msgstr "Search order, contract, description, provider..."
#: contracts/templates/contracts/contracts_index.html
msgid "Effacer la recherche"
msgstr "Clear search"
#: contracts/templates/contracts/contracts_index.html
msgid "Filtres"
msgstr "Filters"
#: contracts/templates/contracts/contracts_index.html
msgid "Afficher par"
msgstr "Show by"
#: contracts/templates/contracts/contracts_index.html
msgid "Filtres actifs"
msgstr "Active filters"
#: contracts/templates/contracts/contracts_index.html
msgid "Supprimer ce critère"
msgstr "Remove this filter"
#: contracts/templates/contracts/contracts_index.html
msgid "Tout effacer"
msgstr "Clear all"
#: contracts/templates/contracts/contracts_index.html
msgid "Télécharger le bon de commande"
msgstr "Download order document"
#: contracts/templates/contracts/contracts_index.html
msgid "Aucune commande ne correspond à vos critères de recherche actuels."
msgstr "No orders match your current search criteria."
#: contracts/templates/contracts/contracts_index.html
msgid "Réinitialiser les filtres"
msgstr "Reset filters"
#: contracts/templates/contracts/contracts_index.html
msgid "Affichage de <strong>%(start)s</strong> à <strong>%(end)s</strong> sur <strong>%(total)s</strong> commande(s)"
msgstr "Showing <strong>%(start)s</strong> to <strong>%(end)s</strong> of <strong>%(total)s</strong> order(s)"
#: contracts/templates/contracts/contracts_index.html
msgid "Pagination des commandes"
msgstr "Orders pagination"
#: contracts/templates/contracts/contracts_index.html
msgid "Détail du contrat"
msgstr "Contract details"
#: contracts/templates/contracts/contracts_index.html
msgid "Aucun marché ne correspond à vos critères de recherche actuels."
msgstr "No contracts match your current search criteria."
#: contracts/templates/contracts/contracts_index.html
msgid "Affichage de <strong>%(start)s</strong> à <strong>%(end)s</strong> sur <strong>%(total)s</strong> marché(s)"
msgstr "Showing <strong>%(start)s</strong> to <strong>%(end)s</strong> of <strong>%(total)s</strong> contract(s)"
#: contracts/templates/contracts/contracts_index.html
msgid "Pagination des marchés"
msgstr "Contracts pagination"

View file

@ -2449,3 +2449,93 @@ msgstr "Couleur cartographique"
msgid "Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745)."
msgstr "Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745)."
#: contracts/templates/contracts/contracts_index.html
msgid "Filtrer les commandes et marchés"
msgstr "Filtrer les commandes et marchés"
#: contracts/templates/contracts/contracts_index.html
msgid "Sélectionnez les critères de recherche"
msgstr "Sélectionnez les critères de recherche"
#: contracts/templates/contracts/contracts_index.html
msgid "Aucun"
msgstr "Aucun"
#: contracts/templates/contracts/contracts_index.html
msgid "Marchés"
msgstr "Marchés"
#: contracts/templates/contracts/contracts_index.html
msgid "Filtrer la liste des marchés..."
msgstr "Filtrer la liste des marchés..."
#: contracts/templates/contracts/contracts_index.html
msgid "Période de commande"
msgstr "Période de commande"
#: contracts/templates/contracts/contracts_index.html
msgid "Rechercher une commande, un marché, une description, un prestataire..."
msgstr "Rechercher une commande, un marché, une description, un prestataire..."
#: contracts/templates/contracts/contracts_index.html
msgid "Effacer la recherche"
msgstr "Effacer la recherche"
#: contracts/templates/contracts/contracts_index.html
msgid "Filtres"
msgstr "Filtres"
#: contracts/templates/contracts/contracts_index.html
msgid "Afficher par"
msgstr "Afficher par"
#: contracts/templates/contracts/contracts_index.html
msgid "Filtres actifs"
msgstr "Filtres actifs"
#: contracts/templates/contracts/contracts_index.html
msgid "Supprimer ce critère"
msgstr "Supprimer ce critère"
#: contracts/templates/contracts/contracts_index.html
msgid "Tout effacer"
msgstr "Tout effacer"
#: contracts/templates/contracts/contracts_index.html
msgid "Télécharger le bon de commande"
msgstr "Télécharger le bon de commande"
#: contracts/templates/contracts/contracts_index.html
msgid "Aucune commande ne correspond à vos critères de recherche actuels."
msgstr "Aucune commande ne correspond à vos critères de recherche actuels."
#: contracts/templates/contracts/contracts_index.html
msgid "Réinitialiser les filtres"
msgstr "Réinitialiser les filtres"
#: contracts/templates/contracts/contracts_index.html
msgid "Affichage de <strong>%(start)s</strong> à <strong>%(end)s</strong> sur <strong>%(total)s</strong> commande(s)"
msgstr "Affichage de <strong>%(start)s</strong> à <strong>%(end)s</strong> sur <strong>%(total)s</strong> commande(s)"
#: contracts/templates/contracts/contracts_index.html
msgid "Pagination des commandes"
msgstr "Pagination des commandes"
#: contracts/templates/contracts/contracts_index.html
msgid "Détail du contrat"
msgstr "Détail du contrat"
#: contracts/templates/contracts/contracts_index.html
msgid "Aucun marché ne correspond à vos critères de recherche actuels."
msgstr "Aucun marché ne correspond à vos critères de recherche actuels."
#: contracts/templates/contracts/contracts_index.html
msgid "Affichage de <strong>%(start)s</strong> à <strong>%(end)s</strong> sur <strong>%(total)s</strong> marché(s)"
msgstr "Affichage de <strong>%(start)s</strong> à <strong>%(end)s</strong> sur <strong>%(total)s</strong> marché(s)"
#: contracts/templates/contracts/contracts_index.html
msgid "Pagination des marchés"
msgstr "Pagination des marchés"

View file

@ -2405,3 +2405,93 @@ msgstr "Kaartkleur"
msgid "Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745)."
msgstr "Weergavekleur van het perceel op de kaart (hexadecimale code, bijv. #28a745)."
#: contracts/templates/contracts/contracts_index.html
msgid "Filtrer les commandes et marchés"
msgstr "Bestellingen en opdrachten filteren"
#: contracts/templates/contracts/contracts_index.html
msgid "Sélectionnez les critères de recherche"
msgstr "Selecteer de zoekcriteria"
#: contracts/templates/contracts/contracts_index.html
msgid "Aucun"
msgstr "Geen"
#: contracts/templates/contracts/contracts_index.html
msgid "Marchés"
msgstr "Opdrachten"
#: contracts/templates/contracts/contracts_index.html
msgid "Filtrer la liste des marchés..."
msgstr "Lijst met opdrachten filteren..."
#: contracts/templates/contracts/contracts_index.html
msgid "Période de commande"
msgstr "Bestelperiode"
#: contracts/templates/contracts/contracts_index.html
msgid "Rechercher une commande, un marché, une description, un prestataire..."
msgstr "Zoeken op bestelnummer, opdracht, omschrijving, dienstverlener..."
#: contracts/templates/contracts/contracts_index.html
msgid "Effacer la recherche"
msgstr "Zoekopdracht wissen"
#: contracts/templates/contracts/contracts_index.html
msgid "Filtres"
msgstr "Filters"
#: contracts/templates/contracts/contracts_index.html
msgid "Afficher par"
msgstr "Weergeven per"
#: contracts/templates/contracts/contracts_index.html
msgid "Filtres actifs"
msgstr "Actieve filters"
#: contracts/templates/contracts/contracts_index.html
msgid "Supprimer ce critère"
msgstr "Dit criterium verwijderen"
#: contracts/templates/contracts/contracts_index.html
msgid "Tout effacer"
msgstr "Alles wissen"
#: contracts/templates/contracts/contracts_index.html
msgid "Télécharger le bon de commande"
msgstr "Bestelbon downloaden"
#: contracts/templates/contracts/contracts_index.html
msgid "Aucune commande ne correspond à vos critères de recherche actuels."
msgstr "Geen enkele bestelling voldoet aan de huidige zoekcriteria."
#: contracts/templates/contracts/contracts_index.html
msgid "Réinitialiser les filtres"
msgstr "Filters opnieuw instellen"
#: contracts/templates/contracts/contracts_index.html
msgid "Affichage de <strong>%(start)s</strong> à <strong>%(end)s</strong> sur <strong>%(total)s</strong> commande(s)"
msgstr "Weergave van <strong>%(start)s</strong> tot <strong>%(end)s</strong> van <strong>%(total)s</strong> bestelling(en)"
#: contracts/templates/contracts/contracts_index.html
msgid "Pagination des commandes"
msgstr "Paginering van bestellingen"
#: contracts/templates/contracts/contracts_index.html
msgid "Détail du contrat"
msgstr "Contractdetail"
#: contracts/templates/contracts/contracts_index.html
msgid "Aucun marché ne correspond à vos critères de recherche actuels."
msgstr "Geen enkele opdracht voldoet aan de huidige zoekcriteria."
#: contracts/templates/contracts/contracts_index.html
msgid "Affichage de <strong>%(start)s</strong> à <strong>%(end)s</strong> sur <strong>%(total)s</strong> marché(s)"
msgstr "Weergave van <strong>%(start)s</strong> tot <strong>%(end)s</strong> van <strong>%(total)s</strong> opdracht(en)"
#: contracts/templates/contracts/contracts_index.html
msgid "Pagination des marchés"
msgstr "Paginering van opdrachten"

View file

@ -0,0 +1,831 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap');
/* Main Page Container */
.contracts-page-container {
max-width: 1440px;
margin: 1.5rem auto 3rem auto;
padding: 0 1.25rem;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Page Header */
.contracts-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.25rem;
}
.contracts-tabs {
border-bottom: 2px solid #e2e8f0;
margin-bottom: 0;
}
.contracts-tabs .nav-link {
font-size: 0.95rem;
font-weight: 600;
color: #64748b;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
padding: 0.65rem 1.25rem;
background: transparent;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.contracts-tabs .nav-link:hover {
color: #0f172a;
border-color: #cbd5e1;
}
.contracts-tabs .nav-link.active {
color: #0f172a;
border-bottom-color: #0f172a;
background: transparent;
}
.tab-counter-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 2px 7px;
border-radius: 9999px;
background-color: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
}
.contracts-tabs .nav-link.active .tab-counter-badge {
background-color: #0f172a;
color: #ffffff;
border-color: #0f172a;
}
/* Primary Action Button */
.btn-new-order {
background-color: #0f172a;
color: #ffffff;
border: 1px solid #0f172a;
border-radius: 9px;
padding: 0.5rem 1rem;
font-size: 0.88rem;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 0.5rem;
transition: all 0.15s ease;
text-decoration: none;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.btn-new-order:hover {
background-color: #1e293b;
color: #ffffff;
border-color: #1e293b;
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(15, 23, 42, 0.12);
}
/* Toolbar: Search, Filters & Settings */
.contracts-toolbar {
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0.85rem 1.15rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.85rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
}
.search-widget {
position: relative;
flex: 1 1 360px;
max-width: 550px;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
width: 100%;
}
.search-icon-left {
position: absolute;
left: 0.85rem;
color: #94a3b8;
font-size: 0.95rem;
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.5rem 2.25rem 0.5rem 2.35rem;
font-size: 0.88rem;
border: 1.5px solid #e2e8f0;
border-radius: 9999px;
background-color: #f8fafc;
color: #0f172a;
transition: all 0.15s ease;
}
.search-input:focus {
outline: none;
background-color: #ffffff;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.search-clear-btn {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #94a3b8;
padding: 2px 4px;
cursor: pointer;
font-size: 0.8rem;
line-height: 1;
display: none;
transition: color 0.15s ease;
}
.search-clear-btn:hover {
color: #0f172a;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 0.65rem;
}
.filter-trigger-btn {
background-color: #ffffff;
border: 1.5px solid #e2e8f0;
border-radius: 9999px;
padding: 0.45rem 1rem;
font-size: 0.85rem;
font-weight: 600;
color: #334155;
display: inline-flex;
align-items: center;
gap: 0.45rem;
transition: all 0.15s ease;
}
.filter-trigger-btn:hover {
background-color: #f8fafc;
border-color: #cbd5e1;
color: #0f172a;
}
.filter-count-badge {
background-color: #0f172a;
color: #ffffff;
font-size: 0.7rem;
font-weight: 700;
border-radius: 9999px;
padding: 1px 6px;
margin-left: 2px;
}
.page-size-selector {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
color: #64748b;
}
.page-size-select {
padding: 0.35rem 0.65rem;
font-size: 0.82rem;
border: 1.5px solid #e2e8f0;
border-radius: 7px;
background-color: #ffffff;
color: #334155;
cursor: pointer;
font-weight: 500;
}
.page-size-select:focus {
outline: none;
border-color: #2563eb;
}
/* Active Filters Pills */
.active-filters-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0 0.25rem;
}
.active-filter-label {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #94a3b8;
margin-right: 0.25rem;
}
.active-filter-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.65rem;
font-size: 0.78rem;
font-weight: 500;
border-radius: 9999px;
background-color: #f1f5f9;
border: 1px solid #e2e8f0;
color: #334155;
transition: all 0.12s ease;
}
.active-filter-chip .remove-filter-btn {
border: none;
background: none;
padding: 0;
margin-left: 2px;
color: #94a3b8;
cursor: pointer;
display: inline-flex;
align-items: center;
font-size: 0.75rem;
line-height: 1;
}
.active-filter-chip .remove-filter-btn:hover {
color: #dc2626;
}
.btn-clear-all-filters {
font-size: 0.78rem;
color: #dc2626;
text-decoration: none;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: background-color 0.12s ease;
}
.btn-clear-all-filters:hover {
background-color: #fef2f2;
color: #b91c1c;
}
/* Table Container & Design */
.contracts-table-card {
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 14px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
overflow: hidden;
}
.table-responsive {
margin-bottom: 0;
}
.contracts-table {
width: 100%;
margin-bottom: 0;
border-collapse: separate;
border-spacing: 0;
}
.contracts-table thead th {
background-color: #f8fafc;
color: #475569;
font-size: 0.74rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.75rem 1rem;
border-bottom: 1.5px solid #e2e8f0;
vertical-align: middle;
white-space: nowrap;
}
.contracts-table thead th.sortable {
cursor: pointer;
user-select: none;
transition: background-color 0.12s ease;
}
.contracts-table thead th.sortable:hover {
background-color: #f1f5f9;
color: #0f172a;
}
.sort-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
text-decoration: none;
color: inherit;
}
.contracts-table tbody tr {
transition: background-color 0.12s ease;
}
.contracts-table tbody tr:hover {
background-color: #f8fafc;
}
.contracts-table tbody td {
padding: 0.65rem 1rem;
font-size: 0.85rem;
color: #334155;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.contracts-table tbody tr:last-child td {
border-bottom: none;
}
/* Code Link Badge */
.table-code-link {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
font-size: 0.82rem !important;
font-weight: 600 !important;
color: #0f172a !important;
text-decoration: none !important;
white-space: nowrap !important;
display: inline-block;
padding: 3px 8px;
border-radius: 6px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
transition: all 0.15s ease;
}
.table-code-link:hover {
color: #2563eb !important;
border-color: #93c5fd;
background-color: #eff6ff;
}
/* Table Action Button */
.table-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
color: #64748b;
background-color: transparent;
border: 1px solid transparent;
transition: all 0.15s ease;
text-decoration: none;
}
.table-action-btn:hover {
color: #0f172a;
background-color: #f1f5f9;
border-color: #cbd5e1;
}
/* Status Badges */
.status-badge {
font-size: 0.74rem;
font-weight: 600;
padding: 0.32rem 0.7rem;
border-radius: 9999px;
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid transparent;
white-space: nowrap;
}
.status-badge.pending {
background-color: #fffbeb;
color: #b45309;
border-color: #fde68a;
}
.status-badge.sent {
background-color: #f0f9ff;
color: #0284c7;
border-color: #bae6fd;
}
.status-badge.completed {
background-color: #f0fdf4;
color: #16a34a;
border-color: #bbf7d0;
}
.status-badge.invoiced {
background-color: #faf5ff;
color: #7c3aed;
border-color: #e9d5ff;
}
.status-badge.paid {
background-color: #ecfdf5;
color: #059669;
border-color: #a7f3d0;
}
.status-badge.cancelled {
background-color: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
/* File Icon Link */
.file-download-btn {
color: #dc2626;
font-size: 1.15rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 6px;
transition: all 0.15s ease;
}
.file-download-btn:hover {
color: #b91c1c;
background-color: #fef2f2;
}
/* Empty State */
.table-empty-state {
padding: 4rem 1.5rem;
text-align: center;
color: #64748b;
}
.empty-icon {
font-size: 3rem;
color: #cbd5e1;
margin-bottom: 1rem;
}
.empty-title {
font-size: 1.1rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 0.35rem;
}
.empty-desc {
font-size: 0.88rem;
max-width: 420px;
margin: 0 auto 1.25rem auto;
}
/* Pagination Bar */
.contracts-pagination-bar {
padding: 0.85rem 1.25rem;
background-color: #ffffff;
border-top: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.pagination-info {
font-size: 0.85rem;
color: #64748b;
}
.pagination-nav .pagination {
margin-bottom: 0;
gap: 3px;
}
.pagination-nav .page-link {
border-radius: 7px;
border: 1px solid #e2e8f0;
color: #334155;
font-size: 0.82rem;
font-weight: 500;
padding: 0.35rem 0.65rem;
min-width: 32px;
text-align: center;
transition: all 0.15s ease;
}
.pagination-nav .page-link:hover {
background-color: #f1f5f9;
color: #0f172a;
border-color: #cbd5e1;
}
.pagination-nav .page-item.active .page-link {
background-color: #0f172a;
border-color: #0f172a;
color: #ffffff;
font-weight: 600;
}
.pagination-nav .page-item.disabled .page-link {
color: #94a3b8;
background-color: #f8fafc;
border-color: #e2e8f0;
}
/* ─── Airbnb-style Modern Filter Modal ──────────────────── */
#filterModal .modal-dialog {
max-width: 820px;
margin: 1.5rem auto;
}
#filterModal .modal-content {
border: 1px solid #e2e8f0;
border-radius: 18px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
max-height: 88vh;
overflow: hidden;
background-color: #ffffff;
}
#filterModal .modal-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #f1f5f9;
background-color: #ffffff;
}
#filterModal .modal-title {
font-size: 1.25rem;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.02em;
}
#filterModal .btn-close {
font-size: 0.85rem;
padding: 0.5rem;
opacity: 0.5;
transition: opacity 0.15s ease;
}
#filterModal .btn-close:hover {
opacity: 1;
}
#filterModal .modal-body {
padding: 1.5rem;
background-color: #f8fafc;
flex: 1;
overflow-y: auto;
}
#filterModal .modal-footer {
padding: 1rem 1.5rem;
background-color: #ffffff;
border-top: 1px solid #f1f5f9;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Filter Sections */
.filter-section {
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 1.15rem;
margin-bottom: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.filter-section:hover {
border-color: #cbd5e1;
}
.filter-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.85rem;
gap: 1rem;
}
.filter-section-header label,
.filter-section-header .section-title {
font-size: 0.95rem;
font-weight: 700;
color: #0f172a;
margin: 0;
letter-spacing: -0.01em;
}
.filter-section-buttons {
display: flex;
gap: 0.35rem;
margin-left: auto;
}
.filter-section-buttons button {
padding: 0.25rem 0.65rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 6px;
border: 1px solid #e2e8f0;
background-color: #ffffff;
color: #64748b;
transition: all 0.15s ease;
}
.filter-section-buttons button:hover {
background-color: #f1f5f9;
color: #0f172a;
border-color: #cbd5e1;
}
/* Filter Chips */
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.filter-option {
display: inline-flex;
align-items: center;
}
.filter-option input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.filter-option label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.85rem;
font-size: 0.82rem;
font-weight: 500;
color: #334155;
background-color: #ffffff;
border: 1.5px solid #e2e8f0;
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
white-space: nowrap;
margin: 0;
}
.filter-option label:hover {
background-color: #f8fafc;
border-color: #94a3b8;
color: #0f172a;
}
/* Default Chips Checked (Thematics) */
.filter-option input[type="checkbox"]:checked + label:not(.context-badge) {
background-color: #0f172a !important;
border-color: #0f172a !important;
color: #ffffff !important;
font-weight: 600;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
}
/* Contextual Status Badges (Filter Modal) */
.filter-option label.context-badge {
background-color: #ffffff;
border-width: 1.5px;
border-style: solid;
font-weight: 600;
}
.filter-option label.context-badge.pending { border-color: #f59e0b; color: #d97706; }
.filter-option label.context-badge.sent { border-color: #0284c7; color: #0284c7; }
.filter-option label.context-badge.completed { border-color: #16a34a; color: #16a34a; }
.filter-option label.context-badge.invoiced { border-color: #7c3aed; color: #7c3aed; }
.filter-option label.context-badge.paid { border-color: #059669; color: #059669; }
.filter-option label.context-badge.cancelled { border-color: #dc2626; color: #dc2626; }
.filter-option input[type="checkbox"]:not(:checked):hover + label.context-badge.pending { background-color: #fffbeb; }
.filter-option input[type="checkbox"]:not(:checked):hover + label.context-badge.sent { background-color: #f0f9ff; }
.filter-option input[type="checkbox"]:not(:checked):hover + label.context-badge.completed { background-color: #f0fdf4; }
.filter-option input[type="checkbox"]:not(:checked):hover + label.context-badge.invoiced { background-color: #faf5ff; }
.filter-option input[type="checkbox"]:not(:checked):hover + label.context-badge.paid { background-color: #ecfdf5; }
.filter-option input[type="checkbox"]:not(:checked):hover + label.context-badge.cancelled { background-color: #fef2f2; }
.filter-option input[type="checkbox"]:checked + label.context-badge {
color: #ffffff !important;
font-weight: 600;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.filter-option input[type="checkbox"]:checked + label.context-badge.pending { background-color: #f59e0b !important; border-color: #f59e0b !important; }
.filter-option input[type="checkbox"]:checked + label.context-badge.sent { background-color: #0284c7 !important; border-color: #0284c7 !important; }
.filter-option input[type="checkbox"]:checked + label.context-badge.completed { background-color: #16a34a !important; border-color: #16a34a !important; }
.filter-option input[type="checkbox"]:checked + label.context-badge.invoiced { background-color: #7c3aed !important; border-color: #7c3aed !important; }
.filter-option input[type="checkbox"]:checked + label.context-badge.paid { background-color: #059669 !important; border-color: #059669 !important; }
.filter-option input[type="checkbox"]:checked + label.context-badge.cancelled { background-color: #dc2626 !important; border-color: #dc2626 !important; }
/* Contracts scrollable list in Filter Modal */
.contracts-filter-container {
background-color: #ffffff;
border: 1.5px solid #e2e8f0;
border-radius: 10px;
padding: 0.5rem;
}
.contracts-filter-search-box {
position: relative;
margin-bottom: 0.5rem;
}
.contracts-filter-search-box input {
padding-left: 2rem;
font-size: 0.82rem;
border-radius: 7px;
border-color: #e2e8f0;
}
.contracts-filter-search-box .search-icon {
position: absolute;
left: 0.65rem;
top: 50%;
transform: translateY(-50%);
color: #94a3b8;
font-size: 0.82rem;
}
.contracts-filter-scroll {
max-height: 200px;
overflow-y: auto;
padding-right: 0.25rem;
}
.contract-filter-item {
padding: 0.35rem 0.5rem;
border-radius: 6px;
transition: background-color 0.12s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.contract-filter-item:hover {
background-color: #f8fafc;
}
.contract-filter-item label {
margin-bottom: 0;
font-size: 0.84rem;
color: #334155;
cursor: pointer;
width: 100%;
}
@media (max-width: 768px) {
.contracts-header {
flex-direction: column;
align-items: stretch;
}
.toolbar-actions {
width: 100%;
justify-content: space-between;
}
.search-widget {
max-width: 100%;
}
.contracts-pagination-bar {
flex-direction: column;
align-items: center;
text-align: center;
}
}

View file

@ -0,0 +1,155 @@
document.addEventListener('DOMContentLoaded', function() {
// ─── Recherche principale ───
const searchInput = document.getElementById('main-search-input');
const searchClearBtn = document.getElementById('main-search-clear');
const searchBtn = document.getElementById('main-search-btn');
function toggleClearBtn() {
if (!searchClearBtn) return;
if (searchInput && searchInput.value.trim().length > 0) {
searchClearBtn.style.display = 'block';
} else {
searchClearBtn.style.display = 'none';
}
}
if (searchInput) {
toggleClearBtn();
searchInput.addEventListener('input', toggleClearBtn);
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
executeSearch(searchInput.value.trim());
}
});
}
if (searchBtn && searchInput) {
searchBtn.addEventListener('click', function() {
executeSearch(searchInput.value.trim());
});
}
if (searchClearBtn) {
searchClearBtn.addEventListener('click', function() {
if (searchInput) {
searchInput.value = '';
toggleClearBtn();
}
executeSearch('');
});
}
function executeSearch(query) {
const url = new URL(window.location.href);
if (query) {
url.searchParams.set('search', query);
} else {
url.searchParams.delete('search');
}
url.searchParams.delete('page');
url.searchParams.delete('contracts_page');
window.location.href = url.toString();
}
// ─── Sélecteur de taille de page ───
const pageSizeSelect = document.getElementById('page-size-select');
if (pageSizeSelect) {
pageSizeSelect.addEventListener('change', function() {
const url = new URL(window.location.href);
url.searchParams.set('page_size', this.value);
url.searchParams.delete('page');
window.location.href = url.toString();
});
}
// ─── Suppression rapide des filtres actifs (chips) ───
document.querySelectorAll('.remove-filter-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
const param = this.dataset.param;
const val = this.dataset.val;
const url = new URL(window.location.href);
if (param === 'search' || param === 'date_from' || param === 'date_to') {
url.searchParams.delete(param);
} else {
const remaining = url.searchParams.getAll(param).filter(v => v !== val);
url.searchParams.delete(param);
remaining.forEach(v => url.searchParams.append(param, v));
}
url.searchParams.delete('page');
url.searchParams.delete('contracts_page');
window.location.href = url.toString();
});
});
// ─── Modal de Filtres : Boutons "Tous" / "Aucun" ───
document.querySelectorAll('.select-all-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const section = this.closest('.filter-section');
if (section) {
section.querySelectorAll('input[type="checkbox"]').forEach(function(cb) {
cb.checked = true;
});
}
});
});
document.querySelectorAll('.deselect-all-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const section = this.closest('.filter-section');
if (section) {
section.querySelectorAll('input[type="checkbox"]').forEach(function(cb) {
cb.checked = false;
});
}
});
});
// ─── Filtrage rapide dans la liste des marchés dans la modale ───
const contractSearchInput = document.getElementById('contract-filter-search');
if (contractSearchInput) {
contractSearchInput.addEventListener('input', function() {
const term = this.value.toLowerCase().trim();
document.querySelectorAll('.contract-filter-item').forEach(function(item) {
const text = item.textContent.toLowerCase();
if (!term || text.includes(term)) {
item.style.display = 'flex';
} else {
item.style.display = 'none';
}
});
});
}
// ─── Synchronisation de l'onglet actif ───
const tabButtons = document.querySelectorAll('button[data-bs-toggle="tab"]');
const filterTabInput = document.getElementById('filter-tab-input');
tabButtons.forEach(function(btn) {
btn.addEventListener('shown.bs.tab', function(e) {
const targetId = e.target.getAttribute('data-bs-target');
const tabName = targetId === '#contracts-content' ? 'contracts' : 'orders';
if (filterTabInput) {
filterTabInput.value = tabName;
}
const url = new URL(window.location.href);
url.searchParams.set('tab', tabName);
window.history.replaceState({}, '', url);
});
});
// Restaurer l'onglet actif si spécifié dans l'URL
const currentTabParam = new URLSearchParams(window.location.search).get('tab');
if (currentTabParam === 'contracts') {
const contractsTabBtn = document.getElementById('contracts-tab');
if (contractsTabBtn && window.bootstrap && bootstrap.Tab) {
bootstrap.Tab.getOrCreateInstance(contractsTabBtn).show();
}
}
});

View file

@ -3,298 +3,512 @@
{% load i18n %}
{% block head %}
<style>
.status-badge.pending { background-color: #ffc107; color: black; }
.status-badge.sent { background-color: #0dcaf0; color: white; }
.status-badge.completed { background-color: #198754; color: white; }
.status-badge.invoiced { background-color: #6f42c1; color: white; }
.status-badge.paid { background-color: #28a745; color: white; }
.status-badge.cancelled { background-color: #dc3545; color: white; }
/* Styles pour les badges de filtre - Pattern inspiré des interventions */
.filter-section {
margin-bottom: 1rem;
}
.filter-option {
display: inline-block;
margin-right: 0.5rem;
}
.filter-option input[type="checkbox"] {
display: none;
}
.filter-option label {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.4rem 0.75rem;
font-weight: 500;
cursor: pointer;
border: 1px solid currentColor;
border-radius: 0.375rem;
background-color: transparent;
transition: all 0.2s ease;
user-select: none;
font-size: 0.875rem;
}
/* Couleurs des badges par statut - Inactive state (outline) */
.filter-option label.context-badge.pending {
border-color: #ffc107;
color: #ffc107;
}
.filter-option label.context-badge.sent {
border-color: #0dcaf0;
color: #0dcaf0;
}
.filter-option label.context-badge.completed {
border-color: #198754;
color: #198754;
}
.filter-option label.context-badge.invoiced {
border-color: #6f42c1;
color: #6f42c1;
}
.filter-option label.context-badge.paid {
border-color: #28a745;
color: #28a745;
}
.filter-option label.context-badge.cancelled {
border-color: #dc3545;
color: #dc3545;
}
/* Checked state - Filled */
.filter-option input[type="checkbox"]:checked + label.context-badge {
color: white;
}
.filter-option input[type="checkbox"]:checked + label.context-badge.pending {
background-color: #ffc107;
border-color: #ffc107;
}
.filter-option input[type="checkbox"]:checked + label.context-badge.sent {
background-color: #0dcaf0;
border-color: #0dcaf0;
}
.filter-option input[type="checkbox"]:checked + label.context-badge.completed {
background-color: #198754;
border-color: #198754;
}
.filter-option input[type="checkbox"]:checked + label.context-badge.invoiced {
background-color: #6f42c1;
border-color: #6f42c1;
}
.filter-option input[type="checkbox"]:checked + label.context-badge.paid {
background-color: #28a745;
border-color: #28a745;
}
.filter-option input[type="checkbox"]:checked + label.context-badge.cancelled {
background-color: #dc3545;
border-color: #dc3545;
}
/* Hover state */
.filter-option input[type="checkbox"]:hover + label.context-badge {
opacity: 0.8;
}
</style>
<link rel="stylesheet" href="{% static 'contracts/css/contracts_index.css' %}">
{% endblock head %}
{% block content %}
<div class="container mt-5 position-relative">
<div class="position-absolute" style="top: 0px; right: 5px;">
{% if is_add_order_allowed == True %}
<a href="{% url 'contracts:contracts_orders_add' %}" class="btn btn-light">
<i class="bi bi-plus-circle"></i> {% translate "Nouvelle commande" %}
<!-- ─── Modale de Filtres ──────────────────────────────────────── -->
<div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title" id="filterModalLabel">{% translate "Filtrer les commandes et marchés" %}</h5>
<div class="text-muted small" style="font-size: 0.8rem;">{% translate "Sélectionnez les critères de recherche" %}</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<form method="get" action="{% url 'contracts:contracts_index' %}" id="filter-form">
<input type="hidden" name="tab" id="filter-tab-input" value="{{ active_tab }}">
{% if search_query %}<input type="hidden" name="search" value="{{ search_query }}">{% endif %}
{% if sort_field %}<input type="hidden" name="sort" value="{{ sort_field }}">{% endif %}
{% if sort_dir %}<input type="hidden" name="dir" value="{{ sort_dir }}">{% endif %}
{% if page_size %}<input type="hidden" name="page_size" value="{{ page_size }}">{% endif %}
<!-- Statuts -->
<div class="filter-section">
<div class="filter-section-header">
<label>{% translate "Statut" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn select-all-btn">{% translate "Tous" %}</button>
<button type="button" class="btn deselect-all-btn">{% translate "Aucun" %}</button>
</div>
</div>
<div class="filter-options">
{% for key, label in status_choices %}
<div class="filter-option">
<input type="checkbox" name="status" id="status_{{ key }}" value="{{ key }}" {% if key in selected_statuses %}checked{% endif %}>
<label for="status_{{ key }}" class="context-badge {{ key }}">{{ label }}</label>
</div>
{% endfor %}
</div>
</div>
<!-- Thématiques -->
{% if available_thematics %}
<div class="filter-section">
<div class="filter-section-header">
<label>{% translate "Thématiques" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn select-all-btn">{% translate "Tous" %}</button>
<button type="button" class="btn deselect-all-btn">{% translate "Aucun" %}</button>
</div>
</div>
<div class="filter-options">
{% for thematic in available_thematics %}
<div class="filter-option">
<input type="checkbox" name="thematic" id="thematic_{{ thematic.id }}" value="{{ thematic.id }}" {% if thematic.id|stringformat:"s" in selected_thematics or thematic.code in selected_thematics %}checked{% endif %}>
<label for="thematic_{{ thematic.id }}">
{% if thematic.icon %}<i class="bi {{ thematic.icon }} me-1"></i>{% endif %}{{ thematic.name }}
</label>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Marchés / Contrats -->
{% if available_contracts %}
<div class="filter-section">
<div class="filter-section-header">
<label>{% translate "Marchés" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn select-all-btn">{% translate "Tous" %}</button>
<button type="button" class="btn deselect-all-btn">{% translate "Aucun" %}</button>
</div>
</div>
<div class="contracts-filter-container">
<div class="contracts-filter-search-box">
<i class="bi bi-search search-icon"></i>
<input type="text" id="contract-filter-search" class="form-control form-control-sm" placeholder="{% translate 'Filtrer la liste des marchés...' %}">
</div>
<div class="contracts-filter-scroll">
{% for c in available_contracts %}
<div class="contract-filter-item">
<input type="checkbox" name="contract" id="contract_{{ c.id }}" value="{{ c.id }}" class="form-check-input" {% if c.id|stringformat:"s" in selected_contracts %}checked{% endif %}>
<label for="contract_{{ c.id }}">
<span class="fw-semibold">{{ c.contract_number }}</span> – {{ c.company.name }}
</label>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Période -->
<div class="filter-section mb-0">
<div class="filter-section-header">
<label>{% translate "Période de commande" %}</label>
</div>
<div class="row g-2 align-items-center">
<div class="col-md-6">
<label for="date-from" class="form-label small text-muted mb-1">{% translate "Commandes à partir du" %}</label>
<input type="date" class="form-control form-control-sm" id="date-from" name="date_from" value="{{ date_from }}">
</div>
<div class="col-md-6">
<label for="date-to" class="form-label small text-muted mb-1">{% translate "jusqu'au" %}</label>
<input type="date" class="form-control form-control-sm" id="date-to" name="date_to" value="{{ date_to }}">
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<a href="{% url 'contracts:contracts_index' %}" class="btn btn-link text-muted p-0 text-decoration-none d-flex align-items-center gap-1" style="font-size: 0.85rem; font-weight: 500;">
<i class="bi bi-arrow-counterclockwise"></i> {% translate "Réinitialiser" %}
</a>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary px-3 py-2" data-bs-dismiss="modal" style="border-radius: 8px; font-weight: 500;">{% translate "Annuler" %}</button>
<button type="submit" form="filter-form" class="btn btn-sm btn-dark px-4 py-2" style="background-color: #0f172a; border-color: #0f172a; border-radius: 8px; font-weight: 600;">
<i class="bi bi-check2 me-1"></i>{% translate "Filtrer" %}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- ─── Contenu Principal ─────────────────────────────────────── -->
<div class="contracts-page-container">
<!-- Header avec onglets et bouton d'action -->
<div class="contracts-header">
<ul class="nav nav-tabs contracts-tabs" id="tablesTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link {% if active_tab == 'orders' %}active{% endif %}" id="orders-tab" data-bs-toggle="tab" data-bs-target="#orders-content" type="button" role="tab" aria-controls="orders-content" aria-selected="{% if active_tab == 'orders' %}true{% else %}false{% endif %}">
<i class="bi bi-receipt"></i>
{% translate "Commandes" %}
<span class="tab-counter-badge">{{ orders_paginator.count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if active_tab == 'contracts' %}active{% endif %}" id="contracts-tab" data-bs-toggle="tab" data-bs-target="#contracts-content" type="button" role="tab" aria-controls="contracts-content" aria-selected="{% if active_tab == 'contracts' %}true{% else %}false{% endif %}">
<i class="bi bi-briefcase"></i>
{% translate "Marchés/Contrats" %}
<span class="tab-counter-badge">{{ contracts_paginator.count }}</span>
</button>
</li>
</ul>
{% if is_add_order_allowed %}
<a href="{% url 'contracts:contracts_orders_add' %}" class="btn-new-order">
<i class="bi bi-plus-circle"></i>
{% translate "Nouvelle commande" %}
</a>
{% endif %}
</div>
<ul class="nav nav-tabs" id="tablesTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="orders-tab" data-bs-toggle="tab" data-bs-target="#orders-content" type="button" role="tab" aria-controls="orders" aria-selected="true" style="color: black;">{% translate "Commandes" %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="contracts-tab" data-bs-toggle="tab" data-bs-target="#contracts-content" type="button" role="tab" aria-controls="contracts-table" aria-selected="false" style="color: black;">{% translate "Marchés/Contrats" %}</button>
</li>
</ul>
<div class="tab-content my-2" id="tablesTabContent">
<div class="tab-pane fade show active" id="orders-content" role="tabpanel" aria-labelledby="orders-tab">
<!-- Filtres pour les commandes -->
<div class="card mb-3">
<div class="card-body">
<form method="get" id="filters-form" class="row g-3">
<!-- Statuts - Full width -->
<div class="col-12 filter-section">
<label class="form-label">{% translate "Statuts" %}</label>
<div class="d-flex flex-wrap gap-2">
<!-- Pending -->
<div class="filter-option">
<input type="checkbox" class="form-check-input" name="status" id="status_pending" value="pending" {% if 'pending' in selected_statuses %}checked{% endif %}>
<label for="status_pending" class="context-badge pending">{% translate "En préparation" %}</label>
<!-- Barre d'outils : Recherche et Filtres -->
<div class="contracts-toolbar">
<div class="search-widget">
<div class="search-input-wrapper">
<i class="bi bi-search search-icon-left"></i>
<input type="text" id="main-search-input" class="search-input" value="{{ search_query }}" placeholder="{% translate 'Rechercher une commande, un marché, une description, un prestataire...' %}">
<button type="button" id="main-search-clear" class="search-clear-btn" title="{% translate 'Effacer la recherche' %}">
<i class="bi bi-x-circle-fill"></i>
</button>
</div>
<!-- Sent -->
<div class="filter-option">
<input type="checkbox" class="form-check-input" name="status" id="status_sent" value="sent" {% if 'sent' in selected_statuses %}checked{% endif %}>
<label for="status_sent" class="context-badge sent">{% translate "Envoyé" %}</label>
</div>
<!-- Completed -->
<div class="filter-option">
<input type="checkbox" class="form-check-input" name="status" id="status_completed" value="completed" {% if 'completed' in selected_statuses %}checked{% endif %}>
<label for="status_completed" class="context-badge completed">{% translate "Terminé" %}</label>
</div>
<!-- Invoiced -->
<div class="filter-option">
<input type="checkbox" class="form-check-input" name="status" id="status_invoiced" value="invoiced" {% if 'invoiced' in selected_statuses %}checked{% endif %}>
<label for="status_invoiced" class="context-badge invoiced">{% translate "Comptabilisé" %}</label>
</div>
<!-- Paid -->
<div class="filter-option">
<input type="checkbox" class="form-check-input" name="status" id="status_paid" value="paid" {% if 'paid' in selected_statuses %}checked{% endif %}>
<label for="status_paid" class="context-badge paid">{% translate "Payé" %}</label>
</div>
<!-- Cancelled -->
<div class="filter-option">
<input type="checkbox" class="form-check-input" name="status" id="status_cancelled" value="cancelled" {% if 'cancelled' in selected_statuses %}checked{% endif %}>
<label for="status_cancelled" class="context-badge cancelled">{% translate "Annulé" %}</label>
<div class="toolbar-actions">
<button type="button" class="filter-trigger-btn" data-bs-toggle="modal" data-bs-target="#filterModal">
<i class="bi bi-funnel"></i>
{% translate "Filtres" %}
{% if active_filters_count > 0 %}
<span class="filter-count-badge">{{ active_filters_count }}</span>
{% endif %}
</button>
<div class="page-size-selector">
<label for="page-size-select" class="d-none d-sm-inline">{% translate "Afficher par" %}</label>
<select id="page-size-select" class="page-size-select">
<option value="25" {% if page_size == 25 %}selected{% endif %}>25</option>
<option value="50" {% if page_size == 50 %}selected{% endif %}>50</option>
<option value="100" {% if page_size == 100 %}selected{% endif %}>100</option>
</select>
</div>
</div>
</div>
<!-- Dates - Next row -->
<div class="col-md-6">
<label for="date-from" class="form-label">{% translate "Commandes à partir du" %}</label>
<input type="date" class="form-control" id="date-from" name="date_from" value="{{ request.GET.date_from }}">
</div>
<div class="col-md-6">
<label for="date-to" class="form-label">{% translate "jusqu'au" %}</label>
<input type="date" class="form-control" id="date-to" name="date_to" value="{{ request.GET.date_to }}">
<!-- Puces des filtres actifs -->
{% if active_filters_list %}
<div class="active-filters-bar">
<span class="active-filter-label">{% translate "Filtres actifs" %}:</span>
{% for filter in active_filters_list %}
<span class="active-filter-chip">
{% if filter.is_status %}
<span class="badge rounded-pill status-badge {{ filter.status_class }}" style="padding: 2px 6px; font-size: 0.72rem;">{{ filter.label }}</span>
{% else %}
{{ filter.label }}
{% endif %}
<button type="button" class="remove-filter-btn" data-param="{{ filter.param }}" data-val="{{ filter.val }}" title="{% translate 'Supprimer ce critère' %}">
<i class="bi bi-x-circle-fill"></i>
</button>
</span>
{% endfor %}
<a href="{% url 'contracts:contracts_index' %}" class="btn-clear-all-filters">
<i class="bi bi-x me-1"></i>{% translate "Tout effacer" %}
</a>
</div>
{% endif %}
<!-- Submit buttons -->
<div class="col-12 d-flex align-items-end gap-2">
<button type="submit" class="btn btn-primary">{% translate "Filtrer" %}</button>
<a href="{% url 'contracts:contracts_index' %}" class="btn btn-secondary">{% translate "Réinitialiser" %}</a>
</div>
</form>
</div>
</div>
<div id="orders-container\">
<table id="orders-table" class="table"
data-height="800"
data-pagination="true"
data-page-size="50"
data-click-to-select="true"
data-search="true" >
<!-- Contenu des onglets -->
<div class="tab-content" id="tablesTabContent">
<!-- ─── Onglet Commandes ──────────────────────────────────── -->
<div class="tab-pane fade {% if active_tab == 'orders' %}show active{% endif %}" id="orders-content" role="tabpanel" aria-labelledby="orders-tab">
<div class="contracts-table-card">
<div class="table-responsive">
<table class="contracts-table">
<thead>
<tr>
<th data-field="actions" data-width="100"></th>
<th data-sortable="true">{% translate "Numéro de commande" %}</th>
<th>{% translate "Description" %}</th>
<th>{% translate "Date" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Fichier" %}</th>
<th data-sortable="true">{% translate "Marché" %}</th>
<th style="width: 48px;"></th>
<th class="sortable">
<a href="{{ sort_urls.code }}" class="sort-link">
<span>{% translate "Numéro de commande" %}</span>
<i class="bi {% if sort_field == 'code' %}{% if sort_dir == 'asc' %}bi-sort-up text-primary{% else %}bi-sort-down text-primary{% endif %}{% else %}bi-arrow-down-up text-muted opacity-50{% endif %}"></i>
</a>
</th>
<th class="sortable">
<a href="{{ sort_urls.description }}" class="sort-link">
<span>{% translate "Description" %}</span>
<i class="bi {% if sort_field == 'description' %}{% if sort_dir == 'asc' %}bi-sort-up text-primary{% else %}bi-sort-down text-primary{% endif %}{% else %}bi-arrow-down-up text-muted opacity-50{% endif %}"></i>
</a>
</th>
<th class="sortable">
<a href="{{ sort_urls.date }}" class="sort-link">
<span>{% translate "Date" %}</span>
<i class="bi {% if sort_field == 'date' %}{% if sort_dir == 'asc' %}bi-sort-up text-primary{% else %}bi-sort-down text-primary{% endif %}{% else %}bi-arrow-down-up text-muted opacity-50{% endif %}"></i>
</a>
</th>
<th class="sortable">
<a href="{{ sort_urls.status }}" class="sort-link">
<span>{% translate "Statut" %}</span>
<i class="bi {% if sort_field == 'status' %}{% if sort_dir == 'asc' %}bi-sort-up text-primary{% else %}bi-sort-down text-primary{% endif %}{% else %}bi-arrow-down-up text-muted opacity-50{% endif %}"></i>
</a>
</th>
{% if can_view_prices %}
<th class="sortable text-end">
<a href="{{ sort_urls.amount }}" class="sort-link justify-content-end">
<span>{% translate "Montant" %}</span>
<i class="bi {% if sort_field == 'amount' %}{% if sort_dir == 'asc' %}bi-sort-up text-primary{% else %}bi-sort-down text-primary{% endif %}{% else %}bi-arrow-down-up text-muted opacity-50{% endif %} ms-1"></i>
</a>
</th>
{% endif %}
<th style="width: 60px; text-align: center;">{% translate "Fichier" %}</th>
<th class="sortable">
<a href="{{ sort_urls.contract }}" class="sort-link">
<span>{% translate "Marché" %}</span>
<i class="bi {% if sort_field == 'contract' %}{% if sort_dir == 'asc' %}bi-sort-up text-primary{% else %}bi-sort-down text-primary{% endif %}{% else %}bi-arrow-down-up text-muted opacity-50{% endif %}"></i>
</a>
</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
{% for order in orders_page %}
<tr>
<td>
<a href="/contracts/orders/{{ order.id }}" type="button" class="btn btn-sm btn-light detail-btn" data-id="${row.id}" data-bs-toggle="tooltip" title="Detail">
<td class="text-center">
<a href="/contracts/orders/{{ order.id }}" class="table-action-btn" title="{% translate 'Détail de la commande' %}">
<i class="bi bi-info-circle"></i>
</a>
</td>
<td>{{ order.order_code }}</td>
<td title="{{ order.description }}">{{ order.description|truncatechars:100 }}</td>
<td>{{ order.order_date }}</td>
<td>
<span class="badge rounded-pill status-badge {{ order.order_status }}">
<a href="/contracts/orders/{{ order.id }}" class="table-code-link">
{{ order.order_code }}
</a>
</td>
<td>
<span title="{{ order.description }}">{{ order.description|default:"-"|truncatechars:90 }}</span>
</td>
<td class="text-nowrap">{{ order.order_date|date:"d/m/Y" }}</td>
<td>
<span class="status-badge {{ order.order_status }}">
{{ order.get_order_status_display }}
</span>
</td>
<td>{% if order.order_file %}<a href="{{ order.order_file.url }}"><i class="bi bi-file-pdf"></i></a>{% endif %}</td>
<td>{{ order.contract }}</td>
{% if can_view_prices %}
<td class="text-end text-nowrap fw-semibold">
{% if order.total_amount %}
{{ order.total_amount }} €
{% else %}
<span class="text-muted font-monospace">-</span>
{% endif %}
</td>
{% endif %}
<td class="text-center">
{% if order.order_file %}
<a href="{{ order.order_file.url }}" target="_blank" class="file-download-btn" title="{% translate 'Télécharger le bon de commande' %}">
<i class="bi bi-file-earmark-pdf"></i>
</a>
{% else %}
<span class="text-muted opacity-50">–</span>
{% endif %}
</td>
<td>
<a href="/contracts/{{ order.contract.id }}" class="text-decoration-none text-dark fw-medium">
{{ order.contract.contract_number }}
</a>
<span class="text-muted small d-block">{{ order.contract.company.name }}</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="{% if can_view_prices %}8{% else %}7{% endif %}">
<div class="table-empty-state">
<div class="empty-icon"><i class="bi bi-inbox"></i></div>
<div class="empty-title">{% translate "Aucune commande trouvée" %}</div>
<div class="empty-desc">{% translate "Aucune commande ne correspond à vos critères de recherche actuels." %}</div>
<a href="{% url 'contracts:contracts_index' %}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Réinitialiser les filtres" %}
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Barre de pagination Commandes -->
{% if orders_paginator.count > 0 %}
<div class="contracts-pagination-bar">
<div class="pagination-info">
{% blocktranslate with start=orders_page.start_index end=orders_page.end_index total=orders_paginator.count %}
Affichage de <strong>{{ start }}</strong> à <strong>{{ end }}</strong> sur <strong>{{ total }}</strong> commande(s)
{% endblocktranslate %}
</div>
<div class="tab-pane fade show" id="contracts-content" role="tabpanel" aria-labelledby="contracts-tab">
<div id="contracts-container">
<table id="contracts-table" class="table"
data-height="500"
data-pagination="true"
data-page-size="25"
data-click-to-select="true"
data-search="true">
{% if orders_page.has_other_pages %}
<nav class="pagination-nav" aria-label="{% translate 'Pagination des commandes' %}">
<ul class="pagination pagination-sm">
{% if orders_page.has_previous %}
<li class="page-item">
<a class="page-link" href="?{{ orders_querystring }}&page={{ orders_page.previous_page_number }}" aria-label="{% translate 'Précédent' %}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</li>
{% endif %}
{% for page_num in orders_elided_range %}
{% if page_num == orders_paginator.ELLIPSIS %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% else %}
<li class="page-item {% if page_num == orders_page.number %}active{% endif %}">
<a class="page-link" href="?{{ orders_querystring }}&page={{ page_num }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if orders_page.has_next %}
<li class="page-item">
<a class="page-link" href="?{{ orders_querystring }}&page={{ orders_page.next_page_number }}" aria-label="{% translate 'Suivant' %}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% endif %}
</div>
</div>
<!-- ─── Onglet Marchés / Contrats ────────────────────────── -->
<div class="tab-pane fade {% if active_tab == 'contracts' %}show active{% endif %}" id="contracts-content" role="tabpanel" aria-labelledby="contracts-tab">
<div class="contracts-table-card">
<div class="table-responsive">
<table class="contracts-table">
<thead>
<tr>
<th data-field="actions" data-width="100"></th>
<th style="width: 48px;"></th>
<th>{% translate "Numéro" %}</th>
<th>{% translate "Description" %}</th>
<th>{% translate "Thématiques" %}</th>
<th>{% translate "Prestataire" %}</th>
<th>{% translate "Début" %}</th>
<th>{% translate "Fin" %}</th>
</tr>
</thead>
<tbody>
{% for contract in contracts %}
{% for contract in contracts_page %}
<tr>
<td>
<a href="/contracts/{{ contract.id }}" type="button" class="btn btn-sm btn-light detail-btn" data-id="${row.id}" data-bs-toggle="tooltip" title="Detail">
<td class="text-center">
<a href="/contracts/{{ contract.id }}" class="table-action-btn" title="{% translate 'Détail du contrat' %}">
<i class="bi bi-info-circle"></i>
</a>
</td>
<td>{{ contract.contract_number }}</td>
<td title="{{ contract.description }}">{{ contract.description|truncatechars:100 }}</td>
<td>{{ contract.company.name }}</td>
<td>{{ contract.start_date }}</td>
<td>{{ contract.end_date }}</td>
<td>
<a href="/contracts/{{ contract.id }}" class="table-code-link">
{{ contract.contract_number }}
</a>
</td>
<td>
<span title="{{ contract.description }}">{{ contract.description|default:"-"|truncatechars:90 }}</span>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{% for thematic in contract.thematics.all %}
<span class="badge rounded-pill bg-light text-dark border" style="font-size: 0.72rem; font-weight: 500;">
{% if thematic.icon %}<i class="bi {{ thematic.icon }} me-1"></i>{% endif %}{{ thematic.name }}
</span>
{% empty %}
<span class="text-muted small">–</span>
{% endfor %}
</div>
</td>
<td class="fw-medium">{{ contract.company.name }}</td>
<td class="text-nowrap">{{ contract.start_date|date:"d/m/Y" }}</td>
<td class="text-nowrap">{{ contract.end_date|date:"d/m/Y" }}</td>
</tr>
{% empty %}
<tr>
<td colspan="7">
<div class="table-empty-state">
<div class="empty-icon"><i class="bi bi-inbox"></i></div>
<div class="empty-title">{% translate "Aucun marché trouvé" %}</div>
<div class="empty-desc">{% translate "Aucun marché ne correspond à vos critères de recherche actuels." %}</div>
<a href="{% url 'contracts:contracts_index' %}?tab=contracts" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Réinitialiser les filtres" %}
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Barre de pagination Marchés -->
{% if contracts_paginator.count > 0 %}
<div class="contracts-pagination-bar">
<div class="pagination-info">
{% blocktranslate with start=contracts_page.start_index end=contracts_page.end_index total=contracts_paginator.count %}
Affichage de <strong>{{ start }}</strong> à <strong>{{ end }}</strong> sur <strong>{{ total }}</strong> marché(s)
{% endblocktranslate %}
</div>
{% if contracts_page.has_other_pages %}
<nav class="pagination-nav" aria-label="{% translate 'Pagination des marchés' %}">
<ul class="pagination pagination-sm">
{% if contracts_page.has_previous %}
<li class="page-item">
<a class="page-link" href="?{{ contracts_querystring }}&contracts_page={{ contracts_page.previous_page_number }}" aria-label="{% translate 'Précédent' %}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</li>
{% endif %}
{% for page_num in contracts_elided_range %}
{% if page_num == contracts_paginator.ELLIPSIS %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% else %}
<li class="page-item {% if page_num == contracts_page.number %}active{% endif %}">
<a class="page-link" href="?{{ contracts_querystring }}&contracts_page={{ page_num }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if contracts_page.has_next %}
<li class="page-item">
<a class="page-link" href="?{{ contracts_querystring }}&contracts_page={{ contracts_page.next_page_number }}" aria-label="{% translate 'Suivant' %}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock content %}
{% block script %}
<script>
$(document).ready(function() {
// Gestion des badges de statut
$('#status-badges label').on('click', function(e) {
// Éviter de double-trigger si on clique sur le checkbox
if (e.target.tagName === 'INPUT') {
return;
}
$(this).find('input[type="checkbox"]').prop('checked', function(i, val) {
return !val;
});
});
$('#orders-table').bootstrapTable({
formatNoMatches: function() {
return '{% translate "Aucune commande trouvée" %}';
}
});
$('#contracts-table').bootstrapTable({
formatNoMatches: function() {
return '{% translate "Aucun marché trouvé" %}';
}
});
});
</script>
<script src="{% static 'contracts/js/contracts_index.js' %}"></script>
{% endblock script %}

View file

@ -802,6 +802,121 @@ class GlobalPostsAndPricesPermissionsTests(TestCase):
self.assertEqual(data['posts'][0]['unit_price'], '10.50')
class ContractsIndexViewTests(TestCase):
def setUp(self):
from decimal import Decimal
from common.models import Thematic, Role, UserConfig
from contracts.models import ContractOrder
from datetime import date, timedelta
self.user = User.objects.create_superuser(username="admin_contracts", password="password123")
self.config = UserConfig.objects.create(user=self.user)
role_admin = Role.objects.create(name="admin")
self.config.roles.add(role_admin)
self.company = Company.objects.create(name="Acme Infra")
self.thematic_green = Thematic.objects.create(code="green_test", name_fr="Espaces Verts", name_nl="Groene Ruimten")
self.thematic_roads = Thematic.objects.create(code="roads_test", name_fr="Voiries", name_nl="Wegen")
today = date(2026, 1, 15)
self.contract_green = Contract.objects.create(
company=self.company,
contract_number="CTR-VERT-001",
start_date=today,
end_date=today + timedelta(days=365),
description="Entretien des espaces verts"
)
self.contract_green.thematics.add(self.thematic_green)
self.contract_roads = Contract.objects.create(
company=self.company,
contract_number="CTR-VOIRIE-002",
start_date=today,
end_date=today + timedelta(days=365),
description="Réparation voiries"
)
self.contract_roads.thematics.add(self.thematic_roads)
# Créer 60 commandes pour tester la pagination et les filtres
orders_to_create = []
for i in range(1, 61):
target_contract = self.contract_green if i <= 40 else self.contract_roads
st = 'cancelled' if i == 1 else ('completed' if i % 2 == 0 else 'pending')
orders_to_create.append(ContractOrder(
contract=target_contract,
order_code=f"CMD-{i:03d}",
description=f"Commande spéciale numéro {i}" if i == 5 else f"Travaux réguliers {i}",
order_date=today + timedelta(days=i),
delivery_date=today + timedelta(days=i + 10),
order_status=st,
total_amount=Decimal(f"{100 + i}.00")
))
ContractOrder.objects.bulk_create(orders_to_create)
self.client = Client()
self.client.force_login(self.user)
def test_contracts_index_renders_and_paginates(self):
url = reverse('contracts:contracts_index')
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, 'contracts/contracts_index.html')
# Par défaut, page 1 avec 50 commandes sur les 59 éligibles (1 annulée exclue par défaut)
orders_page = resp.context['orders_page']
self.assertEqual(len(orders_page), 50)
self.assertTrue(orders_page.has_next())
self.assertEqual(resp.context['orders_paginator'].count, 59)
# Page 2
resp_p2 = self.client.get(url, {'page': 2})
self.assertEqual(resp_p2.status_code, 200)
orders_page_2 = resp_p2.context['orders_page']
self.assertEqual(len(orders_page_2), 9)
def test_contracts_index_filter_status(self):
url = reverse('contracts:contracts_index')
resp = self.client.get(url, {'status': ['cancelled']})
self.assertEqual(resp.status_code, 200)
orders_page = resp.context['orders_page']
self.assertEqual(len(orders_page), 1)
self.assertEqual(orders_page[0].order_code, "CMD-001")
def test_contracts_index_filter_thematics(self):
url = reverse('contracts:contracts_index')
resp = self.client.get(url, {'thematic': [str(self.thematic_roads.id)]})
self.assertEqual(resp.status_code, 200)
# Il y a 20 commandes roads (41 à 60), toutes non annulées
self.assertEqual(resp.context['orders_paginator'].count, 20)
def test_contracts_index_filter_contract(self):
url = reverse('contracts:contracts_index')
resp = self.client.get(url, {'contract': [str(self.contract_roads.id)]})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.context['orders_paginator'].count, 20)
def test_contracts_index_search(self):
url = reverse('contracts:contracts_index')
resp = self.client.get(url, {'search': 'spéciale'})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.context['orders_paginator'].count, 1)
self.assertEqual(resp.context['orders_page'][0].order_code, 'CMD-005')
def test_contracts_index_custom_page_size(self):
url = reverse('contracts:contracts_index')
resp = self.client.get(url, {'page_size': 25})
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.context['orders_page']), 25)
def test_contracts_index_sorting(self):
url = reverse('contracts:contracts_index')
resp = self.client.get(url, {'sort': 'code', 'dir': 'asc'})
self.assertEqual(resp.status_code, 200)
# Premier élément doit être CMD-002 car CMD-001 est cancelled
self.assertEqual(resp.context['orders_page'][0].order_code, 'CMD-002')

View file

@ -3,6 +3,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_GET, require_POST
from django.http import JsonResponse, FileResponse, Http404
from django.core.paginator import Paginator
from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.utils.encoding import smart_str
@ -27,6 +28,7 @@ from contracts.models import (
ContractOrderAttachment,
DirectOrderPost,
DIRECT_ORDER_POST_STATUS_CHOICES,
CONTRACT_ORDER_STATUS_CHOICES,
)
from contracts.forms import ContractOrderForm, ContractOrderQuoteQuantityForm, ClaimDeclarationAttachmentForm, ContractOrderAttachmentForm
from contracts.permissions import get_allowed_contracts_for_user, contract_view_required, order_view_required, user_can_generate_order_documents
@ -39,39 +41,251 @@ from contracts.views_pdf_reports import (
generate_documents_for_order,
)
from common.models import UserConfig, UserThematics
from common.models import UserConfig, UserThematics, Thematic
from common.private_files.helpers import build_private_url
from interventions.permissions import can_add_intervention
@login_required
def contracts_index(request):
contracts = get_allowed_contracts_for_user(request.user)
contracts = contracts.select_related('company').prefetch_related('thematics').defer('geom')
active_tab = request.GET.get('tab', 'orders')
if active_tab not in ('orders', 'contracts'):
active_tab = 'orders'
# Choix disponibles pour les filtres
available_thematics = Thematic.objects.filter(contracts__in=contracts).distinct().order_by('name_fr')
available_contracts = contracts.order_by('contract_number')
# Recherche textuelle
search_query = request.GET.get('search', '').strip()
# Filtrage par statut(s)
has_status_param = 'status' in request.GET
status_filters = request.GET.getlist('status')
if status_filters == []:
# Par défaut, afficher les commandes en 'pending' et 'sent'
if not has_status_param:
# Par défaut, afficher les commandes en 'pending', 'sent', 'completed' et 'invoiced'
status_filters = ['pending', 'sent', 'completed', 'invoiced']
orders = orders = ContractOrder.objects.filter(contract__in=contracts, order_status__in=status_filters)
# Filtrage par thématique(s)
selected_thematics = request.GET.getlist('thematic')
# Filtrage par marché(s)
selected_contracts = request.GET.getlist('contract')
# Filtrage par date
date_from = request.GET.get('date_from', '')
date_from = request.GET.get('date_from', '').strip()
date_to = request.GET.get('date_to', '').strip()
# Taille de page
try:
page_size = int(request.GET.get('page_size', 50))
if page_size not in (25, 50, 100):
page_size = 50
except (ValueError, TypeError):
page_size = 50
# Tri des commandes
sort_field = request.GET.get('sort', 'date')
sort_dir = request.GET.get('dir', 'desc')
if sort_dir not in ('asc', 'desc'):
sort_dir = 'desc'
sort_mapping = {
'code': 'order_code',
'description': 'description',
'date': 'order_date',
'status': 'order_status',
'contract': 'contract__contract_number',
'company': 'contract__company__name',
'amount': 'total_amount',
}
db_sort_col = sort_mapping.get(sort_field, 'order_date')
order_by_arg = db_sort_col if sort_dir == 'asc' else f"-{db_sort_col}"
# Construction de la requête commandes optimisée
orders = ContractOrder.objects.filter(contract__in=contracts)
if status_filters:
orders = orders.filter(order_status__in=status_filters)
if selected_thematics:
orders = orders.filter(
Q(contract__thematics__id__in=selected_thematics) |
Q(contract__thematics__code__in=selected_thematics)
)
if selected_contracts:
orders = orders.filter(contract_id__in=selected_contracts)
if date_from:
orders = orders.filter(order_date__gte=date_from)
date_to = request.GET.get('date_to', '')
if date_to:
orders = orders.filter(order_date__lte=date_to)
# Tri par date décroissante
orders = orders.order_by('-order_date')
if search_query:
orders = orders.filter(
Q(order_code__icontains=search_query) |
Q(description__icontains=search_query) |
Q(contract__contract_number__icontains=search_query) |
Q(contract__description__icontains=search_query) |
Q(contract__company__name__icontains=search_query)
)
orders = orders.distinct().select_related(
'contract',
'contract__company',
'ordered_by'
).defer('contract__geom').order_by(order_by_arg, '-id')
# Pagination des commandes
orders_paginator = Paginator(orders, page_size)
orders_page_num = request.GET.get('page', 1)
orders_page = orders_paginator.get_page(orders_page_num)
orders_elided_range = orders_paginator.get_elided_page_range(orders_page.number, on_each_side=2, on_ends=1)
# Construction de la requête marchés/contrats
contracts_qs = contracts
if search_query:
contracts_qs = contracts_qs.filter(
Q(contract_number__icontains=search_query) |
Q(description__icontains=search_query) |
Q(company__name__icontains=search_query)
)
if selected_thematics:
contracts_qs = contracts_qs.filter(
Q(thematics__id__in=selected_thematics) |
Q(thematics__code__in=selected_thematics)
)
contracts_qs = contracts_qs.distinct().order_by('-start_date')
contracts_paginator = Paginator(contracts_qs, 25)
contracts_page_num = request.GET.get('contracts_page', 1)
contracts_page = contracts_paginator.get_page(contracts_page_num)
contracts_elided_range = contracts_paginator.get_elided_page_range(contracts_page.number, on_each_side=2, on_ends=1)
# Liste des filtres actifs pour affichage sous forme de badges
active_filters_list = []
if search_query:
active_filters_list.append({
'type': 'search',
'label': f'"{search_query}"',
'param': 'search',
'val': search_query,
})
if has_status_param:
status_dict = dict(CONTRACT_ORDER_STATUS_CHOICES)
for s in status_filters:
active_filters_list.append({
'type': 'status',
'label': str(status_dict.get(s, s)),
'param': 'status',
'val': s,
'status_class': s,
'is_status': True,
})
if selected_thematics:
thematic_names = {str(t.id): t.name for t in available_thematics}
thematic_names.update({t.code: t.name for t in available_thematics})
for th in selected_thematics:
active_filters_list.append({
'type': 'thematic',
'label': thematic_names.get(str(th), str(th)),
'param': 'thematic',
'val': th,
})
if selected_contracts:
contract_names = {str(c.id): f"{c.contract_number} ({c.company.name})" for c in available_contracts}
for cid in selected_contracts:
active_filters_list.append({
'type': 'contract',
'label': contract_names.get(str(cid), str(cid)),
'param': 'contract',
'val': cid,
})
if date_from:
active_filters_list.append({
'type': 'date_from',
'label': f"≥ {date_from}",
'param': 'date_from',
'val': date_from,
})
if date_to:
active_filters_list.append({
'type': 'date_to',
'label': f"≤ {date_to}",
'param': 'date_to',
'val': date_to,
})
active_filters_count = len(active_filters_list)
# Query strings pour les liens de pagination (en préservant les autres paramètres)
p_orders = request.GET.copy()
if 'page' in p_orders:
del p_orders['page']
orders_querystring = p_orders.urlencode()
p_contracts = request.GET.copy()
if 'contracts_page' in p_contracts:
del p_contracts['contracts_page']
contracts_querystring = p_contracts.urlencode()
# Générateur d'URLs de tri
def build_sort_url(col):
next_dir = 'asc' if (sort_field == col and sort_dir == 'desc') else 'desc'
p = request.GET.copy()
p['sort'] = col
p['dir'] = next_dir
if 'page' in p:
del p['page']
return f"?{p.urlencode()}"
sort_urls = {col: build_sort_url(col) for col in ('code', 'description', 'date', 'status', 'contract', 'amount')}
# Vérification des droits pour voir les prix
can_view_prices = False
if request.user.is_superuser:
can_view_prices = True
else:
try:
cfg = request.user.config
can_view_prices = getattr(cfg, 'can_view_all_prices', False) or bool(
set(cfg.roles.values_list('name', flat=True)).intersection({'admin', 'top_manager'})
)
except Exception:
pass
context = {
'contracts': contracts,
'orders': orders,
'orders_page': orders_page,
'orders_elided_range': orders_elided_range,
'orders_paginator': orders_paginator,
'contracts_page': contracts_page,
'contracts_elided_range': contracts_elided_range,
'contracts_paginator': contracts_paginator,
'available_thematics': available_thematics,
'available_contracts': available_contracts,
'status_choices': CONTRACT_ORDER_STATUS_CHOICES,
'selected_statuses': status_filters,
'selected_thematics': selected_thematics,
'selected_contracts': selected_contracts,
'search_query': search_query,
'date_from': date_from,
'date_to': date_to,
'page_size': page_size,
'sort_field': sort_field,
'sort_dir': sort_dir,
'sort_urls': sort_urls,
'active_tab': active_tab,
'active_filters_list': active_filters_list,
'active_filters_count': active_filters_count,
'orders_querystring': orders_querystring,
'contracts_querystring': contracts_querystring,
'is_add_order_allowed': user_can_add_order(request.user),
'selected_statuses': status_filters, # Passer les statuts sélectionnés au template
'can_view_prices': can_view_prices,
}
return render(request, "contracts/contracts_index.html", context)