1806 lines
No EOL
72 KiB
TypeScript
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>
|
|
);
|
|
} |