194 lines
8.6 KiB
TypeScript
194 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { supabase } from "@/lib/supabaseClient";
|
|
|
|
type Contract = {
|
|
id: string;
|
|
contract_number?: string | null;
|
|
employee_name?: string | null;
|
|
structure?: string | null;
|
|
type_de_contrat?: string | null;
|
|
start_date?: string | null;
|
|
end_date?: string | null;
|
|
brut?: string | null;
|
|
notes?: string | null;
|
|
created_at?: string | null;
|
|
// common extra fields
|
|
reference?: string | null;
|
|
employee_matricule?: string | null;
|
|
production_name?: string | null;
|
|
gross_pay?: string | null;
|
|
net_pay?: string | null;
|
|
contract_pdf_s3_key?: string | null;
|
|
payslips_count?: number | null;
|
|
etat_de_la_demande?: string | null;
|
|
contrat_signe?: string | null;
|
|
dpae?: string | null;
|
|
etat_de_la_paie?: string | null;
|
|
aem?: string | null;
|
|
// possible aliases / alternate column names present in data
|
|
salaries?: string | null;
|
|
matricule?: string | null;
|
|
production?: string | null;
|
|
debut_contrat?: string | null;
|
|
fin_contrat?: string | null;
|
|
contrat_signe_par_employeur?: string | null;
|
|
contrat_pdf?: string | null;
|
|
bulletin_paie?: string | null;
|
|
};
|
|
|
|
export default function ContractSlide({ row, onClose, onSave }: { row: Contract | null; onClose: () => void; onSave: (id: string, updates: Partial<Contract>) => void; }) {
|
|
const [local, setLocal] = useState<Contract | null>(row);
|
|
const timersRef = useRef<Record<string, number>>({});
|
|
const [saveStatus, setSaveStatus] = useState<Record<string, 'idle'|'saving'|'saved'|'error'>>({});
|
|
|
|
// sync when row changes
|
|
if (row && (!local || row.id !== local.id)) {
|
|
setLocal(row);
|
|
}
|
|
|
|
if (!row) return null;
|
|
|
|
async function save() {
|
|
if (!local) return;
|
|
try {
|
|
const res = await fetch(`/api/staff/contracts/update`, { method: "POST", body: JSON.stringify(local), headers: { "Content-Type": "application/json" } });
|
|
if (!res.ok) {
|
|
const j = await res.json().catch(() => ({}));
|
|
throw new Error(j?.error || "Update failed");
|
|
}
|
|
onSave(local.id, local);
|
|
onClose();
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert("Échec de la sauvegarde");
|
|
}
|
|
}
|
|
|
|
// Save single field to server (immediate)
|
|
async function saveField(field: string, value: any) {
|
|
if (!local) return;
|
|
// clear any pending timer for this field
|
|
try { if (timersRef.current[field]) { clearTimeout(timersRef.current[field]); delete timersRef.current[field]; } } catch (e) {}
|
|
setSaveStatus((s) => ({ ...s, [field]: 'saving' }));
|
|
try {
|
|
const body: any = { id: local.id };
|
|
body[field] = value;
|
|
const res = await fetch(`/api/staff/contracts/update`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
if (!res.ok) {
|
|
const j = await res.json().catch(() => ({}));
|
|
throw new Error(j?.error || 'Save failed');
|
|
}
|
|
// optimistic update to parent grid
|
|
onSave(local.id, { [field]: value } as Partial<Contract>);
|
|
setSaveStatus((s) => ({ ...s, [field]: 'saved' }));
|
|
// reset saved state after short delay
|
|
setTimeout(() => setSaveStatus((s) => ({ ...s, [field]: 'idle' })), 1200);
|
|
} catch (err) {
|
|
console.error('saveField error', field, err);
|
|
setSaveStatus((s) => ({ ...s, [field]: 'error' }));
|
|
}
|
|
}
|
|
|
|
// Debounced schedule save for a field
|
|
function scheduleSaveField(field: string, value: any, delay = 700) {
|
|
try { if (timersRef.current[field]) clearTimeout(timersRef.current[field]); } catch (e) {}
|
|
const id = window.setTimeout(() => saveField(field, value), delay);
|
|
timersRef.current[field] = id;
|
|
}
|
|
|
|
return (
|
|
<div className="fixed right-4 top-16 w-[min(900px,80vw)] h-[calc(100vh-4rem)] bg-white border rounded-lg shadow-lg p-4 overflow-auto">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-semibold">Détails contrat</h3>
|
|
<div className="flex items-center gap-2">
|
|
<button className="px-3 py-1 rounded border" onClick={onClose}>Fermer</button>
|
|
<button className="px-3 py-1 rounded bg-emerald-600 text-white" onClick={save}>Enregistrer</button>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{/* Render all available fields dynamically, but prefer a defined order */}
|
|
{(() => {
|
|
if (!local) return null;
|
|
const preferred = [
|
|
'contract_number','reference','employee_name','employee_matricule','structure','type_de_contrat','production_name',
|
|
'start_date','end_date','brut','net_pay','etat_de_la_demande','contrat_signe','contrat_signe_par_employeur','dpae','aem',
|
|
'payslips_count','contract_pdf_s3_key','contrat_pdf','bulletin_paie','notes','created_at'
|
|
];
|
|
const keys = Array.from(new Set([...(preferred.filter((k) => (local as any)[k] !== undefined && (local as any)[k] !== null)), ...Object.keys(local)]));
|
|
|
|
return keys.map((key) => {
|
|
const rawVal = (local as any)[key];
|
|
const value = rawVal === null || rawVal === undefined ? '' : String(rawVal);
|
|
const isReadonly = key === 'id' || key === 'created_at';
|
|
// guess input type
|
|
const lower = key.toLowerCase();
|
|
let inputType: 'text'|'date'|'number'|'checkbox'|'textarea' = 'text';
|
|
if (lower.includes('date') || lower.includes('debut') || lower.includes('fin')) inputType = 'date';
|
|
else if (lower.includes('brut') || lower.includes('pay') || lower.includes('net') || lower.includes('montant') || lower.includes('salaire') || lower === 'payslips_count') inputType = 'number';
|
|
else if (lower.includes('note') || value.includes('\n')) inputType = 'textarea';
|
|
else if (lower.includes('signe') || lower.includes('signed') || lower.startsWith('is_') || value === 'true' || value === 'false') inputType = 'checkbox';
|
|
|
|
const label = key.replace(/_/g, ' ').replace(/\b([a-z])/g, (m) => m.toUpperCase());
|
|
|
|
return (
|
|
<div key={key}>
|
|
<label className="text-xs text-slate-600 flex items-center justify-between">
|
|
<span>{label}</span>
|
|
<span className="text-xs text-slate-400">{saveStatus[key] === 'saving' ? '…' : saveStatus[key] === 'saved' ? '✓' : saveStatus[key] === 'error' ? '⚠' : ''}</span>
|
|
</label>
|
|
<div className="mt-1">
|
|
{isReadonly ? (
|
|
<div className="rounded border px-2 py-1 text-sm bg-slate-50">{value || '—'}</div>
|
|
) : inputType === 'textarea' ? (
|
|
<textarea
|
|
className="w-full rounded border px-2 py-1 text-sm"
|
|
value={value}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
setLocal((l) => l ? ({ ...l, [key]: v } as Contract) : l);
|
|
scheduleSaveField(key, v);
|
|
}}
|
|
onBlur={(e) => saveField(key, e.target.value)}
|
|
/>
|
|
) : inputType === 'checkbox' ? (
|
|
<input
|
|
type="checkbox"
|
|
checked={value === 'true' || value === '1' || value === 'on'}
|
|
onChange={(e) => {
|
|
const v = e.target.checked;
|
|
setLocal((l) => l ? ({ ...l, [key]: v } as Contract) : l);
|
|
saveField(key, v);
|
|
}}
|
|
/>
|
|
) : (
|
|
<input
|
|
type={inputType === 'number' ? 'number' : (inputType === 'date' ? 'date' : 'text')}
|
|
className="w-full rounded border px-2 py-1 text-sm"
|
|
value={value}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
setLocal((l) => l ? ({ ...l, [key]: v } as Contract) : l);
|
|
scheduleSaveField(key, inputType === 'number' ? (v === '' ? null : Number(v)) : v);
|
|
}}
|
|
onBlur={(e) => {
|
|
const v = (e.target as HTMLInputElement).value;
|
|
saveField(key, inputType === 'number' ? (v === '' ? null : Number(v)) : v);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
(e.target as HTMLInputElement).blur();
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
})()}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|