- Email employeur: ajout code_employeur, correction structure détails document - Email salarié: ajout matricule, type contrat, profession, date début - Séparation PDF préliminaire/signé (signed_pdf_s3_key) pour éviter timing issues - Correction UI: grammaire et libellés conditionnels (avenant/contrat) - Standardisation source notes: 'Client' au lieu de 'Espace Paie' - Ajout note automatique pour paniers repas avec détails - Calcul automatique total heures depuis modale jours de travail - Migration SQL: ajout colonne signed_pdf_s3_key + migration données existantes
217 lines
7.9 KiB
TypeScript
217 lines
7.9 KiB
TypeScript
// components/NotesSection.tsx
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Loader2, StickyNote } from "lucide-react";
|
|
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
export type Note = {
|
|
id: string;
|
|
content: string;
|
|
author?: string;
|
|
source?: string;
|
|
created_at?: string;
|
|
source_created_at?: string;
|
|
};
|
|
|
|
function formatDateTimeFR(iso?: string) {
|
|
if (!iso) return "";
|
|
const d = new Date(iso);
|
|
return d.toLocaleString("fr-FR", {
|
|
day: "2-digit", month: "2-digit", year: "numeric",
|
|
hour: "2-digit", minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
function useContratNotes(contractId: string) {
|
|
return useQuery<Note[]>({
|
|
queryKey: ["contrat", contractId, "notes"],
|
|
queryFn: async () => {
|
|
const res = await fetch(`/api/contrats/${contractId}/notes`, { credentials: "include" });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const json = await res.json();
|
|
return Array.isArray(json?.items) ? json.items : [];
|
|
},
|
|
staleTime: 10_000,
|
|
});
|
|
}
|
|
|
|
function useAddNote(contractId: string, source: string = "Client") {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (content: string) => {
|
|
const res = await fetch(`/api/contrats/${contractId}/notes`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ content, source }),
|
|
});
|
|
if (!res.ok) {
|
|
const t = await res.text().catch(() => "");
|
|
throw new Error(t || `HTTP ${res.status}`);
|
|
}
|
|
return true;
|
|
},
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["contrat", contractId, "notes"] }),
|
|
});
|
|
}
|
|
|
|
export function NotesSection({
|
|
contractId,
|
|
contractRef,
|
|
title = "Notes",
|
|
showAddButton = true,
|
|
compact = false,
|
|
source = "Client",
|
|
}: {
|
|
contractId: string;
|
|
contractRef?: string;
|
|
title?: string;
|
|
showAddButton?: boolean;
|
|
compact?: boolean;
|
|
source?: string;
|
|
}) {
|
|
const { data: notes, isLoading, isError, error } = useContratNotes(contractId);
|
|
const addNote = useAddNote(contractId, source);
|
|
|
|
const [open, setOpen] = useState(false);
|
|
const [text, setText] = useState("");
|
|
const [localErr, setLocalErr] = useState<string | undefined>(undefined);
|
|
|
|
const submit = async () => {
|
|
if (!text.trim()) { setLocalErr("La note est vide."); return; }
|
|
setLocalErr(undefined);
|
|
try {
|
|
await addNote.mutateAsync(text.trim());
|
|
setText("");
|
|
setOpen(false);
|
|
} catch (e: any) {
|
|
setLocalErr(e?.message || "Échec de l'envoi");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-slate-900">
|
|
<StickyNote className="h-5 w-5" />
|
|
{title}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<div className="p-6 pt-0">
|
|
<div className="p-4">
|
|
{showAddButton && (
|
|
<div className="flex items-center justify-between mb-3">
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-600 text-white text-sm shadow hover:opacity-90 transition"
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
Ajouter une note
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isLoading && (
|
|
<div className="text-sm text-slate-500">
|
|
<Loader2 className="w-4 h-4 inline animate-spin mr-2" />
|
|
Chargement des notes…
|
|
</div>
|
|
)}
|
|
|
|
{isError && (
|
|
<div className="text-sm text-rose-500">
|
|
Impossible de charger les notes{error ? ` : ${(error as any)?.message ?? ""}` : ""}
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && !isError && (
|
|
<div className={compact ? "space-y-2" : "space-y-3"}>
|
|
{notes && notes.length > 0 ? (
|
|
notes.map((n) => (
|
|
<div
|
|
key={n.id}
|
|
className={compact
|
|
? "p-3 border border-gray-200 rounded-lg bg-white"
|
|
: "p-4 border border-gray-200 rounded-xl shadow-sm hover:shadow-md transition-shadow bg-gray-50"}
|
|
>
|
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
|
{n.author ? (
|
|
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-slate-200 text-slate-700">
|
|
{n.author}
|
|
</span>
|
|
) : null}
|
|
{/* Source badge */}
|
|
{n.source ? (
|
|
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800">
|
|
{n.source}
|
|
</span>
|
|
) : null}
|
|
{((n.source_created_at && n.source_created_at.length) || (n.created_at && n.created_at.length)) ? (
|
|
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600">
|
|
{formatDateTimeFR(n.source_created_at || n.created_at)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className={compact ? "text-[13px] whitespace-pre-wrap leading-snug text-slate-800" : "text-sm whitespace-pre-wrap leading-relaxed text-slate-800"}>
|
|
{n.content}
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-slate-500">Aucune note pour l'instant.</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modal ajout de note */}
|
|
{open && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
{/* overlay */}
|
|
<div className="absolute inset-0 bg-black/40" onClick={() => !addNote.isPending && setOpen(false)} />
|
|
{/* card */}
|
|
<div className="relative w-[92vw] max-w-xl rounded-2xl overflow-hidden shadow-2xl border border-slate-200 bg-white">
|
|
<div className="px-5 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white">
|
|
<div className="text-sm opacity-90">Ajout de note</div>
|
|
<div className="text-lg font-semibold truncate">Contrat {contractRef || contractId}</div>
|
|
</div>
|
|
<div className="p-5 space-y-4">
|
|
<label className="text-sm text-slate-600">Votre note</label>
|
|
<textarea
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
rows={6}
|
|
className="w-full resize-none rounded-lg border border-slate-300 bg-white p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="Saisissez ici votre note…"
|
|
autoFocus
|
|
disabled={addNote.isPending}
|
|
/>
|
|
{localErr && <div className="text-sm text-rose-600">{localErr}</div>}
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
className="px-3 py-2 rounded-lg border border-slate-300 text-sm"
|
|
onClick={() => setOpen(false)}
|
|
disabled={addNote.isPending}
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-600 text-white text-sm shadow hover:opacity-90 transition disabled:opacity-60"
|
|
onClick={submit}
|
|
disabled={addNote.isPending}
|
|
>
|
|
{addNote.isPending && <Loader2 className="w-4 h-4 animate-spin" />} Envoyer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|