CRA-Helper/artifacts/cra-app/src/pages/timesheet-detail.tsx
SylvainP1 7707be4eab Translate application interface and day names to French
Update translations for the 404 page and day names in the timesheet detail view.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 55837015-10e9-4be9-b857-7f5e6be73772
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 3f420d9d-06b6-481d-a9a3-eb72799fe9e0
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1cc377db-7ea0-49f2-97ce-c3e87e0228cc/55837015-10e9-4be9-b857-7f5e6be73772/58NwK8G
Replit-Helium-Checkpoint-Created: true
2026-04-14 08:14:32 +00:00

442 lines
16 KiB
TypeScript

import { useState, useMemo, useEffect, useRef, useCallback } from "react";
import { useParams, Link } from "wouter";
import {
useGetTimesheet,
getGetTimesheetQueryKey,
useUpdateTimesheet,
useCreateTimesheetLine,
useDeleteTimesheetLine,
useUpsertTimeEntries,
useListProjects,
getListProjectsQueryKey
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Save,
Send,
Plus,
Trash2,
CheckCircle,
AlertCircle
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { formatMonthYear, STATUS_LABELS, STATUS_COLORS, cn } from "@/lib/utils";
import { getDaysInMonth, isWeekend, format } from "date-fns";
import { fr } from "date-fns/locale";
type LocalEntry = {
timesheetLineId: number;
date: string;
hours: number;
};
export default function TimesheetDetailPage() {
const params = useParams();
const timesheetId = parseInt(params.id || "0");
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: timesheet, isLoading } = useGetTimesheet(
timesheetId,
{ query: { enabled: !!timesheetId, queryKey: getGetTimesheetQueryKey(timesheetId) } }
);
const updateTimesheet = useUpdateTimesheet();
const createLine = useCreateTimesheetLine();
const deleteLine = useDeleteTimesheetLine();
const upsertEntries = useUpsertTimeEntries();
// Local state for optimistic updates
const [localEntries, setLocalEntries] = useState<Record<string, number>>({});
const [isAddingLine, setIsAddingLine] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const { data: projects } = useListProjects({ query: { queryKey: getListProjectsQueryKey() } });
useEffect(() => {
if (timesheet?.lines) {
const initial: Record<string, number> = {};
timesheet.lines.forEach(line => {
line.entries?.forEach(entry => {
const dateStr = typeof entry.date === "string" && entry.date.length > 10
? entry.date.slice(0, 10)
: entry.date;
initial[`${line.id}-${dateStr}`] = entry.hours;
});
});
setLocalEntries(initial);
setHasUnsavedChanges(false);
}
}, [timesheet]);
// Derived calendar data
const { days, daysArray, isEditable } = useMemo(() => {
if (!timesheet) return { days: 0, daysArray: [], isEditable: false };
const daysCount = getDaysInMonth(new Date(timesheet.year, timesheet.month - 1));
const arr = Array.from({ length: daysCount }, (_, i) => {
const date = new Date(timesheet.year, timesheet.month - 1, i + 1);
return {
dateStr: format(date, "yyyy-MM-dd"),
dayNum: i + 1,
isWeekendDay: isWeekend(date),
dayName: format(date, "EE", { locale: fr })
};
});
return {
days: daysCount,
daysArray: arr,
isEditable: timesheet.status === "draft"
};
}, [timesheet]);
const handleCellClick = (lineId: number, dateStr: string) => {
if (!isEditable) return;
const key = `${lineId}-${dateStr}`;
const current = localEntries[key] || 0;
// Cycle: 0 -> 0.5 -> 1 -> 0
let next = 0.5;
if (current === 0.5) next = 1;
if (current === 1) next = 0;
setLocalEntries(prev => ({ ...prev, [key]: next }));
setHasUnsavedChanges(true);
};
const mutateFnRef = useRef(upsertEntries.mutate);
mutateFnRef.current = upsertEntries.mutate;
const handleSave = useCallback(() => {
if (!timesheetId) return;
const entriesToSave: LocalEntry[] = [];
Object.entries(localEntries).forEach(([key, hours]) => {
const [lineIdStr, date] = key.split("-", 2);
// Reconstruct the full date string since we split by '-'
const fullDate = key.substring(lineIdStr.length + 1);
if (hours > 0) {
entriesToSave.push({
timesheetLineId: parseInt(lineIdStr),
date: fullDate,
hours
});
}
});
mutateFnRef.current({
timesheetId,
data: { entries: entriesToSave }
}, {
onSuccess: () => {
toast({ title: "Modifications sauvegardées" });
setHasUnsavedChanges(false);
queryClient.invalidateQueries({ queryKey: getGetTimesheetQueryKey(timesheetId) });
},
onError: () => {
toast({ title: "Erreur de sauvegarde", variant: "destructive" });
}
});
}, [timesheetId, localEntries, toast, queryClient]);
const handleSubmit = () => {
if (hasUnsavedChanges) {
toast({ title: "Veuillez sauvegarder avant de soumettre", variant: "destructive" });
return;
}
if (confirm("Êtes-vous sûr de vouloir soumettre ce CRA ? Il ne pourra plus être modifié.")) {
updateTimesheet.mutate({
id: timesheetId,
data: { status: "submitted" }
}, {
onSuccess: () => {
toast({ title: "CRA soumis avec succès" });
queryClient.invalidateQueries({ queryKey: getGetTimesheetQueryKey(timesheetId) });
}
});
}
};
const handleAddLine = (projectId: number) => {
createLine.mutate({
timesheetId,
data: { projectId }
}, {
onSuccess: () => {
toast({ title: "Ligne ajoutée" });
setIsAddingLine(false);
queryClient.invalidateQueries({ queryKey: getGetTimesheetQueryKey(timesheetId) });
},
onError: (err: any) => {
toast({ title: "Erreur", description: err.message, variant: "destructive" });
}
});
};
const handleDeleteLine = (lineId: number) => {
if (confirm("Supprimer cette ligne et toutes ses saisies ?")) {
deleteLine.mutate({
timesheetId,
lineId
}, {
onSuccess: () => {
toast({ title: "Ligne supprimée" });
queryClient.invalidateQueries({ queryKey: getGetTimesheetQueryKey(timesheetId) });
}
});
}
};
if (isLoading) {
return <div className="animate-pulse space-y-6">
<div className="h-8 w-1/3 bg-muted rounded"></div>
<div className="h-[400px] bg-muted rounded-xl"></div>
</div>;
}
if (!timesheet) return <div>CRA introuvable</div>;
// Calculate totals
const rowTotals: Record<number, number> = {};
const colTotals: Record<string, number> = {};
let grandTotal = 0;
timesheet.lines?.forEach(line => {
let rowSum = 0;
daysArray.forEach(({ dateStr }) => {
const hours = localEntries[`${line.id}-${dateStr}`] || 0;
rowSum += hours;
colTotals[dateStr] = (colTotals[dateStr] || 0) + hours;
});
rowTotals[line.id] = rowSum;
grandTotal += rowSum;
});
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-8rem)]">
<div className="flex items-center justify-between shrink-0">
<div className="flex items-center gap-4">
<Link href="/timesheets" className="p-2 rounded-md hover:bg-muted transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h2 className="text-3xl font-bold tracking-tight capitalize">
{formatMonthYear(timesheet.year, timesheet.month)}
</h2>
<Badge variant="outline" className={cn("font-medium border-none", STATUS_COLORS[timesheet.status])}>
{STATUS_LABELS[timesheet.status]}
</Badge>
</div>
<p className="text-muted-foreground mt-1">
Collaborateur: {timesheet.collaborator}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{isEditable && (
<>
<Button
variant={hasUnsavedChanges ? "default" : "outline"}
onClick={handleSave}
disabled={!hasUnsavedChanges || upsertEntries.isPending}
className="gap-2"
>
<Save className="h-4 w-4" />
{hasUnsavedChanges ? "Sauvegarder les modifs" : "Sauvegardé"}
</Button>
<Button
variant="default"
className="gap-2 bg-green-600 hover:bg-green-700 text-white"
onClick={handleSubmit}
disabled={hasUnsavedChanges || updateTimesheet.isPending}
>
<Send className="h-4 w-4" />
Soumettre
</Button>
</>
)}
{timesheet.status === "submitted" && (
<Badge className="bg-blue-100 text-blue-800 hover:bg-blue-100 border-none px-3 py-1.5 text-sm gap-1">
<AlertCircle className="h-4 w-4" />
En attente de validation
</Badge>
)}
{timesheet.status === "validated" && (
<Badge className="bg-green-100 text-green-800 hover:bg-green-100 border-none px-3 py-1.5 text-sm gap-1">
<CheckCircle className="h-4 w-4" />
CRA Validé
</Badge>
)}
</div>
</div>
<div className="bg-card rounded-xl border shadow-sm flex-1 min-h-0 flex flex-col overflow-hidden">
{/* Actions bar */}
{isEditable && (
<div className="p-4 border-b flex justify-between items-center bg-muted/20 shrink-0">
<Button variant="outline" size="sm" onClick={() => setIsAddingLine(true)} className="gap-2">
<Plus className="h-4 w-4" />
Ajouter un projet
</Button>
<div className="text-sm font-medium">
Total facturable: <span className="text-lg text-primary">{grandTotal}h</span>
</div>
</div>
)}
{/* The Grid */}
<div className="flex-1 overflow-auto relative">
<table className="w-full text-sm text-left border-collapse min-w-max">
<thead className="sticky top-0 z-20 bg-muted/80 backdrop-blur supports-[backdrop-filter]:bg-muted/50 text-muted-foreground shadow-sm">
<tr>
<th className="sticky left-0 z-30 bg-muted/80 backdrop-blur supports-[backdrop-filter]:bg-muted/50 p-3 font-semibold border-b border-r min-w-[250px] shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)]">
Projet
</th>
{daysArray.map(day => (
<th
key={day.dateStr}
className={cn(
"p-2 font-medium text-center border-b border-r min-w-[40px]",
day.isWeekendDay ? "bg-muted/50" : ""
)}
>
<div className="flex flex-col items-center">
<span className="text-[10px] uppercase opacity-70">{day.dayName}</span>
<span>{day.dayNum}</span>
</div>
</th>
))}
<th className="p-3 font-semibold border-b text-center sticky right-0 z-20 bg-muted/80 backdrop-blur supports-[backdrop-filter]:bg-muted/50 shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]">
Total
</th>
</tr>
</thead>
<tbody>
{!timesheet.lines || timesheet.lines.length === 0 ? (
<tr>
<td colSpan={days + 2} className="p-8 text-center text-muted-foreground">
Aucun projet ajouté. Cliquez sur "Ajouter un projet" pour commencer.
</td>
</tr>
) : (
timesheet.lines.map(line => (
<tr key={line.id} className="border-b hover:bg-muted/20 transition-colors group">
<td className="sticky left-0 z-10 bg-card p-3 border-r shadow-[2px_0_5px_-2px_rgba(0,0,0,0.05)] group-hover:bg-muted/20">
<div className="flex items-center justify-between">
<div className="truncate pr-2">
<div className="font-medium truncate" title={line.projectName}>{line.projectName}</div>
<div className="text-xs text-muted-foreground">{line.projectCode} {line.client ? `${line.client}` : ''}</div>
</div>
{isEditable && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 shrink-0"
onClick={() => handleDeleteLine(line.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</td>
{daysArray.map(day => {
const key = `${line.id}-${day.dateStr}`;
const val = localEntries[key] || 0;
return (
<td
key={day.dateStr}
onClick={() => handleCellClick(line.id, day.dateStr)}
className={cn(
"p-0 text-center border-r transition-colors select-none",
day.isWeekendDay ? "bg-muted/30" : "",
isEditable ? "cursor-pointer hover:bg-primary/10" : "",
val > 0 ? "bg-primary/5 text-primary font-medium" : ""
)}
>
<div className="flex items-center justify-center h-12 w-full">
{val === 0 ? <span className="opacity-0 group-hover:opacity-20">-</span> : val}
</div>
</td>
);
})}
<td className="p-3 text-center font-medium bg-card sticky right-0 z-10 shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.05)] group-hover:bg-muted/20">
{rowTotals[line.id] > 0 ? (
<Badge variant="secondary" className="bg-primary/10 text-primary hover:bg-primary/20">
{rowTotals[line.id]}h
</Badge>
) : "-"}
</td>
</tr>
))
)}
</tbody>
<tfoot className="sticky bottom-0 z-20 bg-muted/80 backdrop-blur supports-[backdrop-filter]:bg-muted/50 font-medium shadow-[0_-2px_5px_-2px_rgba(0,0,0,0.1)]">
<tr>
<td className="sticky left-0 z-30 p-3 border-r text-right shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)]">
Total du jour
</td>
{daysArray.map(day => (
<td key={day.dateStr} className="p-2 text-center border-r">
{colTotals[day.dateStr] > 0 ? colTotals[day.dateStr] : ""}
</td>
))}
<td className="p-3 text-center text-primary text-lg sticky right-0 z-20 shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]">
{grandTotal}h
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<Dialog open={isAddingLine} onOpenChange={setIsAddingLine}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Ajouter un projet</DialogTitle>
</DialogHeader>
<div className="py-4">
<Select onValueChange={(val) => handleAddLine(parseInt(val))}>
<SelectTrigger>
<SelectValue placeholder="Sélectionner un projet" />
</SelectTrigger>
<SelectContent>
{projects?.filter(p => p.isActive).map(p => (
<SelectItem key={p.id} value={p.id.toString()}>
{p.code} - {p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddingLine(false)}>
Annuler
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}