espace-paie-odentas/app/(app)/contrats/nouveau/saisie-tableau/page.tsx
2025-10-12 17:05:46 +02:00

1806 lines
No EOL
72 KiB
TypeScript

"use client";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { createPortal } from 'react-dom';
import AccessDeniedCard from "@/components/AccessDeniedCard";
import { usePageTitle } from "@/hooks/usePageTitle";
// --- Utils: debounce ---------------------------------------------------------
function generateReference() {
const letters = "ABCDEFGHIKLMNPQRSTUVWXYZ"; // sans O
const digits = "123456789"; // sans 0
const pool = letters + digits;
const pick = (s: string) => s[Math.floor(Math.random() * s.length)];
while (true) {
let ref = "";
for (let i = 0; i < 8; i++) ref += pick(pool);
if (ref.startsWith("RG")) continue; // ne pas commencer par RG
if (!/[A-Z]/.test(ref)) continue; // au moins une lettre
if (!/[1-9]/.test(ref)) continue; // au moins un chiffre
return ref;
}
}
function useDebouncedValue<T>(value: T, delay = 300) {
const [debounced, setDebounced] = React.useState(value);
React.useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
// --- API helpers (alignés avec NouveauCDDUForm.tsx) ------------------------
type SalarieOption = { matricule: string; nom: string; email?: string | null };
type SpectacleOption = { id?: string; nom: string; numero_objet?: string | null };
type ClientInfo = { id: string; name: string; api_name?: string } | null;
// Import de la fonction api depuis lib/fetcher
import { api } from '@/lib/fetcher';
import { PROFESSIONS_ARTISTE, type ProfessionOption } from '@/components/constants/ProfessionsArtiste';
// Hook pour récupérer les infos client
function useClientInfo() {
const [clientInfo, setClientInfo] = React.useState<ClientInfo>(null);
const [loaded, setLoaded] = React.useState(false);
React.useEffect(() => {
if (loaded) return;
console.log('🔍 [CLIENT INFO DEBUG] Loading client info...');
(async () => {
try {
const res = await fetch("/api/me", {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include"
});
console.log('🔍 [CLIENT INFO DEBUG] /api/me response status:', res.status);
if (res.ok) {
const me = await res.json();
console.log('🔍 [CLIENT INFO DEBUG] /api/me response data:', me);
const info = {
id: me.active_org_id || null,
name: me.active_org_name || "Organisation",
api_name: me.active_org_api_name
};
console.log('🔍 [CLIENT INFO DEBUG] Client info constructed:', info);
setClientInfo(info);
} else {
console.log('🔍 [CLIENT INFO DEBUG] /api/me response not ok');
}
} catch (e) {
console.warn('🔍 [CLIENT INFO DEBUG] Could not load client info:', e);
setClientInfo(null);
}
setLoaded(true);
})();
}, [loaded]);
return clientInfo;
}
// Recherche de productions (alignée avec useSearchSpectacles)
async function searchProductions(q: string, clientInfo: ClientInfo): Promise<SpectacleOption[]> {
if (!q || q.trim().length < 2 || !clientInfo) return [];
try {
const params = new URLSearchParams();
params.set("page", "1");
params.set("limit", "10");
params.set("q", q.trim());
const result = await api<{ items: SpectacleOption[] }>(`/spectacles?${params.toString()}`, {}, clientInfo);
return (result.items || []).filter(s => s.nom && s.nom.length > 0);
} catch (e) {
console.warn('Search productions failed:', e);
return [];
}
}
// Recherche de salariés (alignée avec useSearchSalaries) — renvoie des objets complets
async function searchSalaries(q: string, clientInfo: ClientInfo): Promise<SalarieOption[]> {
console.log('🔍 [SEARCH DEBUG] searchSalaries called with:', { q, clientInfo });
if (!q || q.trim().length < 2 || !clientInfo) {
console.log('🔍 [SEARCH DEBUG] Search conditions not met');
return [];
}
try {
const params = new URLSearchParams();
params.set("page", "1");
params.set("limit", "10");
params.set("q", q.trim());
const url = `/salaries?${params.toString()}`;
console.log('🔍 [SEARCH DEBUG] Search URL:', url);
const result = await api<{ items: SalarieOption[] }>(url, {}, clientInfo);
console.log('🔍 [SEARCH DEBUG] API result:', result);
const items = result.items || [];
console.log('🔍 [SEARCH DEBUG] Final items:', items);
return items;
} catch (e) {
console.warn('🔍 [SEARCH DEBUG] Search salaries failed:', e);
return [];
}
}
// Recherche locale dans la liste des professions ARTISTE
function searchProfessionsArtiste(q: string): Promise<string[]> {
if (!q || q.trim().length < 2) return Promise.resolve([]);
const s = q.trim().toLowerCase();
const items = PROFESSIONS_ARTISTE.filter((p: ProfessionOption) =>
p.label.toLowerCase().includes(s) || p.code.toLowerCase().includes(s)
)
.slice(0, 20)
.map((p) => `${p.label}${p.code}`);
return Promise.resolve(items);
}
// Recherche locale dans la liste des professions TECHNICIEN (chargée depuis /public)
let _TECH_LIST: ProfessionOption[] | null = null;
async function loadProfessionsTechnicien(): Promise<ProfessionOption[]> {
if (_TECH_LIST) return _TECH_LIST;
try {
const res = await fetch('/data/professions-techniciens.json', { cache: 'force-cache' });
if (!res.ok) throw new Error('Failed to load technicien professions');
const json = (await res.json()) as ProfessionOption[];
_TECH_LIST = json;
return json;
} catch (e) {
console.warn('Unable to load professions-techniciens.json', e);
_TECH_LIST = [];
return _TECH_LIST;
}
}
async function searchProfessionsTechnicien(q: string): Promise<string[]> {
if (!q || q.trim().length < 2) return [];
const list = await loadProfessionsTechnicien();
const s = q.trim().toLowerCase();
return list
.filter((p) => p.label.toLowerCase().includes(s) || p.code.toLowerCase().includes(s))
.slice(0, 20)
.map((p) => `${p.label}${p.code}`);
}
export type RoleType = "ARTISTE" | "TECHNICIEN";
export type SalaryType = "BRUT" | "NET_AVT_PAS" | "CTE" | "MINIMA";
export type ContractRow = {
id: string;
salarie: string;
salarieMatricule?: string; // Stocker le matricule séparément
role: RoleType;
profession: string;
dateDebut: string; // YYYY-MM-DD
dateFin: string; // YYYY-MM-DD
typeSalaire: SalaryType;
salaire: number | "";
nbreCachetsRepresentation: number | "";
nbreServiceRepet: number | "";
heuresSiTechnicien: number | "";
note: string;
selected?: boolean;
};
const uid = () => Math.random().toString(36).slice(2, 9);
const emptyRow = (defaults?: Partial<ContractRow>): ContractRow => {
const { id: _omitId, selected: _omitSel, ...rest } = defaults || {};
return {
salarie: "",
role: "ARTISTE",
profession: "",
dateDebut: "",
dateFin: "",
typeSalaire: "BRUT",
salaire: "",
nbreCachetsRepresentation: "",
nbreServiceRepet: "",
heuresSiTechnicien: "",
note: "",
// on positionne l'id et selected à la fin pour ne JAMAIS reprendre ceux d'une autre ligne
...rest,
id: uid(),
selected: false,
};
};
function validateRow(r: ContractRow) {
const errors: Record<string, string> = {};
if (!r.salarie) errors.salarie = "Requis";
if (!r.profession) errors.profession = "Requis";
if (!r.dateDebut) errors.dateDebut = "Requis";
if (!r.dateFin) errors.dateFin = "Requis";
if (r.dateDebut && r.dateFin && r.dateFin < r.dateDebut) errors.dateFin = "Fin < Début";
// Nouvelle règle : interdire les contrats multi-mois
if (r.dateDebut && r.dateFin) {
const m1 = r.dateDebut.slice(0, 7); // YYYY-MM
const m2 = r.dateFin.slice(0, 7);
if (m1 !== m2) {
errors.dateFin = "MULTI_MOIS"; // signal dédié pour l'UI
}
}
if (r.typeSalaire !== "MINIMA" && (r.salaire === "" || Number(r.salaire) <= 0)) errors.salaire = "> 0";
if (r.role === "ARTISTE") {
const hasCachets = r.nbreCachetsRepresentation !== "" && Number(r.nbreCachetsRepresentation) >= 0;
const hasServices = r.nbreServiceRepet !== "" && Number(r.nbreServiceRepet) >= 0;
if (!hasCachets && !hasServices) {
errors.nbreCachetsRepresentation = "Requis (cachets ou services)";
errors.nbreServiceRepet = "Requis (cachets ou services)";
} else {
if (r.nbreCachetsRepresentation !== "" && Number(r.nbreCachetsRepresentation) < 0) {
errors.nbreCachetsRepresentation = ">= 0";
}
if (r.nbreServiceRepet !== "" && Number(r.nbreServiceRepet) < 0) {
errors.nbreServiceRepet = ">= 0";
}
}
}
if (r.role === "TECHNICIEN") {
if (r.heuresSiTechnicien === "" || Number(r.heuresSiTechnicien) < 0) errors.heuresSiTechnicien = ">= 0";
}
return errors;
}
function parsePasted(text: string): string[][] {
return text.trim().split(/\r?\n/).map((line) => line.split(/\t|;/));
}
const PASTE_ORDER = [
"salarie",
"role",
"profession",
"dateDebut",
"dateFin",
"typeSalaire",
"salaire",
"nbreCachetsRepresentation",
"nbreServiceRepet",
"heuresSiTechnicien",
"note",
] as const;
// --- CSV utils ---------------------------------------------------------------
function detectDelimiter(text: string): string {
const comma = (text.match(/,/g) || []).length;
const semicolon = (text.match(/;/g) || []).length;
return semicolon > comma ? ';' : ',';
}
function parseCSV(text: string): string[][] {
const delim = detectDelimiter(text);
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
const next = text[i + 1];
if (c === '"') {
if (inQuotes && next === '"') { field += '"'; i++; continue; }
inQuotes = !inQuotes; continue;
}
if (!inQuotes && (c === '\n' || c === '\r')) {
if (c === '\r' && next === '\n') i++; // handle CRLF
row.push(field); field = '';
if (row.length > 1 || row[0] !== '') rows.push(row);
row = []; continue;
}
if (!inQuotes && c === delim) { row.push(field); field = ''; continue; }
field += c;
}
// flush last field/row
row.push(field);
if (row.length > 1 || row[0] !== '') rows.push(row);
return rows;
}
function normalizeDate(val: string): string {
if (!val) return "";
const s = String(val).trim();
// DD/MM/YYYY
const m1 = s.match(/^([0-3]?\d)\/([0-1]?\d)\/(\d{4})$/);
if (m1) {
const dd = m1[1].padStart(2, '0');
const mm = m1[2].padStart(2, '0');
const yyyy = m1[3];
return `${yyyy}-${mm}-${dd}`;
}
// YYYY-MM-DD (déjà OK)
const m2 = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m2) return s;
return s; // fallback: on laisse tel quel
}
function rowsFromMatrix(parsed: string[][]): ContractRow[] {
if (parsed.length === 0) return [];
return parsed.map((cols) => {
const base = emptyRow();
const assigned: Partial<ContractRow> = {};
PASTE_ORDER.forEach((key, i) => {
const val = cols[i];
if (val == null) return;
switch (key) {
case 'role':
assigned.role = /^tech/i.test(val) ? 'TECHNICIEN' : 'ARTISTE';
break;
case 'typeSalaire':
if (/^min/i.test(val)) assigned.typeSalaire = 'MINIMA';
else if (/^net/i.test(val)) assigned.typeSalaire = 'NET_AVT_PAS';
else if (/^cte/i.test(val)) assigned.typeSalaire = 'CTE';
else assigned.typeSalaire = 'BRUT';
break;
case 'dateDebut':
(assigned as any)[key] = normalizeDate(val);
break;
case 'dateFin':
(assigned as any)[key] = normalizeDate(val);
break;
case 'salaire':
case 'nbreCachetsRepresentation':
case 'nbreServiceRepet':
case 'heuresSiTechnicien':
(assigned as any)[key] = val === '' ? '' : Number(String(val).replace(',', '.'));
break;
default:
(assigned as any)[key] = val;
}
});
return { ...base, ...assigned } as ContractRow;
});
}
// Ordre de navigation des colonnes (pour Tab/Shift+Tab)
const COLUMN_ORDER = [
"salarie",
"role",
"profession",
"dateDebut",
"dateFin",
"typeSalaire",
"salaire",
"nbreCachetsRepresentation",
"nbreServiceRepet",
"heuresSiTechnicien",
"note",
] as const;
// --- Classes compactes réutilisables ---
const thCls = "text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground px-2 py-1";
const tdCls = "px-1.5 py-1 align-middle";
const inputCls = (error?: boolean) =>
`w-full rounded-md border px-2 py-1 h-8 text-[13px] ${error ? "border-red-500" : ""}`;
const selectCls = "w-full rounded-md border px-2 h-8 text-[13px] bg-white";
const numberCls = (error?: boolean, disabled?: boolean) =>
`w-full rounded-md border px-2 py-1 h-8 text-right text-[13px] ${error ? "border-red-500" : ""} ${disabled ? "bg-muted/40" : ""}`;
const headerPad = "px-3 pt-2";
const mainPad = "px-3 pb-3";
// --- Combobox compact avec API intégrée -------------------------------------
function ComboBox({
value,
onChange,
onSelect,
placeholder,
searchType,
className = "",
inputProps,
error,
}: {
value: string | SpectacleOption | null;
onChange: (v: string | SpectacleOption | null) => void;
onSelect: (v: string | SpectacleOption | null) => void;
placeholder?: string;
searchType: 'productions' | 'salaries' | 'professionsArtiste' | 'professionsTechnicien';
className?: string;
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
error?: boolean;
}) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [items, setItems] = React.useState<any[]>([]);
const [active, setActive] = React.useState(0);
const [loading, setLoading] = React.useState(false);
const debounced = useDebouncedValue(query, 300);
const clientInfo = useClientInfo();
const listRef = React.useRef<HTMLUListElement | null>(null);
const anchorRef = React.useRef<HTMLDivElement | null>(null);
const [popupPos, setPopupPos] = React.useState<{ top: number; left: number; width: number } | null>(null);
const updatePopupPos = React.useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setPopupPos({ top: r.bottom + window.scrollY, left: r.left + window.scrollX, width: r.width });
}, []);
React.useEffect(() => {
if (!open || !listRef.current) return;
const el = listRef.current.children[active] as HTMLElement | undefined;
if (el) {
el.scrollIntoView({ block: 'nearest' });
}
}, [active, open]);
React.useEffect(() => {
if (!debounced || debounced.length < 2) {
setItems([]);
return;
}
let alive = true;
setLoading(true);
(async () => {
try {
updatePopupPos();
if (searchType === 'productions') {
const res = await searchProductions(debounced, clientInfo);
if (alive) setItems(res);
} else if (searchType === 'salaries') {
const res = await searchSalaries(debounced, clientInfo); // objets complets
if (alive) setItems(res);
} else if (searchType === 'professionsArtiste') {
const res = await searchProfessionsArtiste(debounced);
if (alive) setItems(res);
} else {
const res = await searchProfessionsTechnicien(debounced);
if (alive) setItems(res);
}
} catch (e) {
console.warn('Search error:', e);
if (alive) setItems([]);
} finally {
if (alive) setLoading(false);
}
})();
return () => { alive = false; };
}, [debounced, searchType, clientInfo, updatePopupPos]);
React.useEffect(() => {
if (!open) return;
updatePopupPos();
const onScroll = () => updatePopupPos();
const onResize = () => updatePopupPos();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
}, [open, updatePopupPos]);
const commit = (v: any) => {
console.log('🔍 [COMBOBOX DEBUG] Commit called with:', v);
console.log('🔍 [COMBOBOX DEBUG] Search type:', searchType);
if (searchType === 'salaries' && v && typeof v === 'object') {
// Pour les salariés, on stocke "MATRICULE | Nom" pour pouvoir extraire le matricule plus tard
const matricule = v.matricule || '';
const nom = v.nom || '';
const finalValue = matricule && nom ? `${matricule} | ${nom}` : nom;
console.log('🔍 [COMBOBOX DEBUG] Salarie object:', v);
console.log('🔍 [COMBOBOX DEBUG] Matricule:', matricule);
console.log('🔍 [COMBOBOX DEBUG] Nom:', nom);
console.log('🔍 [COMBOBOX DEBUG] Final value:', finalValue);
onSelect(finalValue);
} else if (searchType === 'productions' && v && typeof v === 'object') {
// Pour les productions, on passe l'objet complet
console.log('🔍 [COMBOBOX DEBUG] Production object:', v);
onSelect(v);
} else if (typeof v === 'string') {
console.log('🔍 [COMBOBOX DEBUG] String value:', v);
onSelect(v);
} else {
console.log('🔍 [COMBOBOX DEBUG] Other value (converted to string):', v);
onSelect(String(v ?? ''));
}
setOpen(false);
};
// Convertir la valeur pour l'affichage dans l'input
const inputValue = React.useMemo(() => {
if (searchType === 'productions' && value && typeof value === 'object') {
const nom = value.nom || '';
const numero = value.numero_objet;
return numero ? `${nom}${numero}` : nom;
}
return typeof value === 'string' ? value : '';
}, [value, searchType]);
return (
<div ref={anchorRef} className={`relative ${className}`}>
<input
value={inputValue}
onChange={(e) => {
const newValue = e.target.value;
if (searchType === 'productions') {
// Pour les productions, on garde la valeur string temporairement pendant la frappe
onChange(newValue);
} else {
onChange(newValue);
}
setQuery(newValue);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
onKeyDown={(e) => {
if (!open) return;
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, Math.max(items.length - 1, 0))); }
if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
if (e.key === "Enter") { e.preventDefault(); commit(items[active] ?? value); }
if (e.key === "Escape") { setOpen(false); }
}}
placeholder={placeholder}
{...inputProps}
className={`w-full rounded-md border px-2 py-1 h-8 text-[13px] ${error ? 'border-red-500' : ''}`}
aria-invalid={error || undefined}
/>
{open && (loading || items.length > 0) && popupPos && createPortal(
(
<ul
ref={listRef}
className="z-[1000] max-h-64 overflow-auto rounded-lg border bg-white shadow-lg text-[13px]"
role="listbox"
style={{ position: 'fixed', top: popupPos.top, left: popupPos.left, width: popupPos.width }}
>
{loading ? (
<li className="px-3 py-2 text-gray-500">Recherche en cours</li>
) : items.length === 0 ? (
<li className="px-3 py-2 text-gray-500">Aucun résultat</li>
) : (
items.map((it: any, i: number) => {
const isActive = i === active;
if (searchType === 'salaries') {
const nom = it?.nom ?? '';
const matricule = it?.matricule ?? '';
const email = it?.email ?? '';
return (
<li
key={matricule || nom + i}
role="option"
aria-selected={isActive}
className={`px-3 py-2 cursor-pointer transition border-l-4 ${
isActive
? 'bg-emerald-50 border-emerald-500'
: 'hover:bg-slate-50 border-transparent'
}`}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => { e.preventDefault(); commit(it); }}
>
<div className="font-medium">{nom}</div>
<div className="text-xs text-slate-500">{matricule}{email ? `${email}` : ''}</div>
</li>
);
}
// productions et professions
return (
<li
key={typeof it === 'string' ? it : (it.id || it.nom || 'opt-' + i)}
role="option"
aria-selected={isActive}
className={`px-3 py-2 cursor-pointer transition border-l-4 ${
isActive
? 'bg-emerald-50 border-emerald-500'
: 'hover:bg-slate-50 border-transparent'
}`}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => { e.preventDefault(); commit(it); }}
>
{(() => {
if (searchType === 'productions' && typeof it === 'object' && it.nom) {
return (
<>
<div className="font-medium">{it.nom}</div>
{it.numero_objet ? (
<div className="text-xs text-slate-500">{it.numero_objet}</div>
) : null}
</>
);
}
// fallback pour professions et autres
const text = typeof it === 'string' ? it : String(it);
const [main, extra] = text.split(' — ');
return (
<>
<div className="font-medium">{main}</div>
{extra ? (
<div className="text-xs text-slate-500">{extra}</div>
) : null}
</>
);
})()}
</li>
);
})
)}
</ul>
),
document.body
)}
</div>
);
}
export default function Page() {
usePageTitle("Saisie en tableau - Nouveaux contrats");
// Staff-only guard for this page
const [authChecked, setAuthChecked] = React.useState(false);
const [isStaff, setIsStaff] = React.useState<boolean>(false);
// Tous les autres hooks DOIVENT être déclarés avant les conditions de retour
const [production, setProduction] = useState<SpectacleOption | null>(null);
const [rows, setRows] = useState<ContractRow[]>([emptyRow()]);
const pasteTargetRef = useRef<HTMLTextAreaElement | null>(null);
// State pour l'organisation sélectionnée (staff uniquement)
const [selectedOrg, setSelectedOrg] = useState<{id: string; name: string} | null>(null);
const [availableOrgs, setAvailableOrgs] = useState<{id: string; name: string; structure_api: string}[]>([]);
// Focus helpers for row/column navigation
const focusCell = useCallback((rowId: string, field: keyof ContractRow) => {
const el = document.querySelector<HTMLElement>(`[data-row="${rowId}"][data-field="${field}"]`);
el?.focus();
}, []);
// Tooltip custom pour le bouton Valider
const validateWrapRef = useRef<HTMLDivElement | null>(null);
const [validateTipOpen, setValidateTipOpen] = useState(false);
const [validateTipPos, setValidateTipPos] = useState<{ top: number; left: number; width: number } | null>(null);
const computeValidateTipPos = useCallback(() => {
const el = validateWrapRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setValidateTipPos({ top: r.top + window.scrollY, left: r.left + window.scrollX + r.width / 2, width: r.width });
}, []);
// Tooltip d'aide pour "Annexe" (icône ou select role)
const annexeIconRef = useRef<HTMLSpanElement | null>(null);
const annexeAnchorEl = useRef<HTMLElement | null>(null);
const [annexeTipOpen, setAnnexeTipOpen] = useState(false);
const [annexeTipPos, setAnnexeTipPos] = useState<{ top: number; left: number } | null>(null);
const computeAnnexeTipPos = useCallback(() => {
const el = annexeAnchorEl.current;
if (!el) return;
const r = el.getBoundingClientRect();
setAnnexeTipPos({ top: r.top + window.scrollY, left: r.left + window.scrollX + r.width / 2 });
}, []);
const openAnnexeTipFromEl = useCallback((el: HTMLElement) => {
annexeAnchorEl.current = el;
const r = el.getBoundingClientRect();
setAnnexeTipPos({ top: r.top + window.scrollY, left: r.left + window.scrollX + r.width / 2 });
setAnnexeTipOpen(true);
}, []);
// États pour les tooltips et la soumission
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitSuccess, setSubmitSuccess] = useState(false);
// Effect pour vérifier le statut staff
React.useEffect(() => {
let mounted = true;
(async () => {
try {
const res = await fetch('/api/me', { cache: 'no-store', credentials: 'include' });
if (res.ok) {
const me = await res.json();
if (mounted) setIsStaff(!!me?.is_staff);
}
} catch (e) {
// ignore, default is false
} finally {
if (mounted) setAuthChecked(true);
}
})();
return () => { mounted = false; };
}, []);
// Charger les organisations disponibles pour le staff
React.useEffect(() => {
if (!isStaff) return;
(async () => {
try {
const res = await fetch("/api/organizations", { credentials: "include", cache: "no-store" });
if (res.ok) {
const json: any = await res.json();
const items = json.items || [];
setAvailableOrgs(items);
// Auto-sélectionner l'organisation active si disponible
const meRes = await fetch("/api/me", { credentials: "include", cache: "no-store" });
if (meRes.ok) {
const meData = await meRes.json();
if (meData.active_org_id) {
const activeOrg = items.find((org: any) => org.id === meData.active_org_id);
if (activeOrg) {
setSelectedOrg({ id: activeOrg.id, name: activeOrg.name });
}
}
}
}
} catch (e) {
console.warn('Could not load organizations:', e);
}
})();
}, [isStaff]);
React.useEffect(() => {
if (!validateTipOpen) return;
const onScroll = () => computeValidateTipPos();
const onResize = () => computeValidateTipPos();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
}, [validateTipOpen, computeValidateTipPos]);
// Hooks pour CSV
const csvInputRef = useRef<HTMLInputElement | null>(null);
const openCSVDialog = useCallback(() => {
csvInputRef.current?.click();
}, []);
const onCSVFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const text = String(reader.result || '');
const matrix = parseCSV(text);
// On ignore la première ligne (headers)
const dataRows = matrix.slice(1);
const newRows = rowsFromMatrix(dataRows);
if (newRows.length) {
setRows((prev) => {
const isFirstRowEmpty = prev.length === 1 && !prev[0].salarie && !prev[0].profession && !prev[0].dateDebut && !prev[0].dateFin && prev[0].salaire === "" && prev[0].nbreCachetsRepresentation === "" && prev[0].nbreServiceRepet === "" && prev[0].heuresSiTechnicien === "" && !prev[0].note;
if (isFirstRowEmpty) {
// Remplacer la première ligne vide par la première ligne CSV, puis ajouter le reste
const [first, ...rest] = newRows;
return [first, ...rest];
}
return [...prev, ...newRows];
});
}
} catch (err) {
console.error('CSV parse error', err);
} finally {
e.target.value = '';
}
};
reader.readAsText(file);
}, []);
React.useEffect(() => {
if (!annexeTipOpen) return;
const onScroll = () => computeAnnexeTipPos();
const onResize = () => computeAnnexeTipPos();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
}, [annexeTipOpen, computeAnnexeTipPos]);
// ==============================================
// TOUS LES AUTRES HOOKS (AVANT LES CONDITIONS DE RETOUR)
// ==============================================
const downloadCSVTemplate = useCallback(() => {
const headers = Array.from(PASTE_ORDER).join(',');
const blob = new Blob([headers + '\n'], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'modele_saisie_cddu.csv';
a.click();
URL.revokeObjectURL(url);
}, []);
// Re-render tick for floating elements (tooltips) on scroll/resize
const [scrollTick, setScrollTick] = useState(0);
React.useEffect(() => {
const onMove = () => setScrollTick((t) => t + 1);
window.addEventListener('scroll', onMove, true);
window.addEventListener('resize', onMove);
return () => {
window.removeEventListener('scroll', onMove, true);
window.removeEventListener('resize', onMove);
};
}, []);
const endDateRefs = useRef<Record<string, HTMLInputElement | null>>({});
// --- Raccourcis clavier & ligne active ------------------------------------
const [activeRowId, setActiveRowId] = useState<string | null>(null);
// --- Éditeur de note latéral ------------------------------------------------
const [noteEditorOpen, setNoteEditorOpen] = useState(false);
const [noteEditorRowId, setNoteEditorRowId] = useState<string | null>(null);
const [noteDraft, setNoteDraft] = useState("");
const [noteAnchor, setNoteAnchor] = useState<{ top: number; left: number; width: number; height: number } | null>(null);
const [panelPos, setPanelPos] = useState<{ top: number; left: number; width: number } | null>(null);
const PANEL_W = 360; // largeur du panneau de note
const openNote = useCallback((rowId: string, current: string, anchorEl: HTMLElement) => {
const r = anchorEl.getBoundingClientRect();
setNoteAnchor({ top: r.top + window.scrollY, left: r.left + window.scrollX, width: r.width, height: r.height });
setPanelPos({ top: r.top + window.scrollY, left: Math.max(16, window.scrollX + window.innerWidth - PANEL_W - 16), width: PANEL_W });
setNoteDraft(current || "");
setNoteEditorRowId(rowId);
setNoteEditorOpen(true);
}, []);
const closeNote = useCallback(() => {
setNoteEditorOpen(false);
setNoteEditorRowId(null);
setNoteDraft("");
setNoteAnchor(null);
setPanelPos(null);
}, []);
// Maintenir la position du panneau et de la flèche au scroll/resize
React.useEffect(() => {
if (!noteEditorOpen || !noteAnchor) return;
const onScroll = () => {
setPanelPos((prev) => ({ top: noteAnchor.top - window.scrollY + window.scrollY, left: Math.max(16, window.scrollX + window.innerWidth - PANEL_W - 16), width: PANEL_W }));
};
const onResize = () => {
setPanelPos({ top: noteAnchor.top, left: Math.max(16, window.scrollX + window.innerWidth - PANEL_W - 16), width: PANEL_W });
};
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
};
}, [noteEditorOpen, noteAnchor]);
const moveFocusVertical = useCallback((delta: -1 | 1) => {
const activeEl = document.activeElement as HTMLElement | null;
const field = (activeEl?.dataset?.field as keyof ContractRow | undefined) || undefined;
if (!field) return;
const idx = rows.findIndex(r => r.id === activeRowId);
if (idx === -1) return;
// Wrap intelligent (option 1) : si on est sur la dernière ligne et qu'on descend, créer une nouvelle ligne
if (delta === 1 && idx === rows.length - 1) {
const newRow = emptyRow();
setRows(prev => [...prev, newRow]);
setActiveRowId(newRow.id);
setTimeout(() => focusCell(newRow.id, field), 0);
return;
}
const targetIdx = Math.max(0, Math.min(rows.length - 1, idx + delta));
const target = rows[targetIdx];
if (target) {
setActiveRowId(target.id);
setTimeout(() => focusCell(target.id, field), 0);
}
}, [rows, activeRowId, focusCell]);
const getRowIndexById = useCallback((id: string) => rows.findIndex(r => r.id === id), [rows]);
const duplicateRowById = useCallback((id: string) => {
setRows(prev => {
const idx = prev.findIndex(r => r.id === id);
if (idx === -1) return prev;
const { id: _omit, selected: _s, ...rest } = prev[idx] as any;
const dup = emptyRow(rest);
const next = [...prev.slice(0, idx + 1), dup, ...prev.slice(idx + 1)];
return next;
});
}, []);
const addEmptyRowAfterId = useCallback((id: string | null) => {
setRows(prev => {
const idx = id ? prev.findIndex(r => r.id === id) : prev.length - 1;
const insertAt = idx >= 0 ? idx + 1 : prev.length;
const next = [...prev.slice(0, insertAt), emptyRow(), ...prev.slice(insertAt)];
return next;
});
}, []);
const deleteRowById = useCallback((id: string) => {
setRows(prev => {
const idx = prev.findIndex(r => r.id === id);
if (idx === -1) return prev;
const next = prev.filter(r => r.id !== id);
// repositionner la ligne active sur la suivante si possible
const nextIdx = Math.min(idx, next.length - 1);
setActiveRowId(next[nextIdx]?.id ?? null);
return next.length ? next : [emptyRow()];
});
}, []);
// Raccourcis globaux
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
const isTyping = tag === 'input' || tag === 'select' || tag === 'textarea';
if (!(e.ctrlKey && e.shiftKey) || e.metaKey) return;
if (e.key.toLowerCase() === 'd') {
e.preventDefault();
if (activeRowId) duplicateRowById(activeRowId);
return;
}
if (e.key.toLowerCase() === 'n') {
e.preventDefault();
addEmptyRowAfterId(activeRowId);
return;
}
if (e.key.toLowerCase() === 'x') {
e.preventDefault();
if (activeRowId) deleteRowById(activeRowId);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
moveFocusVertical(-1);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
moveFocusVertical(1);
return;
}
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [activeRowId, duplicateRowById, addEmptyRowAfterId, deleteRowById, moveFocusVertical]);
// Validation globale
const anyErrors = useMemo(() => rows.some(r => Object.keys(validateRow(r)).length > 0), [rows]);
const hasAnyData = useMemo(() => rows.some(r => (
!!r.salarie || !!r.profession || !!r.dateDebut || !!r.dateFin || r.salaire !== "" || r.nbreCachetsRepresentation !== "" || r.nbreServiceRepet !== "" || r.heuresSiTechnicien !== "" || !!r.note
)), [rows]);
const canSubmit = hasAnyData && !anyErrors;
// handleSubmit comme useCallback
const handleSubmit = useCallback(async () => {
if (!canSubmit) return;
// Validation pour staff : une organisation doit être sélectionnée
if (isStaff && !selectedOrg) {
setSubmitError("Veuillez sélectionner une organisation");
return;
}
setSubmitting(true);
setSubmitError(null);
try {
const validRows = rows.filter(r =>
r.salarie && r.profession && r.dateDebut && r.dateFin &&
(r.salaire !== "" || r.typeSalaire === "MINIMA")
);
if (validRows.length === 0) {
throw new Error("Aucun contrat valide à soumettre");
}
// Créer chaque contrat individuellement
const results = [];
for (const row of validRows) {
// Extraire le matricule depuis le format "MATRICULE | Nom" ou utiliser le nom complet
const salarieData = row.salarie.includes(' | ')
? row.salarie.split(' | ')
: [row.salarie, row.salarie];
const [matriculeRaw = "", nomRaw = ""] = salarieData;
const salarieMatricule = matriculeRaw.trim();
const salarieNomComplet = (nomRaw || matriculeRaw).trim();
console.log('🔍 [FRONTEND DEBUG] Row salarie:', row.salarie);
console.log('🔍 [FRONTEND DEBUG] SalarieData split:', salarieData);
console.log('🔍 [FRONTEND DEBUG] Matricule raw:', matriculeRaw);
console.log('🔍 [FRONTEND DEBUG] Nom raw:', nomRaw);
console.log('🔍 [FRONTEND DEBUG] Matricule final:', salarieMatricule);
console.log('🔍 [FRONTEND DEBUG] Nom final:', salarieNomComplet);
console.log('🔍 [FRONTEND DEBUG] Selected org:', selectedOrg);
console.log('🔍 [FRONTEND DEBUG] Is staff:', isStaff);
const contractData = {
salarie_matricule: salarieMatricule || salarieNomComplet,
salarie_nom: salarieNomComplet,
salarie_email: null,
send_email_confirmation: false,
regime: "CDDU_MONO",
production_id: production?.id || null,
spectacle: production?.nom || "Production",
numero_objet: production?.numero_objet || null,
profession_label: row.profession.split(' — ')[0] || row.profession,
profession_code: row.profession.split(' — ')[1] || null,
categorie: row.role === "ARTISTE" ? "Artiste" : "Technicien",
date_debut: row.dateDebut,
date_fin: row.dateFin,
nb_representations: row.role === "ARTISTE" ? (row.nbreCachetsRepresentation || 0) : 0,
nb_services_repetition: row.role === "ARTISTE" ? (row.nbreServiceRepet || 0) : 0,
heures_total: row.role === "TECHNICIEN" ? (row.heuresSiTechnicien || 0) : 0,
minutes_total: 0,
type_salaire: (() => {
switch (row.typeSalaire) {
case "NET_AVT_PAS": return "Net avant PAS";
case "CTE": return "Coût total employeur";
case "MINIMA": return "Minimum conventionnel";
default: return "Brut";
}
})(),
montant: row.typeSalaire !== "MINIMA" ? Number(row.salaire) : undefined,
panier_repas: "Non",
reference: generateReference(),
notes: row.note || undefined,
org_id: isStaff ? selectedOrg?.id : null,
};
console.log('🔍 [FRONTEND DEBUG] Contract data à envoyer:', contractData);
const response = await fetch("/api/cddu-contracts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(contractData)
});
console.log('🔍 [FRONTEND DEBUG] Response status:', response.status);
console.log('🔍 [FRONTEND DEBUG] Response ok:', response.ok);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.log('🔍 [FRONTEND DEBUG] Error data:', errorData);
throw new Error(errorData.error || `Erreur lors de la création du contrat pour ${row.salarie}`);
}
const result = await response.json();
console.log('🔍 [FRONTEND DEBUG] Success result:', result);
results.push(result);
}
setSubmitSuccess(true);
setTimeout(() => {
window.location.href = "/contrats";
}, 2000);
} catch (error: any) {
setSubmitError(error.message || "Erreur lors de la soumission");
} finally {
setSubmitting(false);
}
}, [canSubmit, isStaff, selectedOrg, rows, production]);
const totals = useMemo(() => {
const totalSalaire = rows.reduce((acc, r) => acc + (Number(r.salaire) || 0), 0);
const totalCachets = rows.reduce((acc, r) => acc + (r.role === "ARTISTE" ? Number(r.nbreCachetsRepresentation) || 0 : 0), 0);
const totalServices = rows.reduce((acc, r) => acc + (r.role === "ARTISTE" ? Number(r.nbreServiceRepet) || 0 : 0), 0);
const totalHeures = rows.reduce((acc, r) => acc + (r.role === "TECHNICIEN" ? Number(r.heuresSiTechnicien) || 0 : 0), 0);
return { totalSalaire, totalCachets, totalServices, totalHeures };
}, [rows]);
// Tous les autres useCallback DOIVENT être ici AVANT les conditions de retour
const addRow = useCallback((preset?: Partial<ContractRow>) => {
setRows((prev) => [...prev, emptyRow(preset)]);
}, []);
const duplicateSelected = useCallback(() => {
setRows((prev) => {
const dups: ContractRow[] = [];
prev.forEach((r) => {
if (r.selected) {
const { id: _omit, selected: _s, ...rest } = r as any;
dups.push(emptyRow(rest));
}
});
if (dups.length === 0) return prev;
return [...prev, ...dups];
});
}, []);
const deleteSelected = useCallback(() => {
setRows((prev) => prev.filter((r) => !r.selected));
}, []);
const clearAll = useCallback(() => {
setRows([emptyRow()]);
}, []);
const handlePasteBulk = useCallback(() => {
pasteTargetRef.current?.focus();
pasteTargetRef.current?.select();
}, []);
const onPaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const text = e.clipboardData.getData("text");
if (!text) return;
e.preventDefault();
const parsed = parsePasted(text);
if (parsed.length === 0) return;
const newRows: ContractRow[] = parsed.map((cols) => {
const base = emptyRow();
const assigned: Partial<ContractRow> = {};
PASTE_ORDER.forEach((key, i) => {
const val = cols[i];
if (val == null) return;
switch (key) {
case "role":
assigned.role = /^tech/i.test(val) ? "TECHNICIEN" : "ARTISTE";
break;
case "typeSalaire":
if (/^min/i.test(val)) assigned.typeSalaire = "MINIMA";
else if (/^net/i.test(val)) assigned.typeSalaire = "NET_AVT_PAS";
else if (/^cte/i.test(val)) assigned.typeSalaire = "CTE";
else assigned.typeSalaire = "BRUT";
break;
case "dateDebut":
(assigned as any)[key] = normalizeDate(val);
break;
case "dateFin":
(assigned as any)[key] = normalizeDate(val);
break;
case "salaire":
case "nbreCachetsRepresentation":
case "nbreServiceRepet":
case "heuresSiTechnicien":
(assigned as any)[key] = val === "" ? "" : Number(val.replace(",", "."));
break;
default:
(assigned as any)[key] = val;
}
});
return { ...base, ...assigned } as ContractRow;
});
setRows((prev) => [...prev, ...newRows]);
}, []);
const updateCell = useCallback((id: string, field: keyof ContractRow, value: any) => {
setRows((prev) =>
prev.map((r) =>
r.id === id
? (() => {
const next: ContractRow = { ...r, [field]: value } as any;
if (field === "role") {
if (value === "ARTISTE") next.heuresSiTechnicien = "";
if (value === "TECHNICIEN") {
next.nbreCachetsRepresentation = "";
next.nbreServiceRepet = "";
}
}
if (field === "typeSalaire") {
if (value === "MINIMA") next.salaire = "";
}
return next;
})()
: r
)
);
}, []);
const saveNote = useCallback(() => {
if (!noteEditorRowId) return;
updateCell(noteEditorRowId, 'note', noteDraft);
closeNote();
}, [noteEditorRowId, noteDraft, updateCell, closeNote]);
const exportJSON = useCallback(() => {
const payload = { production, contrats: rows };
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `saisie_cddu_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}, [production, rows]);
// Conditions de retour APRÈS tous les hooks
if (!authChecked) {
return <div className="p-6 text-sm text-slate-500">Chargement</div>;
}
if (!isStaff) {
return (
<div className="p-6">
<AccessDeniedCard
title="Accès réservé au staff"
message="Cette page n'est pas encore disponible pour les clients."
hint="Cette fonction sera bientôt disponible."
/>
</div>
);
}
// Fonctions d'aide (pas des hooks)
const onKeyDownCell = (
e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
row: ContractRow,
field: keyof ContractRow
) => {
// Sauter les segments internes du <input type="date"> :
if ((field === 'dateDebut' || field === 'dateFin') && e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
const idx = COLUMN_ORDER.indexOf(field as any);
const nextIdx = e.shiftKey ? Math.max(0, idx - 1) : Math.min(COLUMN_ORDER.length - 1, idx + 1);
const nextField = COLUMN_ORDER[nextIdx] as keyof ContractRow;
setTimeout(() => focusCell(row.id, nextField), 0);
return;
}
};
return (
<div className="space-y-3">
<header className={`flex items-center justify-between ${headerPad}`}>
<div className="space-y-1">
<h1 className="text-[18px] font-semibold tracking-tight">Saisie CDDU en tableau</h1>
<p className="text-[12px] text-muted-foreground">
</p>
</div>
<div className="flex gap-1.5" ref={validateWrapRef}
onMouseEnter={() => { if (!canSubmit) { setValidateTipOpen(true); computeValidateTipPos(); } }}
onMouseLeave={() => setValidateTipOpen(false)}
>
<button
className="inline-flex items-center gap-2 rounded-lg px-4 py-2 bg-emerald-600 text-white shadow hover:bg-emerald-700 transition text-[14px] h-10 text-base font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
disabled={!canSubmit || submitting}
onClick={handleSubmit}
>
{submitting ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
Création en cours...
</>
) : (
"Valider la saisie"
)}
</button>
</div>
{/* Messages d'erreur et succès */}
{submitError && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{submitError}
</div>
)}
{submitSuccess && (
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
Contrats créés avec succès ! Redirection vers la liste des contrats...
</div>
)}
{validateTipOpen && !canSubmit && validateTipPos && createPortal(
(() => {
const top = validateTipPos.top - 10; // au-dessus
const left = validateTipPos.left;
return (
<div className="z-[1200]" style={{ position: 'fixed', top, left, transform: 'translate(-50%, -100%)' }}>
<div className="inline-block max-w-[300px] rounded-md border border-amber-400 bg-amber-50 text-amber-900 text-[12px] px-2 py-1 shadow">
Veuillez d'abord corriger les erreurs.
</div>
<div className="mx-auto w-0 h-0" style={{ borderLeft: '8px solid transparent', borderRight: '8px solid transparent', borderTop: '8px solid #f59e0b' }} />
</div>
);
})(),
document.body
)}
</header>
{annexeTipOpen && annexeTipPos && createPortal(
(() => {
const top = annexeTipPos.top - 10;
const left = annexeTipPos.left;
return (
<div className="z-[1200]" style={{ position: 'fixed', top, left, transform: 'translate(-50%, -100%)' }}>
<div className="inline-block rounded-md border border-slate-300 bg-white text-slate-800 text-[12px] px-2 py-1 shadow">
<div>Annexe 8 = Technicien</div>
<div>Annexe 10 = Artiste</div>
</div>
<div className="mx-auto w-0 h-0" style={{ borderLeft: '8px solid transparent', borderRight: '8px solid transparent', borderTop: '8px solid #e5e7eb' }} />
</div>
);
})(),
document.body
)}
<section className={`grid grid-cols-1 md:grid-cols-3 gap-2 items-end ${headerPad}`}>
{isStaff && (
<div className="col-span-full mb-3">
<label className="text-[12px] font-medium text-amber-700">
Organisation (obligatoire pour les utilisateurs staff)
</label>
<select
value={selectedOrg?.id || ""}
onChange={(e) => {
const org = availableOrgs.find(o => o.id === e.target.value);
setSelectedOrg(org ? { id: org.id, name: org.name } : null);
}}
className="mt-1 w-full rounded-md border px-3 py-2 text-sm bg-white"
>
<option value="" disabled>Sélectionner une organisation…</option>
{availableOrgs.map(org => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
{isStaff && !selectedOrg && (
<p className="text-xs text-amber-600 mt-1">
Une organisation doit être sélectionnée avant de pouvoir valider la saisie.
</p>
)}
</div>
)}
<div className="col-span-2">
<label className="text-[12px] font-medium">Production (commune à toutes les lignes)</label>
<ComboBox
value={production}
onChange={(v) => {
if (typeof v === 'string') {
// Quand l'utilisateur tape, on crée un objet temporaire avec le nom
setProduction({ nom: v, numero_objet: null });
} else {
setProduction(v);
}
}}
onSelect={(v) => {
if (typeof v === 'object' && v !== null) {
setProduction(v);
} else if (typeof v === 'string' && v.trim()) {
// Si l'utilisateur tape une nouvelle production, on crée un objet temporaire
setProduction({ nom: v.trim(), numero_objet: null });
} else {
setProduction(null);
}
}}
placeholder="Rechercher ou saisir une production…"
searchType="productions"
className="mt-1"
/>
</div>
<div className="flex gap-1.5">
<button onClick={() => addRow()} className="rounded-lg px-3 py-1 border shadow-sm hover:shadow transition text-[13px] h-8">
+ Ligne
</button>
<button onClick={duplicateSelected} className="rounded-lg px-3 py-1 border shadow-sm hover:shadow transition text-[13px] h-8">
Dupliquer
</button>
<button onClick={deleteSelected} className="rounded-lg px-3 py-1 border shadow-sm hover:shadow transition text-[13px] h-8">
Supprimer
</button>
</div>
</section>
<section className={`flex flex-wrap items-center gap-2 text-[12px] ${headerPad}`}>
<button onClick={openCSVDialog} className="rounded-lg px-2.5 py-1 border shadow-sm hover:shadow transition h-8">
Import CSV
</button>
<button onClick={downloadCSVTemplate} className="rounded-lg px-2.5 py-1 border shadow-sm hover:shadow transition h-8">
Télécharger le modèle CSV
</button>
<span className="text-muted-foreground">
Vous pouvez exporter un CSV depuis Excel/Sheets et l'importer ici.
</span>
<input ref={csvInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={onCSVFileChange} />
</section>
<div className={`overflow-auto rounded-xl border ${mainPad}`}>
<table className="min-w-[1000px] w-full table-fixed text-[13px]">
<thead className="bg-muted/50">
<tr>
<Th w="20px">S</Th>
<Th w="160px">Salarie</Th>
<Th w="75px">
<span className="inline-flex items-center gap-1">
Annexe
<span
ref={annexeIconRef}
className="ml-1 inline-flex items-center justify-center w-4 h-4 rounded-full border text-[10px] text-slate-600 bg-white cursor-help"
onMouseEnter={(e) => { if (e.currentTarget) openAnnexeTipFromEl(e.currentTarget); }}
onMouseLeave={() => setAnnexeTipOpen(false)}
aria-label="Aide sur l'annexe"
>
?
</span>
</span>
</Th>
<Th w="160px">Profession</Th>
<Th w="120px">Début</Th>
<Th w="120px">Fin</Th>
<Th w="120px">Type salaire</Th>
<Th w="80px">Salaire (€)</Th>
<Th w="64px">Cachets repr.</Th>
<Th w="64px">Services rép.</Th>
<Th w="64px">Heures (tech.)</Th>
<Th>Note</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const errors = validateRow(row);
scrollTick; // re-render anchor positions on scroll/resize
return (
<tr
key={row.id}
onFocusCapture={() => setActiveRowId(row.id)}
className={`relative transition-colors odd:bg-background even:bg-muted/20 ${row.id === activeRowId ? 'bg-emerald-50/30 ring-1 ring-emerald-400/40' : ''}`}
>
<Td>
<div className="relative">
{row.id === activeRowId && (
<span className="absolute left-[-6px] top-[-6px] bottom-[-6px] w-[3px] rounded-full bg-emerald-500" aria-hidden />
)}
<input
type="checkbox"
checked={!!row.selected}
onChange={(e) => updateCell(row.id, "selected" as any, e.target.checked)}
className="mx-auto h-4 w-4"
/>
</div>
</Td>
<Td>
<ComboBox
value={row.salarie.includes(' | ') ? row.salarie.split(' | ')[1] : row.salarie}
onChange={(v) => updateCell(row.id, "salarie", v)}
onSelect={(v) => updateCell(row.id, "salarie", v)}
placeholder="Rechercher un salarié"
searchType="salaries"
inputProps={{ 'data-row': row.id, 'data-field': 'salarie' as any } as any}
error={!!errors.salarie}
/>
</Td>
<Td>
<select
value={row.role}
onKeyDown={(e) => onKeyDownCell(e, row, "role")}
onChange={(e) => updateCell(row.id, "role", e.target.value as RoleType)}
className={selectCls}
data-row={row.id}
data-field="role"
onFocus={(e) => openAnnexeTipFromEl(e.currentTarget)}
onMouseEnter={(e) => openAnnexeTipFromEl(e.currentTarget)}
onBlur={() => setAnnexeTipOpen(false)}
onMouseLeave={() => setAnnexeTipOpen(false)}
>
<option value="ARTISTE">A10</option>
<option value="TECHNICIEN">A8</option>
</select>
</Td>
<Td>
{row.role === 'ARTISTE' ? (
<ComboBox
value={row.profession}
onChange={(v) => updateCell(row.id, 'profession', typeof v === 'string' ? v : String(v || ''))}
onSelect={(v) => {
const strValue = typeof v === 'string' ? v : String(v || '');
const label = strValue.includes(' — ') ? strValue.split(' — ')[0] : strValue;
updateCell(row.id, 'profession', label);
}}
placeholder="Rechercher une profession (Artiste)"
searchType="professionsArtiste"
inputProps={{ 'data-row': row.id, 'data-field': 'profession' as any } as any}
error={!!errors.profession}
/>
) : (
<ComboBox
value={row.profession}
onChange={(v) => updateCell(row.id, 'profession', typeof v === 'string' ? v : String(v || ''))}
onSelect={(v) => {
const strValue = typeof v === 'string' ? v : String(v || '');
const label = strValue.includes(' — ') ? strValue.split(' — ')[0] : strValue;
updateCell(row.id, 'profession', label);
}}
placeholder="Rechercher une profession (Technicien)"
searchType="professionsTechnicien"
inputProps={{ 'data-row': row.id, 'data-field': 'profession' as any } as any}
error={!!errors.profession}
/>
)}
</Td>
<Td>
<input
type="date"
value={row.dateDebut}
onKeyDown={(e) => onKeyDownCell(e, row, "dateDebut")}
onChange={(e) => updateCell(row.id, "dateDebut", e.target.value)}
className={inputCls(!!errors.dateDebut)}
data-row={row.id}
data-field="dateDebut"
/>
</Td>
<Td>
<div className="relative">
<input
ref={(el) => { endDateRefs.current[row.id] = el; }}
type="date"
value={row.dateFin}
onKeyDown={(e) => onKeyDownCell(e, row, "dateFin")}
onChange={(e) => updateCell(row.id, "dateFin", e.target.value)}
className={inputCls(!!errors.dateFin)}
data-row={row.id}
data-field="dateFin"
/>
{errors.dateFin === 'MULTI_MOIS' && endDateRefs.current[row.id] && createPortal(
(() => {
const r = endDateRefs.current[row.id]!.getBoundingClientRect();
const top = r.top + window.scrollY - 8; // place au-dessus
const left = r.left + window.scrollX;
const width = Math.min(320, Math.max(220, r.width + 40));
return (
<div className="z-[1200]" style={{ position: 'fixed', top, left, width, transform: 'translateY(-100%)' }}>
<div className="inline-block w-full rounded-md border border-amber-400 bg-amber-50 text-amber-900 text-[12px] px-2 py-1 shadow">
La création d'un contrat multi-mois n'est pour l'instant pas possible en saisie tableau, merci d'utiliser le formulaire unique.
</div>
<div className="ml-4 w-0 h-0 border-l-8 border-l-transparent border-r-8 border-r-transparent" style={{ borderTopWidth: 8, borderTopColor: '#f59e0b' }} />
</div>
);
})(),
document.body
)}
</div>
</Td>
<Td>
<select
value={row.typeSalaire}
onKeyDown={(e) => onKeyDownCell(e, row, "typeSalaire")}
onChange={(e) => updateCell(row.id, "typeSalaire", e.target.value as SalaryType)}
className={selectCls}
data-row={row.id}
data-field="typeSalaire"
>
<option value="BRUT">Brut</option>
<option value="NET_AVT_PAS">Net avt PAS</option>
<option value="CTE">CTE</option>
<option value="MINIMA">Minima</option>
</select>
</Td>
<Td>
<input
type="number"
step="0.01"
placeholder="0.00"
value={row.salaire}
onKeyDown={(e) => onKeyDownCell(e, row, "salaire")}
onChange={(e) => updateCell(row.id, "salaire", e.target.value === "" ? "" : Number(e.target.value))}
className={numberCls(!!errors.salaire, row.typeSalaire === "MINIMA")}
disabled={row.typeSalaire === "MINIMA"}
data-row={row.id}
data-field="salaire"
/>
</Td>
<Td>
<input
type="number"
min={0}
max={99}
placeholder="0"
value={row.nbreCachetsRepresentation}
inputMode="numeric"
onKeyDown={(e) => onKeyDownCell(e, row, "nbreCachetsRepresentation")}
onChange={(e) => {
const v = e.target.value;
if (v === "") return updateCell(row.id, "nbreCachetsRepresentation", "");
const n = Math.min(99, Math.max(0, Number(v)));
updateCell(row.id, "nbreCachetsRepresentation", n);
}}
disabled={row.role !== "ARTISTE"}
className={numberCls(!!errors.nbreCachetsRepresentation, row.role !== "ARTISTE") + " text-center"}
data-row={row.id}
data-field="nbreCachetsRepresentation"
/>
</Td>
<Td>
<input
type="number"
min={0}
max={99}
placeholder="0"
value={row.nbreServiceRepet}
inputMode="numeric"
onKeyDown={(e) => onKeyDownCell(e, row, "nbreServiceRepet")}
onChange={(e) => {
const v = e.target.value;
if (v === "") return updateCell(row.id, "nbreServiceRepet", "");
const n = Math.min(99, Math.max(0, Number(v)));
updateCell(row.id, "nbreServiceRepet", n);
}}
disabled={row.role !== "ARTISTE"}
className={numberCls(!!errors.nbreServiceRepet, row.role !== "ARTISTE") + " text-center"}
data-row={row.id}
data-field="nbreServiceRepet"
/>
</Td>
<Td>
<input
type="number"
min={0}
max={99}
placeholder="0"
value={row.heuresSiTechnicien}
inputMode="numeric"
onKeyDown={(e) => onKeyDownCell(e, row, "heuresSiTechnicien")}
onChange={(e) => {
const v = e.target.value;
if (v === "") return updateCell(row.id, "heuresSiTechnicien", "");
const n = Math.min(99, Math.max(0, Number(v)));
updateCell(row.id, "heuresSiTechnicien", n);
}}
disabled={row.role !== "TECHNICIEN"}
className={numberCls(!!errors.heuresSiTechnicien, row.role !== "TECHNICIEN") + " text-center"}
data-row={row.id}
data-field="heuresSiTechnicien"
/>
</Td>
<Td>
<button
onClick={(e) => openNote(row.id, row.note, e.currentTarget as HTMLElement)}
className="inline-flex items-center gap-2 px-2 py-1 h-8 rounded-md border shadow-sm hover:shadow transition text-[13px] bg-white"
data-row={row.id}
data-field="note"
>
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full border bg-white">
{row.note ? <span className="text-[11px] font-medium">1</span> : <span aria-hidden>📝</span>}
</span>
</button>
</Td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="bg-muted/30">
<Td colSpan={7}>
<div className="text-[12px] text-muted-foreground">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<span>
Raccourcis ligne : <kbd className="px-1 border rounded">Ctrl</kbd> + <kbd className="px-1 border rounded">Shift</kbd> + <kbd className="px-1 border rounded">D</kbd> = Dupliquer
</span>
<span>
<kbd className="px-1 border rounded">Ctrl</kbd> + <kbd className="px-1 border rounded">Shift</kbd> + <kbd className="px-1 border rounded">N</kbd> = Nouvelle ligne
</span>
<span>
<kbd className="px-1 border rounded">Ctrl</kbd> + <kbd className="px-1 border rounded">Shift</kbd> + <kbd className="px-1 border rounded">X</kbd> = Supprimer
</span>
<span>
<kbd className="px-1 border rounded">Ctrl</kbd> + <kbd className="px-1 border rounded">Shift</kbd> + <kbd className="px-1 border rounded">↑/↓</kbd> = Déplacer le focus ligne (même colonne)
</span>
</div>
</div>
</Td>
<Td className="text-right font-medium">{totals.totalSalaire.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</Td>
<Td className="text-right font-medium">{totals.totalCachets}</Td>
<Td className="text-right font-medium">{totals.totalServices}</Td>
<Td className="text-right font-medium">{totals.totalHeures}</Td>
<Td>{/* empty cell */}</Td>
</tr>
</tfoot>
</table>
</div>
<div className={`flex items-center justify-between text-[12px] text-muted-foreground ${mainPad}`}>
<div>
Lignes : <span className="font-medium text-foreground">{rows.length}</span>
</div>
<div className="flex gap-1.5">
<button
onClick={() => addRow(rows[rows.length - 1])}
className="rounded-lg px-2.5 py-1 border shadow-sm hover:shadow transition text-[13px] h-8"
>
+ Copier la dernière
</button>
<button onClick={() => addRow()} className="rounded-lg px-2.5 py-1 border shadow-sm hover:shadow transition text-[13px] h-8">
+ Ligne vide
</button>
</div>
</div>
{noteEditorOpen && panelPos && createPortal(
<div className="z-[1100]" style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}>
{/* Flèche (ligne + pointe) */}
{noteAnchor ? (
<svg width="100%" height="100%" style={{ position: 'absolute', top: 0, left: 0 }}>
{(() => {
const startX = noteAnchor.left + noteAnchor.width + 6;
const startY = noteAnchor.top + noteAnchor.height / 2;
const endX = panelPos.left;
const endY = panelPos.top + 16; // pointe vers le header du panneau
return (
<g stroke="#10b981" strokeWidth="2" fill="none">
<path d={`M ${startX} ${startY} C ${startX + 40} ${startY}, ${endX - 40} ${endY}, ${endX} ${endY}`} />
<polygon points={`${endX},${endY} ${endX - 8},${endY - 6} ${endX - 8},${endY + 6}`} fill="#10b981" />
</g>
);
})()}
</svg>
) : null}
{/* Panneau latéral */}
<div
className="rounded-l-xl border border-slate-200 bg-white shadow-xl"
style={{ position: 'fixed', top: panelPos.top, left: panelPos.left, width: panelPos.width, pointerEvents: 'auto' }}
tabIndex={-1}
onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); closeNote(); } }}
>
<div className="flex items-center justify-between px-3 py-2 border-b bg-emerald-50">
<div className="text-sm font-medium">Note de la ligne</div>
<button onClick={closeNote} className="text-sm text-slate-600 hover:underline">Fermer</button>
</div>
<div className="p-3">
<p className="text-xs text-slate-500 mb-2">Ajoutez une note pour ce contrat, si vous souhaitez nous apporter toute précision que vous jugez utile.</p>
<textarea
value={noteDraft}
onChange={(e) => setNoteDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); saveNote(); }
if (e.key === 'Escape') { e.preventDefault(); closeNote(); }
}}
className="w-full h-40 text-[13px] border rounded-md p-2"
placeholder="Votre note"
autoFocus
/>
<div className="mt-3 flex items-center justify-between gap-2">
<div className="text-[11px] text-slate-500">Astuce : <kbd className="px-1 border rounded">Shift</kbd> + <kbd className="px-1 border rounded">Entrée</kbd> pour valider · <kbd className="px-1 border rounded">Esc</kbd> pour fermer et annuler</div>
<div className="flex gap-2">
<button onClick={closeNote} className="px-3 py-1 h-8 rounded-md border">Annuler</button>
<button onClick={saveNote} className="px-3 py-1 h-8 rounded-md bg-emerald-600 text-white hover:bg-emerald-700">Valider</button>
</div>
</div>
</div>
</div>
</div>,
document.body
)}
</div>
);
}
function Th({ children, w }: { children: React.ReactNode; w?: string }) {
return (
<th style={{ width: w }} className={thCls}>
{children}
</th>
);
}
function Td({
children,
colSpan,
className = "",
}: {
children?: React.ReactNode;
colSpan?: number;
className?: string;
}) {
return (
<td colSpan={colSpan} className={`${tdCls} ${className}`}>
{children}
</td>
);
}