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)." 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)." 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)." 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)." 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)." 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)." 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 %} {% load i18n %}
{% block head %} {% block head %}
<link rel="stylesheet" href="{% static 'contracts/css/contracts_index.css' %}">
<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>
{% endblock head %} {% endblock head %}
{% block content %} {% block content %}
<div class="container mt-5 position-relative"> <!-- ─── Modale de Filtres ──────────────────────────────────────── -->
<div class="position-absolute" style="top: 0px; right: 5px;"> <div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true">
{% if is_add_order_allowed == True %} <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<a href="{% url 'contracts:contracts_orders_add' %}" class="btn btn-light"> <div class="modal-content">
<i class="bi bi-plus-circle"></i> {% translate "Nouvelle commande" %} <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> </a>
{% endif %} {% endif %}
</div> </div>
<ul class="nav nav-tabs" id="tablesTab" role="tablist">
<li class="nav-item" role="presentation"> <!-- Barre d'outils : Recherche et Filtres -->
<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> <div class="contracts-toolbar">
</li> <div class="search-widget">
<li class="nav-item" role="presentation"> <div class="search-input-wrapper">
<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> <i class="bi bi-search search-icon-left"></i>
</li> <input type="text" id="main-search-input" class="search-input" value="{{ search_query }}" placeholder="{% translate 'Rechercher une commande, un marché, une description, un prestataire...' %}">
</ul> <button type="button" id="main-search-clear" class="search-clear-btn" title="{% translate 'Effacer la recherche' %}">
<div class="tab-content my-2" id="tablesTabContent"> <i class="bi bi-x-circle-fill"></i>
<div class="tab-pane fade show active" id="orders-content" role="tabpanel" aria-labelledby="orders-tab"> </button>
<!-- 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>
</div> </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> </div>
<!-- Completed -->
<div class="filter-option"> <div class="toolbar-actions">
<input type="checkbox" class="form-check-input" name="status" id="status_completed" value="completed" {% if 'completed' in selected_statuses %}checked{% endif %}> <button type="button" class="filter-trigger-btn" data-bs-toggle="modal" data-bs-target="#filterModal">
<label for="status_completed" class="context-badge completed">{% translate "Terminé" %}</label> <i class="bi bi-funnel"></i>
</div> {% translate "Filtres" %}
<!-- Invoiced --> {% if active_filters_count > 0 %}
<div class="filter-option"> <span class="filter-count-badge">{{ active_filters_count }}</span>
<input type="checkbox" class="form-check-input" name="status" id="status_invoiced" value="invoiced" {% if 'invoiced' in selected_statuses %}checked{% endif %}> {% endif %}
<label for="status_invoiced" class="context-badge invoiced">{% translate "Comptabilisé" %}</label> </button>
</div>
<!-- Paid --> <div class="page-size-selector">
<div class="filter-option"> <label for="page-size-select" class="d-none d-sm-inline">{% translate "Afficher par" %}</label>
<input type="checkbox" class="form-check-input" name="status" id="status_paid" value="paid" {% if 'paid' in selected_statuses %}checked{% endif %}> <select id="page-size-select" class="page-size-select">
<label for="status_paid" class="context-badge paid">{% translate "Payé" %}</label> <option value="25" {% if page_size == 25 %}selected{% endif %}>25</option>
</div> <option value="50" {% if page_size == 50 %}selected{% endif %}>50</option>
<!-- Cancelled --> <option value="100" {% if page_size == 100 %}selected{% endif %}>100</option>
<div class="filter-option"> </select>
<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> </div>
</div> </div>
</div> </div>
<!-- Dates - Next row --> <!-- Puces des filtres actifs -->
<div class="col-md-6"> {% if active_filters_list %}
<label for="date-from" class="form-label">{% translate "Commandes à partir du" %}</label> <div class="active-filters-bar">
<input type="date" class="form-control" id="date-from" name="date_from" value="{{ request.GET.date_from }}"> <span class="active-filter-label">{% translate "Filtres actifs" %}:</span>
</div> {% for filter in active_filters_list %}
<div class="col-md-6"> <span class="active-filter-chip">
<label for="date-to" class="form-label">{% translate "jusqu'au" %}</label> {% if filter.is_status %}
<input type="date" class="form-control" id="date-to" name="date_to" value="{{ request.GET.date_to }}"> <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> </div>
{% endif %}
<!-- Submit buttons --> <!-- Contenu des onglets -->
<div class="col-12 d-flex align-items-end gap-2"> <div class="tab-content" id="tablesTabContent">
<button type="submit" class="btn btn-primary">{% translate "Filtrer" %}</button>
<a href="{% url 'contracts:contracts_index' %}" class="btn btn-secondary">{% translate "Réinitialiser" %}</a> <!-- ─── Onglet Commandes ──────────────────────────────────── -->
</div> <div class="tab-pane fade {% if active_tab == 'orders' %}show active{% endif %}" id="orders-content" role="tabpanel" aria-labelledby="orders-tab">
</form> <div class="contracts-table-card">
</div> <div class="table-responsive">
</div> <table class="contracts-table">
<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" >
<thead> <thead>
<tr> <tr>
<th data-field="actions" data-width="100"></th> <th style="width: 48px;"></th>
<th data-sortable="true">{% translate "Numéro de commande" %}</th> <th class="sortable">
<th>{% translate "Description" %}</th> <a href="{{ sort_urls.code }}" class="sort-link">
<th>{% translate "Date" %}</th> <span>{% translate "Numéro de commande" %}</span>
<th>{% translate "Statut" %}</th> <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>
<th>{% translate "Fichier" %}</th> </a>
<th data-sortable="true">{% translate "Marché" %}</th> </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> </tr>
</thead> </thead>
<tbody> <tbody>
{% for order in orders %} {% for order in orders_page %}
<tr> <tr>
<td> <td class="text-center">
<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"> <a href="/contracts/orders/{{ order.id }}" class="table-action-btn" title="{% translate 'Détail de la commande' %}">
<i class="bi bi-info-circle"></i> <i class="bi bi-info-circle"></i>
</a> </a>
</td> </td>
<td>{{ order.order_code }}</td>
<td title="{{ order.description }}">{{ order.description|truncatechars:100 }}</td>
<td>{{ order.order_date }}</td>
<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 }} {{ order.get_order_status_display }}
</span> </span>
</td> </td>
<td>{% if order.order_file %}<a href="{{ order.order_file.url }}"><i class="bi bi-file-pdf"></i></a>{% endif %}</td> {% if can_view_prices %}
<td>{{ order.contract }}</td> <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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </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>
<div class="tab-pane fade show" id="contracts-content" role="tabpanel" aria-labelledby="contracts-tab">
<div id="contracts-container"> {% if orders_page.has_other_pages %}
<table id="contracts-table" class="table" <nav class="pagination-nav" aria-label="{% translate 'Pagination des commandes' %}">
data-height="500" <ul class="pagination pagination-sm">
data-pagination="true" {% if orders_page.has_previous %}
data-page-size="25" <li class="page-item">
data-click-to-select="true" <a class="page-link" href="?{{ orders_querystring }}&page={{ orders_page.previous_page_number }}" aria-label="{% translate 'Précédent' %}">
data-search="true"> <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> <thead>
<tr> <tr>
<th data-field="actions" data-width="100"></th> <th style="width: 48px;"></th>
<th>{% translate "Numéro" %}</th> <th>{% translate "Numéro" %}</th>
<th>{% translate "Description" %}</th> <th>{% translate "Description" %}</th>
<th>{% translate "Thématiques" %}</th>
<th>{% translate "Prestataire" %}</th> <th>{% translate "Prestataire" %}</th>
<th>{% translate "Début" %}</th> <th>{% translate "Début" %}</th>
<th>{% translate "Fin" %}</th> <th>{% translate "Fin" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for contract in contracts %} {% for contract in contracts_page %}
<tr> <tr>
<td> <td class="text-center">
<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"> <a href="/contracts/{{ contract.id }}" class="table-action-btn" title="{% translate 'Détail du contrat' %}">
<i class="bi bi-info-circle"></i> <i class="bi bi-info-circle"></i>
</a> </a>
</td> </td>
<td>{{ contract.contract_number }}</td> <td>
<td title="{{ contract.description }}">{{ contract.description|truncatechars:100 }}</td> <a href="/contracts/{{ contract.id }}" class="table-code-link">
<td>{{ contract.company.name }}</td> {{ contract.contract_number }}
<td>{{ contract.start_date }}</td> </a>
<td>{{ contract.end_date }}</td> </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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </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> </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>
</div> </div>
{% endblock content%} {% endblock content %}
{% block script %} {% block script %}
<script src="{% static 'contracts/js/contracts_index.js' %}"></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>
{% endblock script %} {% endblock script %}

View file

@ -802,6 +802,121 @@ class GlobalPostsAndPricesPermissionsTests(TestCase):
self.assertEqual(data['posts'][0]['unit_price'], '10.50') 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.contrib.auth.decorators import login_required
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
from django.http import JsonResponse, FileResponse, Http404 from django.http import JsonResponse, FileResponse, Http404
from django.core.paginator import Paginator
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import smart_str from django.utils.encoding import smart_str
@ -27,6 +28,7 @@ from contracts.models import (
ContractOrderAttachment, ContractOrderAttachment,
DirectOrderPost, DirectOrderPost,
DIRECT_ORDER_POST_STATUS_CHOICES, DIRECT_ORDER_POST_STATUS_CHOICES,
CONTRACT_ORDER_STATUS_CHOICES,
) )
from contracts.forms import ContractOrderForm, ContractOrderQuoteQuantityForm, ClaimDeclarationAttachmentForm, ContractOrderAttachmentForm 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 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, 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 common.private_files.helpers import build_private_url
from interventions.permissions import can_add_intervention from interventions.permissions import can_add_intervention
@login_required
def contracts_index(request): def contracts_index(request):
contracts = get_allowed_contracts_for_user(request.user) 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) # Filtrage par statut(s)
has_status_param = 'status' in request.GET
status_filters = request.GET.getlist('status') status_filters = request.GET.getlist('status')
if status_filters == []: if not has_status_param:
# Par défaut, afficher les commandes en 'pending' et 'sent' # Par défaut, afficher les commandes en 'pending', 'sent', 'completed' et 'invoiced'
status_filters = ['pending', 'sent', 'completed', '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 # 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: if date_from:
orders = orders.filter(order_date__gte=date_from) orders = orders.filter(order_date__gte=date_from)
date_to = request.GET.get('date_to', '')
if date_to: if date_to:
orders = orders.filter(order_date__lte=date_to) orders = orders.filter(order_date__lte=date_to)
# Tri par date décroissante if search_query:
orders = orders.order_by('-order_date') 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 = { context = {
'contracts': contracts, '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), '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) return render(request, "contracts/contracts_index.html", context)