espace-paie-odentas/components/NotesSection.tsx
odentas bea8700104 feat: Amélioration système d'avenants et emails de relance
- 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
2025-11-03 19:19:57 +01:00

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>
);
}