espace-paie-odentas/app/api/staff/contracts/[id]/route.ts
odentas 965b1fb9cd feat: Ajouter interface de saisie en masse du temps de travail réel
- Création de la page /staff/contrats/saisie-temps-reel avec tableau éditable
- Ajout des colonnes jours_representations et jours_repetitions dans l'API
- Construction intelligente du TT Contractuel (concaténation des sources)
- Ajout de la colonne temps_reel_traite pour marquer les contrats traités
- Interface avec filtres (année, mois, organisation, recherche)
- Tri par date/salarié
- Édition inline avec auto-save via API
- Checkbox pour marquer comme traité (masque automatiquement la ligne)
- Toggle pour afficher/masquer les contrats traités
- Migration SQL pour la colonne temps_reel_traite
- Ajout du menu 'Temps de travail réel' dans la sidebar
- Logs de débogage pour le suivi des sauvegardes
2025-11-28 12:31:02 +01:00

197 lines
7.5 KiB
TypeScript

import { NextResponse } from "next/server";
import { createSbServer } from "@/lib/supabaseServer";
async function assertStaff(sb: ReturnType<typeof createSbServer>, userId: string) {
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", userId)
.maybeSingle();
return !!me?.is_staff;
}
function sanitizeDate(d: any) {
if (!d) return null;
const s = String(d);
const val = s.length >= 10 ? s.slice(0, 10) : s;
return /\d{4}-\d{2}-\d{2}/.test(val) ? val : null;
}
// Helper function to parse monetary amounts with French decimal notation (comma)
function parseMonetaryAmount(value: any): number | null {
if (value === null || value === undefined || value === "") return null;
const str = String(value).trim();
if (!str) return null;
// Replace comma with dot for proper parseFloat
const normalized = str.replace(',', '.');
const parsed = parseFloat(normalized);
return isNaN(parsed) ? null : parsed;
}
// Mapping des noms de champs frontend -> Supabase
const FIELD_MAPPING: Record<string, string> = {
"dates_repetitions": "jours_repetitions",
"nb_representations": "cachets_representations",
"nb_services_repetition": "services_repetitions",
"dates_representations": "jours_representations",
"heures_total": "nombre_d_heures",
"panier_repas": "paniers_repas"
// Retirer le mapping brut -> gross_pay
};
const CONTRACT_UPDATABLE_FIELDS = new Set([
// Champs de base
"role", "start_date", "end_date", "hours", "gross_pay", "net_pay", "notes",
"contract_pdf_s3_key", "reference", "structure",
// Champs de workflow
"etat_de_la_demande", "contrat_signe", "dpae", "etat_de_la_paie", "aem",
// Informations contrat
"production_name", "employee_name", "employee_matricule", "categorie_pro", "profession",
"objet_spectacle", "contract_number", "analytique",
// Champs CDDU spécifiques (noms exacts de Supabase)
"salaire", "type_salaire", "cachets_representations", "services_repetitions",
"nombre_d_heures", "jours_representations", "jours_repetitions",
"jours_travail_non_artiste", "jours_travail", "paniers_repas", "nombre_paniers_repas",
"n_objet", "type_de_contrat", "type_d_embauche", "date_de_reception_de_la_demande",
"net", "net_apres_pas", "brut", "cout_employeur", "fin_reelle", "motif_fin_contrat",
"contrat_signe_par_employeur", "nombre_d_heures_par_jour", "precisions_salaire",
"autreprecision_duree", "autreprecision_salaire",
"heure_traitement_demande", "cachets", "heures_de_repet", "heures_annexe_8",
"date_signature_contrat_salarie", "multi_mois", "panier_repas_ccn",
"si_non_montant_par_panier", "date_signature_contrat_employeur", "virement_effectue",
"indemnite_hebergement", "nombre_indemnite_hebergement", "indemnite_hebergement_ccn",
"si_non_montant_par_indemnite", "mineur_entre_16_et_18", "civilite_representant_legal",
"nom_representant_legal", "adresse_representant_legal", "dob_representant_legal",
"cob_representant_legal", "periode", "docuseal_template_id", "docuseal_submission_id",
"motif_cdd", "contract_kind", "date_signature",
// Temps de travail réel (informatif pour le client)
"jours_travail_reel",
"nb_representations_reel",
"nb_services_repetitions_reel", "nb_heures_repetitions_reel",
"nb_heures_annexes_reel",
"nb_cachets_aem_reel", "nb_heures_aem_reel"
]);
export async function GET(_req: Request, { params }: { params: { id: string } }) {
const supabase = createSbServer();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
const isStaff = await assertStaff(supabase, user.id);
if (!isStaff) {
return NextResponse.json({ error: "Accès réservé au staff." }, { status: 403 });
}
const { data: contract, error: cErr } = await supabase
.from("cddu_contracts")
.select("*")
.eq("id", params.id)
.single();
if (cErr || !contract) {
return NextResponse.json({ error: cErr?.message ?? "Contrat introuvable." }, { status: 404 });
}
const { data: payslips } = await supabase
.from("payslips")
.select("*")
.eq("contract_id", params.id)
.order("pay_number", { ascending: true });
return NextResponse.json({ contract, payslips: payslips ?? [] });
}
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
const supabase = createSbServer();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
const isStaff = await assertStaff(supabase, user.id);
if (!isStaff) return NextResponse.json({ error: "Accès réservé au staff." }, { status: 403 });
const body = await req.json().catch(() => ({}));
// Logging pour debug
console.log("PATCH body received:", JSON.stringify(body, null, 2));
if (body?.contract && typeof body.contract === "object") {
const patch: Record<string, any> = {};
const skippedFields: string[] = [];
for (const k of Object.keys(body.contract)) {
// Mapper le nom du champ frontend vers le nom Supabase
const supabaseFieldName = FIELD_MAPPING[k] || k;
if (!CONTRACT_UPDATABLE_FIELDS.has(supabaseFieldName)) {
skippedFields.push(k);
continue;
}
let v = body.contract[k];
// Nettoyer les valeurs vides pour les champs numériques
if (v === "" || v === "null" || v === "undefined") {
v = null;
}
// Sanitizer les dates
if (k === "start_date" || k === "end_date") {
v = sanitizeDate(v);
}
// Normaliser les champs monétaires (gérer les virgules françaises)
if (["gross_pay", "net_pay", "brut", "net", "net_apres_pas", "cout_employeur", "salaire", "paniers_repas", "panier_repas"].includes(supabaseFieldName)) {
v = parseMonetaryAmount(v);
}
// Utiliser le nom Supabase dans le patch
patch[supabaseFieldName] = v;
}
// Synchronisation des champs brut et gross_pay pour maintenir la cohérence
if (patch.gross_pay !== undefined) {
// Si gross_pay est mis à jour (champ "Brut" de l'interface), synchroniser brut seulement si brut n'est pas déjà défini
if (patch.brut === undefined) {
patch.brut = patch.gross_pay;
}
}
console.log("Fields to update:", patch);
console.log("Skipped fields:", skippedFields);
if (Object.keys(patch).length > 0) {
console.log("About to update Supabase with:", patch);
console.log("Contract ID:", params.id);
console.log("User ID:", user.id);
const { data, error: uErr, count } = await supabase
.from("cddu_contracts")
.update(patch)
.eq("id", params.id)
.select("*");
if (uErr) {
console.log("Supabase update error:", uErr);
return NextResponse.json({ error: uErr.message }, { status: 400 });
}
console.log("Update result - data:", data);
console.log("Update result - count:", count);
console.log("Update successful for fields:", Object.keys(patch));
} else {
console.log("No valid fields to update");
}
}
const { data: contract } = await supabase
.from("cddu_contracts").select("*").eq("id", params.id).single();
const { data: payslips } = await supabase
.from("payslips")
.select("*")
.eq("contract_id", params.id)
.order("pay_number", { ascending: true });
return NextResponse.json({ contract, payslips: payslips ?? [] });
}