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
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s
This commit is contained in:
69
app/api/banking/transactions/reconcile-date-range/route.ts
Normal file
69
app/api/banking/transactions/reconcile-date-range/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -950,11 +950,12 @@ 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>
|
||||
<div className="scale-90 origin-top-left">
|
||||
<CalendarComponent
|
||||
mode="single"
|
||||
selected={customStartDate}
|
||||
@@ -967,10 +968,12 @@ export default function StatisticsPage() {
|
||||
locale={fr}
|
||||
/>
|
||||
</div>
|
||||
</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={customEndDate}
|
||||
@@ -994,8 +997,10 @@ export default function StatisticsPage() {
|
||||
locale={fr}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{customStartDate && customEndDate && (
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
260
components/settings/reconcile-date-range-card.tsx
Normal file
260
components/settings/reconcile-date-range-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,9 +202,10 @@ 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>
|
||||
<div className="scale-90 origin-top-left">
|
||||
<CalendarComponent
|
||||
mode="single"
|
||||
selected={customStartDate}
|
||||
@@ -217,8 +218,10 @@ export function TransactionFilters({
|
||||
locale={fr}
|
||||
/>
|
||||
</div>
|
||||
</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={customEndDate}
|
||||
@@ -238,8 +241,10 @@ export function TransactionFilters({
|
||||
locale={fr}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{customStartDate && customEndDate && (
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user