fix: paginate import history and report
This commit is contained in:
@@ -110,6 +110,7 @@ const MONTHS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const YEARS = Array.from({ length: 5 }, (_, index) => String(new Date().getFullYear() - index));
|
const YEARS = Array.from({ length: 5 }, (_, index) => String(new Date().getFullYear() - index));
|
||||||
|
const IMPORT_LOG_PAGE_SIZE = 200;
|
||||||
|
|
||||||
function formatAccount(account: Pick<EmailAccount, "userName" | "userEmail" | "emailAddress">) {
|
function formatAccount(account: Pick<EmailAccount, "userName" | "userEmail" | "emailAddress">) {
|
||||||
const owner = account.userName?.trim() || account.userEmail;
|
const owner = account.userName?.trim() || account.userEmail;
|
||||||
@@ -139,7 +140,11 @@ function TriggerBadge({ trigger }: { trigger: DetailedImportLog["importTrigger"]
|
|||||||
export default function History() {
|
export default function History() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAdmin = user?.role === "admin";
|
const isAdmin = user?.role === "admin";
|
||||||
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery();
|
const [page, setPage] = useState(0);
|
||||||
|
const { data: logsPage, isLoading } = trpc.importLogs.getByUser.useQuery({
|
||||||
|
limit: IMPORT_LOG_PAGE_SIZE,
|
||||||
|
offset: page * IMPORT_LOG_PAGE_SIZE,
|
||||||
|
});
|
||||||
const { data: emailAccounts, isLoading: accountsLoading } = trpc.emailImportService.getConfiguredAccounts.useQuery(undefined, {
|
const { data: emailAccounts, isLoading: accountsLoading } = trpc.emailImportService.getConfiguredAccounts.useQuery(undefined, {
|
||||||
enabled: isAdmin,
|
enabled: isAdmin,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
@@ -148,7 +153,8 @@ export default function History() {
|
|||||||
const checkAccountNowMutation = trpc.emailImportService.checkAccountNow.useMutation();
|
const checkAccountNowMutation = trpc.emailImportService.checkAccountNow.useMutation();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const detailedLogs = (logs ?? []) as DetailedImportLog[];
|
const detailedLogs = (logsPage?.items ?? []) as DetailedImportLog[];
|
||||||
|
const totalLogs = logsPage?.total ?? 0;
|
||||||
const accounts = (emailAccounts ?? []) as EmailAccount[];
|
const accounts = (emailAccounts ?? []) as EmailAccount[];
|
||||||
const configuredAccounts = useMemo(() => accounts.filter((account) => account.isConfigured === 1), [accounts]);
|
const configuredAccounts = useMemo(() => accounts.filter((account) => account.isConfigured === 1), [accounts]);
|
||||||
|
|
||||||
@@ -329,7 +335,14 @@ export default function History() {
|
|||||||
<Select value={triggerFilter} onValueChange={(value) => setTriggerFilter(value as TriggerFilter)}><SelectTrigger className="h-8 w-32 bg-white text-sm"><SelectValue placeholder="Déclencheur" /></SelectTrigger><SelectContent><SelectItem value="all">Tout mode</SelectItem><SelectItem value="manual">Manuel</SelectItem><SelectItem value="automatic">Planifié</SelectItem><SelectItem value="unknown">Non tracé</SelectItem></SelectContent></Select>
|
<Select value={triggerFilter} onValueChange={(value) => setTriggerFilter(value as TriggerFilter)}><SelectTrigger className="h-8 w-32 bg-white text-sm"><SelectValue placeholder="Déclencheur" /></SelectTrigger><SelectContent><SelectItem value="all">Tout mode</SelectItem><SelectItem value="manual">Manuel</SelectItem><SelectItem value="automatic">Planifié</SelectItem><SelectItem value="unknown">Non tracé</SelectItem></SelectContent></Select>
|
||||||
{hasFilters && <Button variant="ghost" size="sm" className="h-8 px-2 text-gray-500" onClick={() => { setYearFilter("all"); setMonthFilter("all"); setSourceFilter("all"); setTriggerFilter("all"); }} title="Réinitialiser les filtres"><X className="h-4 w-4" /></Button>}
|
{hasFilters && <Button variant="ghost" size="sm" className="h-8 px-2 text-gray-500" onClick={() => { setYearFilter("all"); setMonthFilter("all"); setSourceFilter("all"); setTriggerFilter("all"); }} title="Réinitialiser les filtres"><X className="h-4 w-4" /></Button>}
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-auto text-sm text-gray-500">{filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} · {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""}</span>
|
<div className="ml-auto flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<span>{filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} · {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""}</span>
|
||||||
|
{totalLogs > IMPORT_LOG_PAGE_SIZE && <>
|
||||||
|
<Button variant="outline" size="sm" className="h-8" disabled={page === 0} onClick={() => setPage((current) => current - 1)}>Précédent</Button>
|
||||||
|
<span className="whitespace-nowrap">Page {page + 1}</span>
|
||||||
|
<Button variant="outline" size="sm" className="h-8" disabled={(page + 1) * IMPORT_LOG_PAGE_SIZE >= totalLogs} onClick={() => setPage((current) => current + 1)}>Suivant</Button>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
</CardContent></Card>
|
</CardContent></Card>
|
||||||
|
|
||||||
<Card><CardContent className="overflow-x-auto p-0">
|
<Card><CardContent className="overflow-x-auto p-0">
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ type ErrorDetail = {
|
|||||||
value?: string;
|
value?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const IMPORT_LOG_PAGE_SIZE = 200;
|
||||||
|
|
||||||
function parseJson<T>(str: string | null | undefined): T[] {
|
function parseJson<T>(str: string | null | undefined): T[] {
|
||||||
if (!str) return [];
|
if (!str) return [];
|
||||||
try {
|
try {
|
||||||
@@ -59,7 +61,13 @@ function StatusBadge({ imported, duplicates, errors }: { imported: number; dupli
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ImportReport() {
|
export default function ImportReport() {
|
||||||
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery();
|
const [page, setPage] = useState(0);
|
||||||
|
const { data: logsPage, isLoading } = trpc.importLogs.getByUser.useQuery({
|
||||||
|
limit: IMPORT_LOG_PAGE_SIZE,
|
||||||
|
offset: page * IMPORT_LOG_PAGE_SIZE,
|
||||||
|
});
|
||||||
|
const logs = (logsPage?.items ?? []) as ImportLog[];
|
||||||
|
const totalLogs = logsPage?.total ?? 0;
|
||||||
const [yearFilter, setYearFilter] = useState<string>(String(new Date().getFullYear()));
|
const [yearFilter, setYearFilter] = useState<string>(String(new Date().getFullYear()));
|
||||||
const [monthFilter, setMonthFilter] = useState<string>("all");
|
const [monthFilter, setMonthFilter] = useState<string>("all");
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
@@ -319,6 +327,14 @@ export default function ImportReport() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{totalLogs > IMPORT_LOG_PAGE_SIZE && (
|
||||||
|
<div className="mt-3 flex items-center justify-end gap-2 text-xs text-gray-500">
|
||||||
|
<span>{totalLogs.toLocaleString("fr-FR")} journaux</span>
|
||||||
|
<Button size="sm" variant="outline" className="h-8" disabled={page === 0} onClick={() => setPage((current) => current - 1)}>Précédent</Button>
|
||||||
|
<span>Page {page + 1}</span>
|
||||||
|
<Button size="sm" variant="outline" className="h-8" disabled={(page + 1) * IMPORT_LOG_PAGE_SIZE >= totalLogs} onClick={() => setPage((current) => current + 1)}>Suivant</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
29
server/db.ts
29
server/db.ts
@@ -571,10 +571,15 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
|
|||||||
* les paramètres sensibles de messagerie. L'administrateur peut ainsi relier
|
* les paramètres sensibles de messagerie. L'administrateur peut ainsi relier
|
||||||
* chaque import à son compte, fichier source et déclencheur.
|
* chaque import à son compte, fichier source et déclencheur.
|
||||||
*/
|
*/
|
||||||
export async function getImportLogsWithDetailsByUser(userId: number) {
|
export type ImportLogPageOptions = {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getImportLogsWithDetailsByUser(userId: number, options: ImportLogPageOptions) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return [];
|
if (!db) return { items: [], total: 0 };
|
||||||
return db.select({
|
const items = await db.select({
|
||||||
...getTableColumns(importLogs),
|
...getTableColumns(importLogs),
|
||||||
userName: users.name,
|
userName: users.name,
|
||||||
userEmail: users.email,
|
userEmail: users.email,
|
||||||
@@ -585,13 +590,17 @@ export async function getImportLogsWithDetailsByUser(userId: number) {
|
|||||||
.leftJoin(users, eq(importLogs.userId, users.id))
|
.leftJoin(users, eq(importLogs.userId, users.id))
|
||||||
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
|
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
|
||||||
.where(eq(importLogs.userId, userId))
|
.where(eq(importLogs.userId, userId))
|
||||||
.orderBy(desc(importLogs.importedAt));
|
.orderBy(desc(importLogs.importedAt))
|
||||||
|
.limit(options.limit)
|
||||||
|
.offset(options.offset);
|
||||||
|
const [total] = await db.select({ total: count() }).from(importLogs).where(eq(importLogs.userId, userId));
|
||||||
|
return { items, total: Number(total?.total ?? 0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllImportLogsWithDetails() {
|
export async function getAllImportLogsWithDetails(options: ImportLogPageOptions) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return [];
|
if (!db) return { items: [], total: 0 };
|
||||||
return db.select({
|
const items = await db.select({
|
||||||
...getTableColumns(importLogs),
|
...getTableColumns(importLogs),
|
||||||
userName: users.name,
|
userName: users.name,
|
||||||
userEmail: users.email,
|
userEmail: users.email,
|
||||||
@@ -601,7 +610,11 @@ export async function getAllImportLogsWithDetails() {
|
|||||||
.from(importLogs)
|
.from(importLogs)
|
||||||
.leftJoin(users, eq(importLogs.userId, users.id))
|
.leftJoin(users, eq(importLogs.userId, users.id))
|
||||||
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
|
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
|
||||||
.orderBy(desc(importLogs.importedAt));
|
.orderBy(desc(importLogs.importedAt))
|
||||||
|
.limit(options.limit)
|
||||||
|
.offset(options.offset);
|
||||||
|
const [total] = await db.select({ total: count() }).from(importLogs);
|
||||||
|
return { items, total: Number(total?.total ?? 0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAllImportLogs(userId: number | null): Promise<void> {
|
export async function deleteAllImportLogs(userId: number | null): Promise<void> {
|
||||||
|
|||||||
22
server/importLogPagination.test.ts
Normal file
22
server/importLogPagination.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_IMPORT_LOG_PAGE_SIZE,
|
||||||
|
MAX_IMPORT_LOG_PAGE_SIZE,
|
||||||
|
normalizeImportLogPagination,
|
||||||
|
} from "../shared/importLogPagination";
|
||||||
|
|
||||||
|
describe("pagination des journaux d'import", () => {
|
||||||
|
it("retourne une page bornée par défaut au lieu de l'historique complet", () => {
|
||||||
|
expect(normalizeImportLogPagination()).toEqual({
|
||||||
|
limit: DEFAULT_IMPORT_LOG_PAGE_SIZE,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("borne les entrées utilisateur pour préserver la réactivité", () => {
|
||||||
|
expect(normalizeImportLogPagination({ limit: MAX_IMPORT_LOG_PAGE_SIZE + 1, offset: -20 })).toEqual({
|
||||||
|
limit: MAX_IMPORT_LOG_PAGE_SIZE,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { COOKIE_NAME } from "@shared/const";
|
|||||||
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
|
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
|
||||||
import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard";
|
import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard";
|
||||||
import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
|
import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
|
||||||
|
import { normalizeImportLogPagination } from "@shared/importLogPagination";
|
||||||
import { webPortalUrlSchema } from "./webPortalUrl";
|
import { webPortalUrlSchema } from "./webPortalUrl";
|
||||||
|
|
||||||
interface Condition {
|
interface Condition {
|
||||||
@@ -1727,12 +1728,15 @@ export const appRouter = router({
|
|||||||
|
|
||||||
// ============= IMPORT LOGS ROUTES =============
|
// ============= IMPORT LOGS ROUTES =============
|
||||||
importLogs: router({
|
importLogs: router({
|
||||||
getByUser: protectedProcedure.query(async ({ ctx }) => {
|
getByUser: protectedProcedure
|
||||||
|
.input(z.object({ limit: z.number().int().positive().optional(), offset: z.number().int().min(0).optional() }).optional())
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const pagination = normalizeImportLogPagination(input);
|
||||||
// Les admins voient tous les logs d'import
|
// Les admins voient tous les logs d'import
|
||||||
if (ctx.user.role === 'admin') {
|
if (ctx.user.role === 'admin') {
|
||||||
return getAllImportLogsWithDetails();
|
return getAllImportLogsWithDetails(pagination);
|
||||||
}
|
}
|
||||||
return getImportLogsWithDetailsByUser(ctx.user.id);
|
return getImportLogsWithDetailsByUser(ctx.user.id, pagination);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
|
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
|
|||||||
22
shared/importLogPagination.ts
Normal file
22
shared/importLogPagination.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export const DEFAULT_IMPORT_LOG_PAGE_SIZE = 200;
|
||||||
|
export const MAX_IMPORT_LOG_PAGE_SIZE = 500;
|
||||||
|
|
||||||
|
export type ImportLogPaginationInput = {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise les limites avant toute lecture de journal. Les imports historiques
|
||||||
|
* peuvent contenir plusieurs dizaines de milliers de lignes : l'interface ne
|
||||||
|
* doit jamais tenter de les transférer en une seule requête.
|
||||||
|
*/
|
||||||
|
export function normalizeImportLogPagination(input?: ImportLogPaginationInput) {
|
||||||
|
const requestedLimit = input?.limit ?? DEFAULT_IMPORT_LOG_PAGE_SIZE;
|
||||||
|
const requestedOffset = input?.offset ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
limit: Math.min(Math.max(Math.trunc(requestedLimit), 1), MAX_IMPORT_LOG_PAGE_SIZE),
|
||||||
|
offset: Math.max(Math.trunc(requestedOffset), 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
5
todo.md
5
todo.md
@@ -1,5 +1,10 @@
|
|||||||
# Invoice Analyzer - TODO
|
# Invoice Analyzer - TODO
|
||||||
|
|
||||||
|
## Incident historique et rapport d’import — production — 09/10/2026
|
||||||
|
- [x] Tracer les requêtes et données : 101 886 journaux sont renvoyés sans limite à l’interface
|
||||||
|
- [x] Borner et paginer la récupération des journaux sans supprimer l’historique
|
||||||
|
- [ ] Valider l’historique et le rapport en recette puis en production
|
||||||
|
|
||||||
## Cohérence export / BAP — production — 09/10/2026
|
## Cohérence export / BAP — production — 09/10/2026
|
||||||
- [x] Tracer les états : Amazon et Inmac ont été exportées par le flux PDF générique le 30/09, sans BAP validé ni historique BAP
|
- [x] Tracer les états : Amazon et Inmac ont été exportées par le flux PDF générique le 30/09, sans BAP validé ni historique BAP
|
||||||
- [x] Bloquer tout nouvel export PDF hors abonnement avant validation BAP et rendre l’état historique explicite dans l’interface
|
- [x] Bloquer tout nouvel export PDF hors abonnement avant validation BAP et rendre l’état historique explicite dans l’interface
|
||||||
|
|||||||
Reference in New Issue
Block a user