feat: add ReconcileDateRangeCard to settings page; enhance date picker layout in statistics and transaction filters components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s

This commit is contained in:
Julien Froidefond
2025-12-20 12:05:30 +01:00
parent 8b81dfe8c0
commit 53798176a0
7 changed files with 439 additions and 70 deletions

View File

@@ -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 });
}
}

View File

@@ -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() {
<PasswordCard />
<ReconcileDateRangeCard />
<DangerZoneCard
categorizedCount={categorizedCount}
onClearCategories={clearAllCategories}

View File

@@ -950,52 +950,57 @@ export default function StatisticsPage() {
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-4 space-y-4">
<div className="p-3 flex gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Date de début
</label>
<CalendarComponent
mode="single"
selected={customStartDate}
onSelect={(date) => {
setCustomStartDate(date);
if (date && customEndDate && date > customEndDate) {
setCustomEndDate(undefined);
}
}}
locale={fr}
/>
<div className="scale-90 origin-top-left">
<CalendarComponent
mode="single"
selected={customStartDate}
onSelect={(date) => {
setCustomStartDate(date);
if (date && customEndDate && date > customEndDate) {
setCustomEndDate(undefined);
}
}}
locale={fr}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Date de fin
</label>
<CalendarComponent
mode="single"
selected={customEndDate}
onSelect={(date) => {
if (
date &&
customStartDate &&
date < customStartDate
) {
return;
}
setCustomEndDate(date);
if (date && customStartDate) {
setIsCustomDatePickerOpen(false);
}
}}
disabled={(date) => {
if (!customStartDate) return true;
return date < customStartDate;
}}
locale={fr}
/>
<div className="scale-90 origin-top-left">
<CalendarComponent
mode="single"
selected={customEndDate}
onSelect={(date) => {
if (
date &&
customStartDate &&
date < customStartDate
) {
return;
}
setCustomEndDate(date);
if (date && customStartDate) {
setIsCustomDatePickerOpen(false);
}
}}
disabled={(date) => {
if (!customStartDate) return true;
return date < customStartDate;
}}
locale={fr}
/>
</div>
</div>
{customStartDate && customEndDate && (
<div className="flex gap-2 pt-2 border-t">
</div>
{customStartDate && customEndDate && (
<div className="flex gap-2 pt-2 border-t px-3 pb-3">
<Button
variant="outline"
size="sm"
@@ -1016,7 +1021,6 @@ export default function StatisticsPage() {
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
)}

View File

@@ -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";

View File

@@ -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<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5" />
Pointer les opérations par date
</CardTitle>
<CardDescription>
Marquer toutes les opérations pointées dans une plage de dates
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Popover open={isDatePickerOpen} onOpenChange={setIsDatePickerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<Calendar className="mr-2 h-4 w-4" />
{endDate ? (
<>
{startDate ? (
<>
{format(startDate, "PPP", { locale: fr})} -{" "}
{format(endDate, "PPP", { locale: fr })}
</>
) : (
<>Jusqu'au {format(endDate, "PPP", { locale: fr })}</>
)}
</>
) : (
<span>Sélectionner une date de fin</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-3 flex gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Date de début <span className="text-xs text-muted-foreground">(optionnel)</span>
</label>
<div className="scale-90 origin-top-left">
<CalendarComponent
mode="single"
selected={startDate}
onSelect={(date) => {
setStartDate(date);
if (date && endDate && date > endDate) {
setEndDate(undefined);
}
}}
locale={fr}
/>
</div>
{startDate && (
<Button
variant="ghost"
size="sm"
className="w-full text-xs"
onClick={() => setStartDate(undefined)}
>
Supprimer
</Button>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Date de fin</label>
<div className="scale-90 origin-top-left">
<CalendarComponent
mode="single"
selected={endDate}
onSelect={(date) => {
if (date && startDate && date < startDate) {
return;
}
setEndDate(date);
if (date && startDate) {
setIsDatePickerOpen(false);
}
}}
disabled={(date) => {
if (startDate) {
return date < startDate;
}
return false;
}}
locale={fr}
/>
</div>
</div>
</div>
{endDate && (
<div className="px-3 pb-3 text-sm text-muted-foreground">
{startDate ? (
<>
{format(startDate, "PPP", { locale: fr })} -{" "}
{format(endDate, "PPP", { locale: fr })}
</>
) : (
<>Jusqu'au {format(endDate, "PPP", { locale: fr })}</>
)}
</div>
)}
</PopoverContent>
</Popover>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="w-full"
disabled={!canReconcile || isReconciling}
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Pointer les opérations
{isReconciling && (
<span className="ml-auto text-xs text-muted-foreground">
En cours...
</span>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Pointer toutes les opérations ?
</AlertDialogTitle>
<AlertDialogDescription>
{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.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isReconciling}>
Annuler
</AlertDialogCancel>
<AlertDialogAction
onClick={handleReconcile}
disabled={isReconciling}
className="bg-primary hover:bg-primary/90"
>
{isReconciling ? "Pointage..." : "Pointer"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}

View File

@@ -202,44 +202,49 @@ export function TransactionFilters({
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-4 space-y-4">
<div className="p-3 flex gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Date de début</label>
<CalendarComponent
mode="single"
selected={customStartDate}
onSelect={(date) => {
onCustomStartDateChange(date);
if (date && customEndDate && date > customEndDate) {
onCustomEndDateChange(undefined);
}
}}
locale={fr}
/>
<div className="scale-90 origin-top-left">
<CalendarComponent
mode="single"
selected={customStartDate}
onSelect={(date) => {
onCustomStartDateChange(date);
if (date && customEndDate && date > customEndDate) {
onCustomEndDateChange(undefined);
}
}}
locale={fr}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Date de fin</label>
<CalendarComponent
mode="single"
selected={customEndDate}
onSelect={(date) => {
if (date && customStartDate && date < customStartDate) {
return;
}
onCustomEndDateChange(date);
if (date && customStartDate) {
onCustomDatePickerOpenChange(false);
}
}}
disabled={(date) => {
if (!customStartDate) return true;
return date < customStartDate;
}}
locale={fr}
/>
<div className="scale-90 origin-top-left">
<CalendarComponent
mode="single"
selected={customEndDate}
onSelect={(date) => {
if (date && customStartDate && date < customStartDate) {
return;
}
onCustomEndDateChange(date);
if (date && customStartDate) {
onCustomDatePickerOpenChange(false);
}
}}
disabled={(date) => {
if (!customStartDate) return true;
return date < customStartDate;
}}
locale={fr}
/>
</div>
</div>
{customStartDate && customEndDate && (
<div className="flex gap-2 pt-2 border-t">
</div>
{customStartDate && customEndDate && (
<div className="flex gap-2 pt-2 border-t px-3 pb-3">
<Button
variant="outline"
size="sm"
@@ -260,7 +265,6 @@ export function TransactionFilters({
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
)}

View File

@@ -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 };
},
};