From 53798176a027bf6c73eb43f39aa02729a734db8d Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sat, 20 Dec 2025 12:05:30 +0100 Subject: [PATCH] feat: add ReconcileDateRangeCard to settings page; enhance date picker layout in statistics and transaction filters components --- .../reconcile-date-range/route.ts | 69 +++++ app/settings/page.tsx | 3 + app/statistics/page.tsx | 78 +++--- components/settings/index.ts | 1 + .../settings/reconcile-date-range-card.tsx | 260 ++++++++++++++++++ .../transactions/transaction-filters.tsx | 70 ++--- services/transaction.service.ts | 28 ++ 7 files changed, 439 insertions(+), 70 deletions(-) create mode 100644 app/api/banking/transactions/reconcile-date-range/route.ts create mode 100644 components/settings/reconcile-date-range-card.tsx diff --git a/app/api/banking/transactions/reconcile-date-range/route.ts b/app/api/banking/transactions/reconcile-date-range/route.ts new file mode 100644 index 0000000..1890a37 --- /dev/null +++ b/app/api/banking/transactions/reconcile-date-range/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; +import { transactionService } from "@/services/transaction.service"; +import { requireAuth } from "@/lib/auth-utils"; + +export async function POST(request: NextRequest) { + const authError = await requireAuth(); + if (authError) return authError; + + try { + const body = await request.json(); + const { startDate, endDate, reconciled = true } = body; + + if (!endDate) { + return NextResponse.json( + { error: "endDate is required" }, + { status: 400 }, + ); + } + + // Validate date format (YYYY-MM-DD) + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (startDate && !dateRegex.test(startDate)) { + return NextResponse.json( + { error: "Invalid startDate format. Expected YYYY-MM-DD" }, + { status: 400 }, + ); + } + if (!dateRegex.test(endDate)) { + return NextResponse.json( + { error: "Invalid endDate format. Expected YYYY-MM-DD" }, + { status: 400 }, + ); + } + + if (startDate && startDate > endDate) { + return NextResponse.json( + { error: "startDate must be before or equal to endDate" }, + { status: 400 }, + ); + } + + const result = await transactionService.reconcileByDateRange( + startDate, + endDate, + reconciled, + ); + + // Revalider le cache des pages + revalidatePath("/transactions", "page"); + revalidatePath("/statistics", "page"); + revalidatePath("/dashboard", "page"); + revalidatePath("/settings", "page"); + + return NextResponse.json(result, { + headers: { + "Cache-Control": "no-store", + }, + }); + } catch (error) { + console.error("Error reconciling transactions by date range:", error); + const errorMessage = + error instanceof Error + ? error.message + : "Failed to reconcile transactions"; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} + diff --git a/app/settings/page.tsx b/app/settings/page.tsx index e70c422..8df5ca7 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -8,6 +8,7 @@ import { OFXInfoCard, BackupCard, PasswordCard, + ReconcileDateRangeCard, } from "@/components/settings"; import { useBankingData } from "@/lib/hooks"; import type { BankingData } from "@/lib/types"; @@ -125,6 +126,8 @@ export default function SettingsPage() { + + -
+
- { - setCustomStartDate(date); - if (date && customEndDate && date > customEndDate) { - setCustomEndDate(undefined); - } - }} - locale={fr} - /> +
+ { + setCustomStartDate(date); + if (date && customEndDate && date > customEndDate) { + setCustomEndDate(undefined); + } + }} + locale={fr} + /> +
- { - if ( - date && - customStartDate && - date < customStartDate - ) { - return; - } - setCustomEndDate(date); - if (date && customStartDate) { - setIsCustomDatePickerOpen(false); - } - }} - disabled={(date) => { - if (!customStartDate) return true; - return date < customStartDate; - }} - locale={fr} - /> +
+ { + if ( + date && + customStartDate && + date < customStartDate + ) { + return; + } + setCustomEndDate(date); + if (date && customStartDate) { + setIsCustomDatePickerOpen(false); + } + }} + disabled={(date) => { + if (!customStartDate) return true; + return date < customStartDate; + }} + locale={fr} + /> +
- {customStartDate && customEndDate && ( -
+
+ {customStartDate && customEndDate && ( +
)} -
)} diff --git a/components/settings/index.ts b/components/settings/index.ts index cc77a68..2c58ec6 100644 --- a/components/settings/index.ts +++ b/components/settings/index.ts @@ -3,3 +3,4 @@ export { DangerZoneCard } from "./danger-zone-card"; export { OFXInfoCard } from "./ofx-info-card"; export { BackupCard } from "./backup-card"; export { PasswordCard } from "./password-card"; +export { ReconcileDateRangeCard } from "./reconcile-date-range-card"; diff --git a/components/settings/reconcile-date-range-card.tsx b/components/settings/reconcile-date-range-card.tsx new file mode 100644 index 0000000..b5e6d3a --- /dev/null +++ b/components/settings/reconcile-date-range-card.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Calendar as CalendarComponent } from "@/components/ui/calendar"; +import { CheckCircle2, Calendar } from "lucide-react"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { useQueryClient } from "@tanstack/react-query"; +import { invalidateAllTransactionQueries } from "@/lib/cache-utils"; + +export function ReconcileDateRangeCard() { + const [startDate, setStartDate] = useState(undefined); + const [endDate, setEndDate] = useState(undefined); + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); + const [isReconciling, setIsReconciling] = useState(false); + const queryClient = useQueryClient(); + + const handleReconcile = async () => { + if (!endDate) return; + + setIsReconciling(true); + try { + const endDateStr = format(endDate, "yyyy-MM-dd"); + const body: { endDate: string; startDate?: string; reconciled: boolean } = { + endDate: endDateStr, + reconciled: true, + }; + + if (startDate) { + body.startDate = format(startDate, "yyyy-MM-dd"); + } + + const response = await fetch( + "/api/banking/transactions/reconcile-date-range", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Erreur lors du pointage"); + } + + const result = await response.json(); + + // Invalider toutes les requêtes de transactions pour rafraîchir les données + invalidateAllTransactionQueries(queryClient); + + alert( + `${result.updatedCount} opération${result.updatedCount > 1 ? "s" : ""} pointée${result.updatedCount > 1 ? "s" : ""}`, + ); + + // Réinitialiser les dates + setStartDate(undefined); + setEndDate(undefined); + setIsDatePickerOpen(false); + } catch (error) { + console.error(error); + alert( + error instanceof Error + ? error.message + : "Erreur lors du pointage des opérations", + ); + } finally { + setIsReconciling(false); + } + }; + + const canReconcile = endDate && (!startDate || startDate <= endDate); + + return ( + + + + + Pointer les opérations par date + + + Marquer toutes les opérations pointées dans une plage de dates + + + + + + + + +
+
+ +
+ { + setStartDate(date); + if (date && endDate && date > endDate) { + setEndDate(undefined); + } + }} + locale={fr} + /> +
+ {startDate && ( + + )} +
+
+ +
+ { + if (date && startDate && date < startDate) { + return; + } + setEndDate(date); + if (date && startDate) { + setIsDatePickerOpen(false); + } + }} + disabled={(date) => { + if (startDate) { + return date < startDate; + } + return false; + }} + locale={fr} + /> +
+
+
+ {endDate && ( +
+ {startDate ? ( + <> + {format(startDate, "PPP", { locale: fr })} -{" "} + {format(endDate, "PPP", { locale: fr })} + + ) : ( + <>Jusqu'au {format(endDate, "PPP", { locale: fr })} + )} +
+ )} +
+
+ + + + + + + + + Pointer toutes les opérations ? + + + {endDate && ( + <> + Cette action va marquer toutes les opérations non pointées{" "} + {startDate ? ( + <> + entre {format(startDate, "PPP", { locale: fr })} et{" "} + {format(endDate, "PPP", { locale: fr })} + + ) : ( + <>jusqu'au {format(endDate, "PPP", { locale: fr })} + )}{" "} + comme pointées. Seules les opérations non encore pointées seront + modifiées. + + )} + + + + + Annuler + + + {isReconciling ? "Pointage..." : "Pointer"} + + + + +
+
+ ); +} + diff --git a/components/transactions/transaction-filters.tsx b/components/transactions/transaction-filters.tsx index 4e2fb4b..3249cb0 100644 --- a/components/transactions/transaction-filters.tsx +++ b/components/transactions/transaction-filters.tsx @@ -202,44 +202,49 @@ export function TransactionFilters({ -
+
- { - onCustomStartDateChange(date); - if (date && customEndDate && date > customEndDate) { - onCustomEndDateChange(undefined); - } - }} - locale={fr} - /> +
+ { + onCustomStartDateChange(date); + if (date && customEndDate && date > customEndDate) { + onCustomEndDateChange(undefined); + } + }} + locale={fr} + /> +
- { - if (date && customStartDate && date < customStartDate) { - return; - } - onCustomEndDateChange(date); - if (date && customStartDate) { - onCustomDatePickerOpenChange(false); - } - }} - disabled={(date) => { - if (!customStartDate) return true; - return date < customStartDate; - }} - locale={fr} - /> +
+ { + if (date && customStartDate && date < customStartDate) { + return; + } + onCustomEndDateChange(date); + if (date && customStartDate) { + onCustomDatePickerOpenChange(false); + } + }} + disabled={(date) => { + if (!customStartDate) return true; + return date < customStartDate; + }} + locale={fr} + /> +
- {customStartDate && customEndDate && ( -
+
+ {customStartDate && customEndDate && ( +
)} -
)} diff --git a/services/transaction.service.ts b/services/transaction.service.ts index 327857e..2105087 100644 --- a/services/transaction.service.ts +++ b/services/transaction.service.ts @@ -245,4 +245,32 @@ export const transactionService = { duplicatesFound: duplicatesToDelete.length, }; }, + + async reconcileByDateRange( + startDate: string | undefined, + endDate: string, + reconciled: boolean = true, + ): Promise<{ updatedCount: number }> { + // Update all transactions in the date range + // If startDate is not provided, use a very old date to include all transactions up to endDate + const whereClause: { + date: { lte: string; gte?: string }; + isReconciled: boolean; + } = { + date: { + lte: endDate, + ...(startDate && { gte: startDate }), + }, + isReconciled: !reconciled, // Only update transactions that don't already have the target state + }; + + const result = await prisma.transaction.updateMany({ + where: whereClause, + data: { + isReconciled: reconciled, + }, + }); + + return { updatedCount: result.count }; + }, };