Checkpoint: Factures BAP inclut désormais, après Année et Mois, les filtres Destinataires, Entités et Ventilations comptables de la page Factures. Leur logique est identique, avec valeurs absentes et règle SANTINOVA/ITINOVA fondée sur DSI SANTINOVA. Les filtres sont conservés au retour depuis le détail. Tests, TypeScript, build et rendu visuel validés en sandbox.

This commit is contained in:
Manus
2026-09-02 11:52:20 +00:00
parent 0b2f1b781d
commit da19629ee0
4 changed files with 100 additions and 4 deletions

View File

@@ -8,6 +8,9 @@ export type BapFilters = {
statusFilter: string;
selectedYear: string;
selectedMonth: string;
recipientFilter: string;
entityFilter: string;
ventilationFilter: string;
sortField: "invoiceDate" | "createdAt";
sortDir: "asc" | "desc";
};
@@ -34,6 +37,10 @@ function isYear(value: string | null): value is string {
return value === "all" || (value !== null && /^\d{4}$/.test(value));
}
function isSafeFilterValue(value: string | null): value is string {
return value !== null && value.length > 0 && value.length <= 200;
}
/** Lit et valide les filtres BAP transmis dans lURL. */
export function parseBapFilters(search: string, defaultYear: string): BapFilters {
const params = asSearchParams(search);
@@ -48,6 +55,9 @@ export function parseBapFilters(search: string, defaultYear: string): BapFilters
statusFilter: status && BAP_STATUSES.has(status) ? status : "all",
selectedYear: isYear(year) ? year : defaultYear,
selectedMonth: month === "all" || isMonth(month) ? month : "all",
recipientFilter: isSafeFilterValue(params.get("recipient")) ? params.get("recipient")! : "all",
entityFilter: ["all", "santinova", "itinova"].includes(params.get("entity") || "") ? params.get("entity")! : "all",
ventilationFilter: isSafeFilterValue(params.get("ventilation")) ? params.get("ventilation")! : "all",
sortField: sort === "invoiceDate" ? "invoiceDate" : "createdAt",
sortDir: direction === "asc" ? "asc" : "desc",
};
@@ -61,6 +71,9 @@ export function buildBapDetailLocation(invoiceId: number, filters: BapFilters):
status: filters.statusFilter,
year: filters.selectedYear,
month: filters.selectedMonth,
recipient: filters.recipientFilter,
entity: filters.entityFilter,
ventilation: filters.ventilationFilter,
sort: filters.sortField,
dir: filters.sortDir,
});
@@ -74,7 +87,7 @@ export function getBapReturnLocation(search: string): string {
if (params.get("returnTo") !== "bap") return "/invoices";
const allowed = new URLSearchParams();
for (const key of ["q", "status", "year", "month", "sort", "dir"]) {
for (const key of ["q", "status", "year", "month", "recipient", "entity", "ventilation", "sort", "dir"]) {
const value = params.get(key);
if (value) allowed.set(key, value);
}

View File

@@ -116,6 +116,9 @@ export default function InvoicesBAP() {
const [searchQuery, setSearchQuery] = useState(initialFilters.searchQuery);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<string>(initialFilters.statusFilter);
const [recipientFilter, setRecipientFilter] = useState<string>(initialFilters.recipientFilter);
const [entityFilter, setEntityFilter] = useState<string>(initialFilters.entityFilter);
const [ventilationFilter, setVentilationFilter] = useState<string>(initialFilters.ventilationFilter);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
@@ -143,6 +146,9 @@ export default function InvoicesBAP() {
statusFilter,
selectedYear,
selectedMonth,
recipientFilter,
entityFilter,
ventilationFilter,
sortField,
sortDir,
}));
@@ -169,6 +175,14 @@ export default function InvoicesBAP() {
const { data: allInvoices, isLoading } = trpc.invoices.list.useQuery();
// Filter for BAP invoices only (Abonnement = NON, isSubscription = 0)
const invoices = allInvoices?.filter(inv => inv.isSubscription === 0);
const uniqueRecipients = useMemo(
() => Array.from(new Set((invoices || []).map(inv => (inv as any).recipientName).filter(Boolean))).sort(),
[invoices],
);
const uniqueVentilations = useMemo(
() => Array.from(new Set((invoices || []).map(inv => (inv as any).ventilationComptable).filter(Boolean))).sort(),
[invoices],
);
// IDs des factures déjà validées BAP (pour charger leurs pdfUrl depuis bapHistory)
// Stabilisé avec useMemo pour éviter les re-renders infinis (anti-pattern tRPC)
const validatedInvoiceIds = useMemo(
@@ -440,6 +454,27 @@ export default function InvoicesBAP() {
if (statusFilter === "bap_pending" && inv.bapValidated === 1) return false;
if (statusFilter === "to_complete" && !isToComplete(inv)) return false;
}
// Même convention que la page Factures : valeur vide et entités déduites du service.
if (recipientFilter !== "all") {
if (recipientFilter === "__empty__") {
if ((inv as any).recipientName) return false;
} else if ((inv as any).recipientName !== recipientFilter) {
return false;
}
}
if (entityFilter !== "all") {
const service = ((inv as any).serviceConcerne || "").trim().toUpperCase();
if (entityFilter === "santinova" && service !== "DSI SANTINOVA") return false;
if (entityFilter === "itinova" && service === "DSI SANTINOVA") return false;
}
if (ventilationFilter !== "all") {
if (ventilationFilter === "__empty__") {
if ((inv as any).ventilationComptable) return false;
} else if ((inv as any).ventilationComptable !== ventilationFilter) {
return false;
}
}
return true;
});
@@ -778,6 +813,40 @@ export default function InvoicesBAP() {
))}
</SelectContent>
</Select>
<Select value={recipientFilter} onValueChange={setRecipientFilter}>
<SelectTrigger className="w-[180px] h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Destinataire" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous destinataires</SelectItem>
<SelectItem value="__empty__">Sans destinataire</SelectItem>
{uniqueRecipients.map((recipient) => (
<SelectItem key={recipient} value={recipient}>{recipient}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={entityFilter} onValueChange={setEntityFilter}>
<SelectTrigger className="w-[150px] h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Entité" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes entités</SelectItem>
<SelectItem value="santinova">SANTINOVA</SelectItem>
<SelectItem value="itinova">ITINOVA</SelectItem>
</SelectContent>
</Select>
<Select value={ventilationFilter} onValueChange={setVentilationFilter}>
<SelectTrigger className="w-[170px] h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Ventilation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes ventilations</SelectItem>
<SelectItem value="__empty__">Sans ventilation</SelectItem>
{uniqueVentilations.map((ventilation) => (
<SelectItem key={ventilation} value={ventilation}>{ventilation}</SelectItem>
))}
</SelectContent>
</Select>
{(selectedYear !== "all" || selectedMonth !== "all") && (
<button
onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }}