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:
parent
2cd19df69f
commit
bea8700104
14 changed files with 314 additions and 176 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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é
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
Loading…
Reference in a new issue