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
442 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|