Checkpoint: Ajout de la prochaine date d'exécution dynamique dans les Paramètres (calcul frontend selon mode/heure/jour, aligné sur le cron réel backend). Ajout de la règle de rétention configurable (Illimité/3/6/12/18/24 mois) avec purge automatique au démarrage et après chaque import. 0 erreur TypeScript, 21 tests passés.

This commit is contained in:
Manus
2026-07-05 07:51:02 -04:00
parent f0be15d432
commit 55f3d5a5d4
6 changed files with 215 additions and 6 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -23,6 +23,8 @@ import {
Upload,
FileSpreadsheet,
AlertCircle,
CalendarClock,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
@@ -43,7 +45,96 @@ const SOURCE_LABELS: Record<SourceType, string> = {
sharepoint: "SharePoint",
};
// ─── Composant UploadZone (réutilisé depuis ImportLogs) ───────────────────────
const DAYS_FR = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
const DAYS_SHORT = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"];
// ─── Calcul de la prochaine date d'exécution ─────────────────────────────────
function computeNextRun(
mode: string,
importTime: string,
dayOfWeek: number,
dayOfMonth: number,
intervalMinutes: number
): Date {
const now = new Date();
const [hour, minute] = importTime.split(":").map(Number);
if (mode === "interval") {
const intervalMin = Math.max(60, intervalMinutes);
const next = new Date(now);
next.setSeconds(0, 0);
if (intervalMin % 60 === 0) {
const hours = intervalMin / 60;
if (hours >= 24) {
// cron `0 0 * * *` — prochain minuit
next.setHours(0, 0);
next.setDate(next.getDate() + 1);
} else {
// cron `0 */N * * *` — prochaine heure alignée
const currentHour = now.getHours();
const nextHour = Math.ceil((currentHour * 60 + now.getMinutes() + 1) / 60 / hours) * hours;
next.setHours(nextHour, 0);
if (next <= now) next.setHours(next.getHours() + hours);
}
} else {
// cron `*/N * * * *` — prochain multiple de N minutes
const totalMin = now.getHours() * 60 + now.getMinutes();
const nextMin = Math.ceil((totalMin + 1) / intervalMin) * intervalMin;
next.setHours(Math.floor(nextMin / 60), nextMin % 60);
if (next <= now) next.setMinutes(next.getMinutes() + intervalMin);
}
return next;
}
if (mode === "weekly") {
const next = new Date(now);
next.setSeconds(0, 0);
next.setHours(hour ?? 6, minute ?? 0);
// Avancer jusqu'au prochain jour de la semaine voulu
const diff = (dayOfWeek - next.getDay() + 7) % 7;
next.setDate(next.getDate() + (diff === 0 && next <= now ? 7 : diff));
return next;
}
if (mode === "monthly") {
const next = new Date(now);
next.setSeconds(0, 0);
next.setHours(hour ?? 6, minute ?? 0);
next.setDate(dayOfMonth);
if (next <= now) {
next.setMonth(next.getMonth() + 1);
next.setDate(dayOfMonth);
}
return next;
}
// scheduled (quotidien)
const next = new Date(now);
next.setSeconds(0, 0);
next.setHours(hour ?? 6, minute ?? 0);
if (next <= now) next.setDate(next.getDate() + 1);
return next;
}
function formatNextRun(date: Date, mode: string, intervalMinutes: number): string {
if (mode === "interval") {
const mins = Math.max(60, intervalMinutes);
if (mins < 60) return `dans ${mins} minutes`;
const h = mins / 60;
return `dans ${h}h (à ${date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })})`;
}
const dayName = DAYS_FR[date.getDay()];
const dayNum = date.getDate();
const monthName = date.toLocaleDateString("fr-FR", { month: "long" });
const timeStr = date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
const isToday = new Date().toDateString() === date.toDateString();
const isTomorrow = new Date(Date.now() + 86400000).toDateString() === date.toDateString();
const dayLabel = isToday ? "aujourd'hui" : isTomorrow ? "demain" : `${dayName} ${dayNum} ${monthName}`;
return `${dayLabel} à ${timeStr}`;
}
// ─── Composant UploadZone ─────────────────────────────────────────────────────
interface UploadResult {
success: boolean;
@@ -224,6 +315,7 @@ export default function SettingsPage() {
fetch_interval_minutes: "1440",
fetch_day_of_week: "1",
fetch_day_of_month: "1",
retention_months: "0",
});
useEffect(() => {
@@ -236,6 +328,21 @@ export default function SettingsPage() {
const handleSave = () => saveMutation.mutate(form as Parameters<typeof saveMutation.mutate>[0]);
const sourceType = (form.source_type || "local") as SourceType;
// ─── Calcul de la prochaine date d'exécution ──────────────────────────────
const nextRun = useMemo(() => {
return computeNextRun(
form.fetch_mode || "scheduled",
form.import_time || "06:00",
parseInt(form.fetch_day_of_week || "1", 10),
parseInt(form.fetch_day_of_month || "1", 10),
parseInt(form.fetch_interval_minutes || "1440", 10)
);
}, [form.fetch_mode, form.import_time, form.fetch_day_of_week, form.fetch_day_of_month, form.fetch_interval_minutes]);
const nextRunLabel = useMemo(() => {
return formatNextRun(nextRun, form.fetch_mode || "scheduled", parseInt(form.fetch_interval_minutes || "1440", 10));
}, [nextRun, form.fetch_mode, form.fetch_interval_minutes]);
return (
<div className="p-6 space-y-6 max-w-4xl animate-fade-up">
{/* En-tête */}
@@ -515,7 +622,7 @@ export default function SettingsPage() {
<div className="space-y-2 max-w-xs">
<Label>Jour de la semaine</Label>
<div className="grid grid-cols-7 gap-1">
{["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"].map((day, i) => (
{DAYS_SHORT.map((day, i) => (
<button
key={i}
type="button"
@@ -578,6 +685,65 @@ export default function SettingsPage() {
</div>
</div>
)}
{/* ── Prochaine date d'exécution */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 max-w-xl">
<CalendarClock size={16} className="text-primary shrink-0" />
<div className="text-sm">
<span className="text-muted-foreground">Prochain import prévu : </span>
<span className="font-semibold text-foreground">{nextRunLabel}</span>
</div>
</div>
</CardContent>
</Card>
{/* ── Rétention des articles ──────────────────────────────────── */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Trash2 size={16} className="text-muted-foreground" />
Rétention des articles
</CardTitle>
<CardDescription>
Supprimez automatiquement les articles plus anciens que la durée choisie.
La purge s'exécute au démarrage du serveur et après chaque import automatique.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2 max-w-xl">
{[
{ label: "Illimité", value: "0" },
{ label: "3 mois", value: "3" },
{ label: "6 mois", value: "6" },
{ label: "12 mois", value: "12" },
{ label: "18 mois", value: "18" },
{ label: "24 mois", value: "24" },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => set("retention_months", opt.value)}
className={cn(
"py-2 px-3 rounded-lg border-2 text-sm font-medium transition-all",
(form.retention_months || "0") === opt.value
? opt.value === "0"
? "border-emerald-500 bg-emerald-50 text-emerald-700"
: "border-primary bg-primary/5 text-primary"
: "border-border hover:border-primary/40 text-foreground"
)}
>
{opt.label}
</button>
))}
</div>
{(form.retention_months || "0") !== "0" && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 max-w-xl">
<AlertCircle size={14} className="text-amber-600 mt-0.5 shrink-0" />
<p className="text-xs text-amber-700">
Les articles importés il y a plus de <strong>{form.retention_months} mois</strong> seront supprimés définitivement lors du prochain démarrage ou import automatique.
</p>
</div>
)}
</CardContent>
</Card>