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
This commit is contained in:
odentas 2025-11-03 19:19:57 +01:00
parent 2cd19df69f
commit bea8700104
14 changed files with 314 additions and 176 deletions

View file

@ -593,7 +593,7 @@ export async function POST(request: NextRequest) {
contract_id: contractId,
organization_id: orgId,
content: rawNote,
source: 'Espace Paie',
source: 'Client',
};
const { error: noteError } = await supabase
.from('notes')
@ -617,6 +617,38 @@ export async function POST(request: NextRequest) {
console.error('Exception lors de la création de la note liée au contrat:', noteCatchErr);
}
// Créer une note spécifique pour les paniers repas si fournie
try {
const panierRepasNote = typeof body.panier_repas_note === 'string' ? body.panier_repas_note.trim() : '';
if (panierRepasNote) {
const panierNotePayload: NoteInsert = {
contract_id: contractId,
organization_id: orgId,
content: panierRepasNote,
source: 'Système',
};
const { error: panierNoteError } = await supabase
.from('notes')
.insert([panierNotePayload]);
if (panierNoteError) {
console.warn('Erreur insertion note paniers repas avec client standard, tentative service_role:', panierNoteError);
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { error: srPanierNoteError } = await serviceSupabase
.from('notes')
.insert([panierNotePayload]);
if (srPanierNoteError) {
console.error('Échec insertion note paniers repas même avec service_role:', srPanierNoteError);
}
}
}
} catch (panierNoteCatchErr) {
console.error('Exception lors de la création de la note paniers repas:', panierNoteCatchErr);
}
// Correction de persistance: certains champs optionnels peuvent ne pas être renseignés par la RPC
// Assurer que jours_travail (jours de travail technicien) est bien enregistré si fourni
try {

View file

@ -65,7 +65,7 @@ export async function POST(req: Request, ctx: { params: { id: string } }) {
try {
const body = await req.json().catch(() => ({}));
const content: string = (body && body.content) || "";
const source: string = (body && body.source) || "Espace Paie";
const source: string = (body && body.source) || "Client";
if (!content || !content.trim()) {
return NextResponse.json({ error: "invalid_input", message: "content required" }, { status: 400 });

View file

@ -127,7 +127,8 @@ export async function GET(request: NextRequest) {
employeeId = contractData.employee_id;
orgId = contractData.org_id;
docusealSubmissionId = avenant.docuseal_submission_id;
pdfS3Key = avenant.pdf_s3_key;
// Utiliser signed_pdf_s3_key en priorité (PDF signé), sinon pdf_s3_key (PDF préliminaire)
pdfS3Key = avenant.signed_pdf_s3_key || avenant.pdf_s3_key;
documentId = avenant.id;
} else if (contract) {
documentData = {

View file

@ -34,11 +34,18 @@ export async function GET(
// Récupérer l'avenant
const { data: avenant, error } = await supabase
.from("avenants")
.select("pdf_s3_key")
.select("pdf_s3_key, signed_pdf_s3_key, signature_status")
.eq("id", id)
.single();
if (error || !avenant || !avenant.pdf_s3_key) {
if (error || !avenant) {
return NextResponse.json({ error: "Avenant non trouvé" }, { status: 404 });
}
// Utiliser signed_pdf_s3_key si disponible (PDF signé), sinon pdf_s3_key (PDF préliminaire)
const pdfS3Key = avenant.signed_pdf_s3_key || avenant.pdf_s3_key;
if (!pdfS3Key) {
return NextResponse.json({ error: "PDF non trouvé" }, { status: 404 });
}
@ -55,7 +62,7 @@ export async function GET(
s3Client,
new GetObjectCommand({
Bucket: "odentas-docs",
Key: avenant.pdf_s3_key,
Key: pdfS3Key,
}),
{ expiresIn: 3600 } // 1 heure
);

View file

@ -2,10 +2,6 @@ import { NextRequest, NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
import { sendUniversalEmailV2, EmailConfigV2 } from "@/lib/emailTemplateService";
// Configuration DocuSeal
const DOCUSEAL_API_URL = 'api.docuseal.eu';
const DOCUSEAL_API_KEY = process.env.DOCUSEAL_API_KEY || process.env.DOCUSEAL_TOKEN;
// Fonction pour formater une date ISO au format DD/MM/AAAA
const formatDate = (isoDate: string) => {
const date = new Date(isoDate);
@ -15,43 +11,6 @@ const formatDate = (isoDate: string) => {
return `${day}/${month}/${year}`;
};
// Fonction pour récupérer le slug employeur depuis DocuSeal
async function getEmployerSlug(docusealSubID: string): Promise<string> {
console.log("🔗 [REMIND-EMPLOYER-AVENANT] Récupération slug DocuSeal:", docusealSubID);
// Mode simulation si pas de clé API DocuSeal
if (!DOCUSEAL_API_KEY) {
console.log("⚠️ [REMIND-EMPLOYER-AVENANT] Mode simulation - Pas de clé API DocuSeal");
return `test-slug-${docusealSubID}`;
}
const response = await fetch(`https://${DOCUSEAL_API_URL}/submissions/${docusealSubID}`, {
method: 'GET',
headers: {
'X-Auth-Token': DOCUSEAL_API_KEY || '',
},
});
if (!response.ok) {
console.log("❌ [REMIND-EMPLOYER-AVENANT] Erreur DocuSeal API:", {
status: response.status,
statusText: response.statusText
});
throw new Error(`Erreur DocuSeal API: ${response.status}`);
}
const data = await response.json();
console.log("📋 [REMIND-EMPLOYER-AVENANT] Réponse DocuSeal:", { submitters: data.submitters?.length || 0 });
const employerSubmitter = data.submitters?.find((submitter: any) => submitter.role === 'Employeur');
if (!employerSubmitter || !employerSubmitter.slug) {
throw new Error('Slug du rôle Employeur non trouvé.');
}
return employerSubmitter.slug;
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
@ -209,23 +168,8 @@ export async function POST(
}, { status: 400 });
}
console.log("🔗 [REMIND-EMPLOYER-AVENANT] Récupération du slug employeur DocuSeal...");
// Récupération du slug employeur depuis DocuSeal
let employerSlug: string;
try {
employerSlug = await getEmployerSlug(avenant.docuseal_submission_id);
console.log("✅ [REMIND-EMPLOYER-AVENANT] Slug récupéré:", employerSlug);
} catch (error) {
console.log("❌ [REMIND-EMPLOYER-AVENANT] Erreur récupération slug:", error);
return NextResponse.json({
error: "Impossible de récupérer le lien de signature employeur"
}, { status: 500 });
}
// Construction du lien de signature
const siteBase = process.env.NEXT_PUBLIC_SITE_URL || 'https://paie.odentas.fr';
const signatureLink = `${siteBase}/signature-employeur?docuseal_id=${employerSlug}`;
// Construction du lien de signature vers la page des signatures électroniques
const signatureLink = 'https://paie.odentas.fr/signatures-electroniques';
console.log("📧 [REMIND-EMPLOYER-AVENANT] Préparation de l'email...");
@ -239,9 +183,11 @@ export async function POST(
customMessage: `Vous avez reçu un avenant au contrat de ${contract.employee_name}. Merci de le signer en cliquant sur le bouton ci-dessous.`,
ctaUrl: signatureLink,
organizationName: org.name || '',
code_employeur: orgDetails.code_employeur || '',
handlerName: 'Renaud BREVIERE-ABRAHAM',
employeeName: contract.employee_name || 'Salarié',
contractReference: avenant.numero_avenant || 'N/A',
productionName: contract.analytique || contract.production_name || '',
documentType: `Avenant ${avenant.numero_avenant || 'N/A'}`,
contractReference: contract.reference || contract.contract_number || 'N/A',
companyName: org.name || '',
}
};

View file

@ -73,7 +73,11 @@ export async function POST(req: NextRequest) {
production_name,
start_date,
org_id,
structure
structure,
profession,
role,
multi_mois,
type_de_contrat
)
`)
.eq('id', avenantId);
@ -226,6 +230,16 @@ export async function POST(req: NextRequest) {
employer: employerName
});
// Formatage de la date de début
const formattedStartDate = contract.start_date ? formatDate(contract.start_date) : 'N/A';
// Détermination du type de contrat
const isCDDU = contract.type_de_contrat === "CDD d'usage";
const isMultiMois = contract.multi_mois === "Oui";
const contractType = isCDDU
? (isMultiMois ? 'CDDU multi-mois' : 'CDDU mono-mois')
: (contract.type_de_contrat || 'Régime Général');
// Préparation de la configuration pour l'email
const emailConfig: EmailConfigV2 = {
type: 'signature-request-employee-amendment',
@ -236,7 +250,11 @@ export async function POST(req: NextRequest) {
customMessage: `Vous avez reçu un avenant à votre contrat. Merci de le signer en cliquant sur le bouton ci-dessous.`,
ctaUrl: signatureLink,
organizationName: employerName,
matricule: contract.employee_matricule || 'N/A',
contractReference: avenant.numero_avenant || 'N/A',
contractType: contractType,
profession: contract.profession || contract.role || 'N/A',
startDate: formattedStartDate,
productionName: contract.production_name || '',
companyName: employerName,
}

View file

@ -183,14 +183,14 @@ export async function POST(request: NextRequest) {
.update({
signature_status: "signed",
statut: "signed",
pdf_s3_key: s3Key,
signed_pdf_s3_key: s3Key, // Nouveau champ pour le PDF signé
})
.eq("id", avenant.id);
if (updateError) {
console.error("❌ [WEBHOOK AVENANT COMPLETED] Erreur mise à jour avenant:", updateError);
} else {
console.log("✅ [WEBHOOK AVENANT COMPLETED] Statut avenant mis à jour: signed + PDF S3");
console.log("✅ [WEBHOOK AVENANT COMPLETED] Statut avenant mis à jour: signed + PDF signé S3");
}
// 5. Mettre à jour le contrat pour marquer l'avenant comme signé

View file

@ -89,7 +89,7 @@ export default function AlreadySignedCard({ contract, signed_at, downloadUrl, do
<div>
<h1 className="text-2xl font-bold">{documentLabel} signé</h1>
<p className="text-green-50 text-sm mt-1">
Vous avez signé électroniquement ce {documentLabel.toLowerCase()}.
Vous avez signé électroniquement {documentType === 'avenant' ? 'cet avenant' : 'ce contrat'}.
</p>
</div>
</div>
@ -215,7 +215,7 @@ export default function AlreadySignedCard({ contract, signed_at, downloadUrl, do
className="w-full inline-flex items-center justify-center gap-3 px-6 py-4 text-base font-semibold text-white bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-700 hover:to-blue-700 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
<Download className="w-5 h-5" />
Télécharger le contrat signé
Télécharger {documentType === 'avenant' ? "l'avenant" : 'le contrat'} signé
</button>
</div>
)}

View file

@ -20,6 +20,7 @@ interface DatesQuantityModalProps {
pdfFormatted: string;
globalQuantity?: number;
globalDuration?: "3" | "4";
totalHours?: number; // Total des heures saisies (pour jours_travail)
}) => void;
selectedDates: string[]; // format input "12/10, 13/10, ..."
dateType: "representations" | "repetitions" | "jours_travail" | "heures_repetitions"; // Type de dates pour déterminer le libellé
@ -245,6 +246,7 @@ export default function DatesQuantityModal({
const handleApply = () => {
let globalQty: number | undefined = undefined;
let globalDur: "3" | "4" | undefined = undefined;
let totalHrs: number | undefined = undefined;
// Si on ne veut pas d'heures par jour, valider le nombre global
if (skipHoursByDay) {
@ -261,12 +263,22 @@ export default function DatesQuantityModal({
}
} else {
// Vérifier que toutes les quantités sont > 0
let sum = 0;
for (const iso of selectedIsos) {
const qty = quantities[iso];
if (!qty || qty === "" || (typeof qty === "number" && qty < 1)) {
setValidationError("Toutes les quantités doivent être >= 1");
return;
}
// Calculer le total pour jours_travail
if (dateType === "jours_travail" && typeof qty === "number") {
sum += qty;
}
}
// Si c'est des jours de travail, on retourne le total d'heures
if (dateType === "jours_travail" && sum > 0) {
totalHrs = sum;
}
}
@ -281,6 +293,7 @@ export default function DatesQuantityModal({
pdfFormatted,
globalQuantity: globalQty,
globalDuration: globalDur,
totalHours: totalHrs,
});
onClose();

View file

@ -37,7 +37,7 @@ function useContratNotes(contractId: string) {
});
}
function useAddNote(contractId: string, source: string = "Espace Paie") {
function useAddNote(contractId: string, source: string = "Client") {
const qc = useQueryClient();
return useMutation({
mutationFn: async (content: string) => {
@ -63,7 +63,7 @@ export function NotesSection({
title = "Notes",
showAddButton = true,
compact = false,
source = "Espace Paie",
source = "Client",
}: {
contractId: string;
contractRef?: string;

View file

@ -364,6 +364,7 @@ export function NouveauCDDUForm({
const [joursTravailDisplay, setJoursTravailDisplay] = useState("");
const [joursTravailOpen, setJoursTravailOpen] = useState(false);
const [joursTravailRaw, setJoursTravailRaw] = useState<string[]>([]); // Format input ["12/10", "13/10"]
// États pour les modales de quantités après sélection des dates
const [quantityModalOpen, setQuantityModalOpen] = useState(false);
@ -487,47 +488,75 @@ export function NouveauCDDUForm({
pdfFormatted: string;
globalQuantity?: number;
globalDuration?: "3" | "4";
totalHours?: number;
}) => {
// Si un nombre global est fourni, l'utiliser; sinon calculer le nombre de dates
const quantity = result.globalQuantity || result.selectedDates.length;
// Récupérer toutes les dates AVANT de faire les setState
const allDatesStrings: string[] = [];
// Convertir les dates sélectionnées en ISO (result.selectedDates sont au format "12/10, 13/10...")
const currentYearContext = dateDebut || new Date().toISOString().slice(0, 10);
const currentIsos = result.selectedDates
.map(d => parseFrenchedDate(d.trim(), currentYearContext))
.filter(iso => iso && iso.length === 10);
// Récupérer toutes les dates ISO existantes selon le type
let allIsos: string[] = [];
// Ajouter les dates selon le type
if (quantityModalType === "representations") {
allDatesStrings.push(...result.pdfFormatted.split(/[;,]/));
// Ajouter les dates de répétitions existantes
if (datesServ) allDatesStrings.push(...datesServ.split(/[;,]/));
// Ajouter les nouvelles représentations
allIsos.push(...currentIsos);
// Ajouter les répétitions existantes
if (datesServ) {
const servIsos = datesServ.split(/[;,]/)
.map(d => parseFrenchedDate(d.trim().replace(/^(le|du|au)\s+/i, '').replace(/\.$/, ''), currentYearContext))
.filter(iso => iso && iso.length === 10);
allIsos.push(...servIsos);
}
// Ajouter les jours de travail existants
if (joursTravail) allDatesStrings.push(...joursTravail.split(/[;,]/));
if (joursTravail) {
const jtIsos = joursTravail.split(/[;,]/)
.map(d => parseFrenchedDate(d.trim().replace(/^(le|du|au)\s+/i, '').replace(/\.$/, ''), currentYearContext))
.filter(iso => iso && iso.length === 10);
allIsos.push(...jtIsos);
}
} else if (quantityModalType === "repetitions") {
// Ajouter les dates de représentations existantes
if (datesRep) allDatesStrings.push(...datesRep.split(/[;,]/));
allDatesStrings.push(...result.pdfFormatted.split(/[;,]/));
// Ajouter les représentations existantes
if (datesRep) {
const repIsos = datesRep.split(/[;,]/)
.map(d => parseFrenchedDate(d.trim().replace(/^(le|du|au)\s+/i, '').replace(/\.$/, ''), currentYearContext))
.filter(iso => iso && iso.length === 10);
allIsos.push(...repIsos);
}
// Ajouter les nouvelles répétitions
allIsos.push(...currentIsos);
// Ajouter les jours de travail existants
if (joursTravail) allDatesStrings.push(...joursTravail.split(/[;,]/));
if (joursTravail) {
const jtIsos = joursTravail.split(/[;,]/)
.map(d => parseFrenchedDate(d.trim().replace(/^(le|du|au)\s+/i, '').replace(/\.$/, ''), currentYearContext))
.filter(iso => iso && iso.length === 10);
allIsos.push(...jtIsos);
}
} else if (quantityModalType === "jours_travail") {
// Ajouter les dates de représentations existantes
if (datesRep) allDatesStrings.push(...datesRep.split(/[;,]/));
// Ajouter les dates de répétitions existantes
if (datesServ) allDatesStrings.push(...datesServ.split(/[;,]/));
allDatesStrings.push(...result.pdfFormatted.split(/[;,]/));
// Ajouter les représentations existantes
if (datesRep) {
const repIsos = datesRep.split(/[;,]/)
.map(d => parseFrenchedDate(d.trim().replace(/^(le|du|au)\s+/i, '').replace(/\.$/, ''), currentYearContext))
.filter(iso => iso && iso.length === 10);
allIsos.push(...repIsos);
}
// Ajouter les répétitions existantes
if (datesServ) {
const servIsos = datesServ.split(/[;,]/)
.map(d => parseFrenchedDate(d.trim().replace(/^(le|du|au)\s+/i, '').replace(/\.$/, ''), currentYearContext))
.filter(iso => iso && iso.length === 10);
allIsos.push(...servIsos);
}
// Ajouter les nouveaux jours de travail
allIsos.push(...currentIsos);
}
// Convertir toutes les dates en format ISO et trier
const isos = allDatesStrings
.filter(d => d && d.trim())
.map(d => {
// Nettoyer la chaîne : enlever "le", "du", "au", ".", espaces
const cleaned = d.trim()
.replace(/^(le|du|au)\s+/i, '')
.replace(/\.$/, '')
.trim();
return parseFrenchedDate(cleaned, dateDebut || new Date().toISOString().slice(0, 10));
})
.filter(iso => iso && iso.length === 10)
.sort();
// Trier et dédupliquer
const isos = [...new Set(allIsos)].sort();
// Calculer les dates min/max et multi-mois
let newDateDebut = dateDebut;
@ -567,6 +596,24 @@ export function NouveauCDDUForm({
case "jours_travail":
setJoursTravail(result.pdfFormatted);
setJoursTravailDisplay(result.pdfFormatted);
setJoursTravailRaw(result.selectedDates); // Stocker les dates au format input
// Mettre à jour le total d'heures depuis la modale
// Si totalHours est fourni (heures par jour), on l'utilise
// Sinon si globalQuantity est fourni (mode "Ne pas appliquer d'heures par jour"), on l'utilise
const hoursToSet = result.totalHours ?? result.globalQuantity;
if (hoursToSet !== undefined) {
const hours = Math.floor(hoursToSet);
const remainder = hoursToSet - hours;
setHeuresTotal(hours);
// Si on a une demi-heure, on met 30 minutes
if (remainder >= 0.5) {
setMinutesTotal("30");
} else {
setMinutesTotal("0");
}
}
break;
}
@ -640,6 +687,8 @@ export function NouveauCDDUForm({
const parsed = parseDateString(dateStr, yearContext);
setJoursTravail(dateStr);
setJoursTravailDisplay(parsed.pdfFormatted);
// Extraire les dates au format input pour initialDates
setJoursTravailRaw(parsed.allDatesFr);
}
if (typeof prefill.multi_mois === "boolean") setIsMultiMois(prefill.multi_mois ? "Oui" : "Non");
@ -1252,6 +1301,29 @@ useEffect(() => {
return dateDebut < todayYmd();
}, [dateDebut]);
// Générer automatiquement une note système pour les paniers repas
const panierRepasNote = useMemo(() => {
if (panierRepas !== "Oui") return "";
const details: string[] = [];
// Nombre de paniers
if (nombrePaniersRepas !== "" && nombrePaniersRepas > 0) {
details.push(`Nombre : ${nombrePaniersRepas} panier${nombrePaniersRepas > 1 ? "s" : ""}`);
}
// Type de panier (CCN ou montant personnalisé)
if (panierRepasCCN === "Oui") {
details.push("Type : Au minima CCN");
} else if (panierRepasCCN === "Non" && montantParPanier !== "" && montantParPanier > 0) {
details.push(`Type : Montant personnalisé (${EURO.format(Number(montantParPanier))} / panier)`);
}
if (details.length === 0) return "";
return `Panier(s) repas demandé(s)\n${details.join(" • ")}`;
}, [panierRepas, nombrePaniersRepas, panierRepasCCN, montantParPanier]);
async function ensureTechniciensLoaded(){
if (techLoadedRef.current || techniciens) return;
try {
@ -1473,6 +1545,7 @@ useEffect(() => {
si_non_montant_par_panier: panierRepas === "Oui" && panierRepasCCN === "Non" && montantParPanier !== "" ? montantParPanier : undefined,
reference,
notes: notes || undefined,
panier_repas_note: panierRepasNote || undefined, // Note spécifique pour les paniers repas
send_email_confirmation: emailConfirm === "Oui",
valider_direct: validerDirect === "Oui",
} as const;
@ -1500,6 +1573,7 @@ useEffect(() => {
panier_repas: payload.panier_repas,
reference: payload.reference,
notes: payload.notes,
panier_repas_note: payload.panier_repas_note, // Note spécifique paniers repas
// Ajouter l'organisation sélectionnée si staff
org_id: selectedOrg?.id || null,
@ -1530,7 +1604,12 @@ useEffect(() => {
jours_travail_non_artiste: payload.jours_travail_non_artiste,
multi_mois: payload.multi_mois,
salaires_par_date: payload.salaires_par_date,
})
}),
// Champs paniers repas (communs aux deux régimes)
nombre_paniers_repas: payload.nombre_paniers_repas,
panier_repas_ccn: payload.panier_repas_ccn,
si_non_montant_par_panier: payload.si_non_montant_par_panier,
};
console.log("🚀 [onSubmit] contractData envoyé à l'API:", contractData);
@ -2166,7 +2245,65 @@ useEffect(() => {
title="Sélectionner les dates de répétitions"
/>
</>
) : null
) : (
<>
<FieldRow>
<div>
<Label>Indiquez les jours de travail</Label>
<div className="flex items-center gap-2">
<div
onClick={() => setJoursTravailOpen(true)}
className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center cursor-pointer hover:bg-slate-100 transition"
>
{joursTravailDisplay || "Cliquez pour sélectionner…"}
</div>
<button
type="button"
onClick={() => setJoursTravailOpen(true)}
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
>
Modifier
</button>
</div>
</div>
<div>
<Label required>Combien d'heures de travail au total ?</Label>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
value={heuresTotal}
onChange={(e) => setHeuresTotal(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="ex : 3"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<select
value={minutesTotal}
onChange={(e) => setMinutesTotal(e.target.value as "0" | "30")}
className="px-3 py-2 rounded-lg border bg-white text-sm"
aria-label="Minutes"
>
<option value="0">+ 00 min</option>
<option value="30">+ 30 min</option>
</select>
</div>
<p className="text-[11px] text-slate-500 mt-1">
Utilisez le menu pour ajouter 30 minutes (ex. 3 h 30).
</p>
</div>
</FieldRow>
{/* Calendrier pour jours de travail */}
<DatePickerCalendar
isOpen={joursTravailOpen}
onClose={() => setJoursTravailOpen(false)}
onApply={handleJoursTravailApply}
initialDates={joursTravailRaw}
title="Sélectionner les jours de travail"
/>
</>
)
)}
{/* Sous-card avec les champs auto-remplis */}
@ -2290,68 +2427,6 @@ useEffect(() => {
</p>
)}
</div>
{/* Sections spécifiques aux CDDU - masquées en mode RG */}
{!isRegimeRG && (
useHeuresMode ? (
<>
<FieldRow>
<div>
<Label required>Combien d'heures de travail au total ?</Label>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
value={heuresTotal}
onChange={(e) => setHeuresTotal(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="ex : 3"
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
/>
<select
value={minutesTotal}
onChange={(e) => setMinutesTotal(e.target.value as "0" | "30")}
className="px-3 py-2 rounded-lg border bg-white text-sm"
aria-label="Minutes"
>
<option value="0">+ 00 min</option>
<option value="30">+ 30 min</option>
</select>
</div>
<p className="text-[11px] text-slate-500 mt-1">
Utilisez le menu pour ajouter 30 minutes (ex. 3 h 30).
</p>
</div>
<div>
<Label>Indiquez les jours de travail</Label>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 rounded-lg border bg-slate-50 text-sm text-slate-700 min-h-[42px] flex items-center">
{joursTravailDisplay || "Cliquez pour sélectionner…"}
</div>
<button
type="button"
onClick={() => setJoursTravailOpen(true)}
className="px-3 py-2 rounded-lg border bg-white text-sm hover:bg-slate-50 transition whitespace-nowrap"
>
Modifier
</button>
</div>
</div>
</FieldRow>
{/* Calendrier pour jours de travail */}
<DatePickerCalendar
isOpen={joursTravailOpen}
onClose={() => setJoursTravailOpen(false)}
onApply={handleJoursTravailApply}
initialDates={joursTravail ? joursTravail.split(", ") : []}
title="Sélectionner les jours de travail"
minDate={dateDebut}
maxDate={dateFin}
/>
</>
) : null
)}
</Section>
{isPastStart && (
@ -2562,6 +2637,19 @@ useEffect(() => {
</p>
</div>
)}
{/* Affichage de la note générée pour les paniers repas */}
{panierRepas === "Oui" && panierRepasNote && (
<div className="mt-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs font-semibold text-amber-900 mb-1">Note automatique - Paniers repas</p>
<p className="text-xs text-amber-800 whitespace-pre-line">{panierRepasNote}</p>
</div>
</div>
</div>
)}
</div>
</FieldRow>
)}

View file

@ -29,7 +29,8 @@ export default function AvenantDetailPageClient({ avenant }: AvenantDetailPageCl
// Charger l'URL du PDF si la clé S3 existe
useEffect(() => {
if (avenant.pdf_s3_key) {
const pdfKey = avenant.signed_pdf_s3_key || avenant.pdf_s3_key;
if (pdfKey) {
setLoadingPdf(true);
fetch(`/api/staff/amendments/${avenant.id}/pdf-url`)
.then(res => res.json())
@ -41,7 +42,7 @@ export default function AvenantDetailPageClient({ avenant }: AvenantDetailPageCl
.catch(err => console.error("Erreur chargement URL PDF:", err))
.finally(() => setLoadingPdf(false));
}
}, [avenant.id, avenant.pdf_s3_key]);
}, [avenant.id, avenant.pdf_s3_key, avenant.signed_pdf_s3_key]);
const handleDelete = async () => {
setIsDeleting(true);
@ -599,14 +600,16 @@ export default function AvenantDetailPageClient({ avenant }: AvenantDetailPageCl
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-900">
{avenant.pdf_s3_key ? `Avenant ${avenant.numero_avenant}.pdf` : "PDF non généré"}
{(avenant.signed_pdf_s3_key || avenant.pdf_s3_key) ? `Avenant ${avenant.numero_avenant}.pdf` : "PDF non généré"}
</div>
<div className="text-sm text-slate-500 mt-1">
{avenant.pdf_s3_key ? "Document stocké sur AWS S3" : "Aucun document disponible"}
{avenant.signed_pdf_s3_key ? "Document signé stocké sur AWS S3" :
avenant.pdf_s3_key ? "Document préliminaire stocké sur AWS S3" :
"Aucun document disponible"}
</div>
{avenant.pdf_s3_key && (
{(avenant.signed_pdf_s3_key || avenant.pdf_s3_key) && (
<div className="text-xs text-slate-400 mt-1 font-mono truncate">
{avenant.pdf_s3_key}
{avenant.signed_pdf_s3_key || avenant.pdf_s3_key}
</div>
)}
</div>

View file

@ -778,7 +778,7 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
},
infoCard: [
{ label: 'Votre structure', key: 'organizationName' },
{ label: 'Votre code employeur', key: 'employerCode' },
{ label: 'Votre code employeur', key: 'code_employeur' },
{ label: 'Votre gestionnaire', key: 'handlerName' },
],
detailsCard: {
@ -786,8 +786,7 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
rows: [
{ label: 'Document', key: 'documentType' },
{ label: 'Salarié', key: 'employeeName' },
{ label: 'Référence', key: 'contractReference' },
{ label: 'Statut', key: 'status' },
{ label: 'Référence contrat', key: 'contractReference' },
]
}
},
@ -852,7 +851,7 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
detailsCard: {
title: 'Détails de l\'avenant',
rows: [
{ label: 'Référence contrat', key: 'contractReference' },
{ label: 'Référence avenant', key: 'contractReference' },
{ label: 'Type de contrat', key: 'contractType' },
{ label: 'Profession', key: 'profession' },
{ label: 'Date de début', key: 'startDate' },

View file

@ -0,0 +1,31 @@
-- Migration: Ajouter signed_pdf_s3_key à la table avenants
-- Date: 2025-11-03
-- Description: Séparer le PDF préliminaire (pdf_s3_key) du PDF signé (signed_pdf_s3_key)
-- Ajouter la nouvelle colonne
ALTER TABLE public.avenants
ADD COLUMN IF NOT EXISTS signed_pdf_s3_key TEXT;
-- Commentaires
COMMENT ON COLUMN public.avenants.signed_pdf_s3_key IS 'Clé S3 du PDF signé par toutes les parties (après signature complète)';
COMMENT ON COLUMN public.avenants.pdf_s3_key IS 'Clé S3 du PDF préliminaire (avant signature)';
-- Migration des données existantes : copier pdf_s3_key vers signed_pdf_s3_key pour les avenants déjà signés
-- Cela garantit que les avenants déjà signés auront le bon PDF disponible
UPDATE public.avenants
SET signed_pdf_s3_key = pdf_s3_key
WHERE statut = 'signed'
AND pdf_s3_key IS NOT NULL
AND signed_pdf_s3_key IS NULL;
-- Afficher le nombre d'avenants migrés
DO $$
DECLARE
migrated_count INTEGER;
BEGIN
SELECT COUNT(*) INTO migrated_count
FROM public.avenants
WHERE statut = 'signed' AND signed_pdf_s3_key IS NOT NULL;
RAISE NOTICE 'Migration terminée : % avenant(s) signé(s) migré(s)', migrated_count;
END $$;