/** * scaffold-ui-primitives/generate.ts — Build the editable UI primitives * (EntityLookup, DateInput, EnumSelect, MultiSelect, SegmentedControl, Textarea, * Switch, TruncatedText, DataTable, ResponsiveDataTable, useColumnVisibility, * ColumnPicker, FilterBar, TabStrip) + i18n locale stubs. * * Pure function — no I/O. Caller in index.ts writes the returned files honoring * each file's `strategy` (overwrite vs deep-merge-json for locales). * * Theme-compliance contract: the form primitives MUST use only SmartStack tokens * (var(--bg-card), var(--color-accent-*), …) — zero hardcoded Tailwind colors * (bg-blue-500, text-gray-700, dark:bg-…). The companion test asserts this * with a regex sweep so a future hand-edit can't silently regress it. * TruncatedText / ResponsiveDataTable are pure layout/logic (no colors) — they * add the responsive-column + truncation-tooltip behavior on top of the local * , which is OWNED here too (the customisation-ui baseline table: * sorting / pagination / search / selection, styled via the --table-* tokens * scaffold-theme emits). Without it, ResponsiveDataTable + every *ListPage would * import a missing @/components/ui/DataTable. */ import type { GeneratedFile, ScaffoldUiPrimitivesInput } from './types.js'; import { meetsFloor, MODULE_AVAILABILITY_MIN_VERSION } from './version.js'; const HEADER = `/* AUTO-GENERATED — re-run scaffold-ui-primitives to refresh. Add @customised at the top to opt out. */`; const LOCALES = ['fr', 'en', 'it', 'de'] as const; // Namespaced i18n strings (locale → namespace → key). Each primitive reads its // own namespace via useTranslation('common'); localeFile() writes the whole // per-locale tree into common.json (deep-merged, sibling namespaces preserved). const I18N_STRINGS: Record>> = { fr: { entityLookup: { placeholder: 'Rechercher…', loading: 'Chargement…', empty: 'Aucun résultat', error: 'Erreur de chargement', clear: 'Effacer la sélection', search: 'Rechercher', }, dateInput: { placeholder: 'Choisir une date', clear: 'Effacer la date', previousMonth: 'Mois précédent', nextMonth: 'Mois suivant', monthLabel: 'Mois', yearLabel: 'Année', chipToday: "Aujourd'hui", chipTomorrow: 'Demain', chipNextWeek: '+1 sem.', chipNextMonth: '+1 mois', }, enumSelect: { placeholder: 'Sélectionner…', clear: 'Effacer', empty: 'Aucune option', }, multiSelect: { placeholder: 'Sélectionner…', empty: 'Aucune option', remove: 'Retirer', }, rowActions: { more: "Plus d'actions", }, headerActions: { more: "Plus d'actions", }, tabStrip: { scrollLeft: 'Défiler les onglets vers la gauche', scrollRight: 'Défiler les onglets vers la droite', }, smartCode: { label: 'Code', manualBadge: 'Manuel', backToAuto: 'Revenir à la suggestion', regenerate: 'Proposer un autre code', moreSuggestions: 'Autres propositions', suggestions: 'Propositions de code', proposeOthers: "Proposer d'autres", missingFields: "Renseignez d'abord : {{fields}}", loadFailed: "Suggestions indisponibles — le moteur allouera à l'enregistrement.", autoInfo: "Attribué automatiquement à l'enregistrement", autoExample: 'ex. {{code}}', enterManually: 'Saisir un code (reprise/import)', backToAutoInfo: "Revenir à l'attribution automatique", }, }, en: { entityLookup: { placeholder: 'Search…', loading: 'Loading…', empty: 'No results', error: 'Failed to load', clear: 'Clear selection', search: 'Search', }, dateInput: { placeholder: 'Select a date', clear: 'Clear date', previousMonth: 'Previous month', nextMonth: 'Next month', monthLabel: 'Month', yearLabel: 'Year', chipToday: 'Today', chipTomorrow: 'Tomorrow', chipNextWeek: '+1 week', chipNextMonth: '+1 month', }, enumSelect: { placeholder: 'Select…', clear: 'Clear', empty: 'No options', }, multiSelect: { placeholder: 'Select…', empty: 'No options', remove: 'Remove', }, rowActions: { more: 'More actions', }, headerActions: { more: 'More actions', }, tabStrip: { scrollLeft: 'Scroll tabs left', scrollRight: 'Scroll tabs right', }, smartCode: { label: 'Code', manualBadge: 'Manual', backToAuto: 'Back to suggestion', regenerate: 'Suggest another code', moreSuggestions: 'More proposals', suggestions: 'Code proposals', proposeOthers: 'Propose others', missingFields: 'Fill in first: {{fields}}', loadFailed: 'Suggestions unavailable — the engine still allocates at save.', autoInfo: 'Assigned automatically at save', autoExample: 'e.g. {{code}}', enterManually: 'Enter a code (import/backfill)', backToAutoInfo: 'Back to automatic assignment', }, }, it: { entityLookup: { placeholder: 'Cerca…', loading: 'Caricamento…', empty: 'Nessun risultato', error: 'Errore di caricamento', clear: 'Cancella selezione', search: 'Cerca', }, dateInput: { placeholder: 'Scegli una data', clear: 'Cancella data', previousMonth: 'Mese precedente', nextMonth: 'Mese successivo', monthLabel: 'Mese', yearLabel: 'Anno', chipToday: 'Oggi', chipTomorrow: 'Domani', chipNextWeek: '+1 sett.', chipNextMonth: '+1 mese', }, enumSelect: { placeholder: 'Seleziona…', clear: 'Cancella', empty: 'Nessuna opzione', }, multiSelect: { placeholder: 'Seleziona…', empty: 'Nessuna opzione', remove: 'Rimuovi', }, rowActions: { more: 'Altre azioni', }, headerActions: { more: 'Altre azioni', }, tabStrip: { scrollLeft: 'Scorri le schede a sinistra', scrollRight: 'Scorri le schede a destra', }, smartCode: { label: 'Codice', manualBadge: 'Manuale', backToAuto: 'Torna al suggerimento', regenerate: 'Proponi un altro codice', moreSuggestions: 'Altre proposte', suggestions: 'Proposte di codice', proposeOthers: 'Proponi altri', missingFields: 'Compila prima: {{fields}}', loadFailed: 'Suggerimenti non disponibili — il motore assegna al salvataggio.', autoInfo: 'Assegnato automaticamente al salvataggio', autoExample: 'es. {{code}}', enterManually: 'Inserire un codice (import/ripresa)', backToAutoInfo: "Torna all'assegnazione automatica", }, }, de: { entityLookup: { placeholder: 'Suchen…', loading: 'Wird geladen…', empty: 'Keine Ergebnisse', error: 'Ladefehler', clear: 'Auswahl löschen', search: 'Suchen', }, dateInput: { placeholder: 'Datum wählen', clear: 'Datum löschen', previousMonth: 'Vorheriger Monat', nextMonth: 'Nächster Monat', monthLabel: 'Monat', yearLabel: 'Jahr', chipToday: 'Heute', chipTomorrow: 'Morgen', chipNextWeek: '+1 Woche', chipNextMonth: '+1 Monat', }, enumSelect: { placeholder: 'Auswählen…', clear: 'Löschen', empty: 'Keine Optionen', }, multiSelect: { placeholder: 'Auswählen…', empty: 'Keine Optionen', remove: 'Entfernen', }, rowActions: { more: 'Weitere Aktionen', }, headerActions: { more: 'Weitere Aktionen', }, tabStrip: { scrollLeft: 'Tabs nach links scrollen', scrollRight: 'Tabs nach rechts scrollen', }, smartCode: { label: 'Code', manualBadge: 'Manuell', backToAuto: 'Zurück zum Vorschlag', regenerate: 'Anderen Code vorschlagen', moreSuggestions: 'Weitere Vorschläge', suggestions: 'Code-Vorschläge', proposeOthers: 'Weitere vorschlagen', missingFields: 'Zuerst ausfüllen: {{fields}}', loadFailed: 'Vorschläge nicht verfügbar — die Engine vergibt beim Speichern.', autoInfo: 'Wird beim Speichern automatisch vergeben', autoExample: 'z. B. {{code}}', enterManually: 'Code manuell erfassen (Import/Übernahme)', backToAutoInfo: 'Zurück zur automatischen Vergabe', }, }, }; function entityLookupComponent(): string { return `${HEADER} import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { api } from '@atlashub/smartstack'; import { ChevronDown, X, Loader2 } from 'lucide-react'; /** * Paginated reference shape returned by GET /api/{module}/{plural}/lookup. * Matches \`{Entity}RefDto\` emitted by scaffold-business. */ interface RefItem { id: string; displayName: string; [k: string]: unknown; } interface PaginatedResult { items: T[]; // Matches SmartStack.app PaginatedResult.TotalCount on the wire (JSON: "totalCount"). totalCount: number; page: number; pageSize: number; } export interface EntityLookupProps { /** Endpoint hitting the lookup contract, e.g. \`/api/hr/departments/lookup\`. */ apiEndpoint: string; /** Currently selected entity id (the FK Guid to persist). \`null\` = empty. */ value: string | null; /** Receives the selected id (or null on clear) and, when available, the * full item so the caller can store a denormalised label without a refetch. */ onChange: (id: string | null, item?: TItem) => void; /** Visible field label rendered above the input. */ label: string; /** Override for the search input placeholder. Defaults to * \`t('entityLookup.placeholder')\`. */ placeholder?: string; /** Optional remap when the backend returns more than \`{ id, displayName }\`. * Defaults to \`(it) => ({ id: it.id, label: it.displayName })\`. */ mapOption?: (item: TItem) => { id: string; label: string; sublabel?: string }; /** Optional adapter for a NON-standard endpoint that does not return the * \`{ items: [...] }\` lookup contract (e.g. a Core "references" endpoint that * returns a combined DTO). Given the raw response, return the item list. When * set, the component fetches the full set once and filters/pages it CLIENT-side * (the endpoint serves no \`search\`/\`page\` params). Leave unset for the * standard paginated lookup. */ selectItems?: (raw: unknown) => TItem[]; required?: boolean; disabled?: boolean; /** Error string rendered under the input — typically a form validation * message from react-hook-form / zod. */ error?: string; /** Fallback label shown for the current value while/if the lookup endpoint * can't resolve it (row soft-deleted, slow network, or the caller already * holds a denormalised name). Prevents the raw Guid from ever flashing in * the field. When omitted, an unresolved value shows a neutral ellipsis — * the raw id is NEVER user-facing. */ valueLabel?: string; /** Multi-select — not implemented yet; reserved so the API doesn't break * callers when the feature lands. Throws at runtime if set to true. */ multi?: boolean; } const PAGE_SIZE = 20; const DEBOUNCE_MS = 300; export function EntityLookup({ apiEndpoint, value, onChange, label, placeholder, mapOption, selectItems, required = false, disabled = false, error, valueLabel, multi = false, }: EntityLookupProps) { if (multi) { throw new Error('EntityLookup: multi-select is not implemented yet (PR3 scope).'); } const { t } = useTranslation('common'); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [open, setOpen] = useState(false); const [focusedIdx, setFocusedIdx] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); const [data, setData] = useState | undefined>(); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); const [selectedItem, setSelectedItem] = useState(null); // Debounce the search input so we don't fire a request per keystroke. useEffect(() => { const t = setTimeout(() => setDebouncedSearch(search), DEBOUNCE_MS); return () => clearTimeout(t); }, [search]); // Fetch lookup results when dropdown is open. const fetchResults = useCallback(async (signal: AbortSignal) => { setIsLoading(true); setIsError(false); try { if (selectItems) { // Non-standard endpoint (combined references DTO): fetch the full set once, // extract the items via the adapter, then filter + page on the client. const raw = await api.get(apiEndpoint, { signal }); const all = selectItems(raw); const q = debouncedSearch.trim().toLowerCase(); const filtered = q ? all.filter((it) => String(it.displayName ?? '').toLowerCase().includes(q)) : all; setData({ items: filtered.slice(0, PAGE_SIZE), totalCount: filtered.length, page: 1, pageSize: PAGE_SIZE }); } else { const result = await api.get>(apiEndpoint, { params: { search: debouncedSearch || undefined, page: 1, pageSize: PAGE_SIZE }, signal, }); setData(result); } } catch (err) { if (!(err instanceof DOMException && err.name === 'AbortError')) setIsError(true); } finally { setIsLoading(false); } }, [apiEndpoint, debouncedSearch, selectItems]); useEffect(() => { if (!open) return; const ac = new AbortController(); void fetchResults(ac.signal); return () => ac.abort(); }, [open, fetchResults]); // Resolve the currently selected item label so the input shows a human // name instead of the raw Guid. Skipped when pick() already stored the // full item — no refetch, no label flash. useEffect(() => { if (!value) { setSelectedItem(null); return; } if (selectedItem && selectedItem.id === value) return; let cancelled = false; api.get>(apiEndpoint, { params: { search: value, pageSize: 1 } }) .then((result) => { if (!cancelled) setSelectedItem(result.items.find((it) => it.id === value) ?? null); }) .catch(() => { if (!cancelled) setSelectedItem(null); }); return () => { cancelled = true; }; }, [apiEndpoint, value, selectedItem]); const options = useMemo(() => { const items = data?.items ?? []; return items.map((it) => { const mapped = mapOption ? mapOption(it) : { id: it.id, label: it.displayName }; return { ...mapped, raw: it }; }); }, [data, mapOption]); const selectedLabel = useMemo(() => { if (!value) return ''; // Unresolved (still fetching, or the ref is gone): show the caller's // denormalised label or a neutral ellipsis — NEVER the raw Guid. if (!selectedItem) return valueLabel ?? '…'; return mapOption ? mapOption(selectedItem).label : selectedItem.displayName; }, [value, selectedItem, mapOption, valueLabel]); function pick(idx: number): void { const opt = options[idx]; if (!opt) return; setSelectedItem(opt.raw); onChange(opt.id, opt.raw); setOpen(false); setSearch(''); inputRef.current?.blur(); } function onKeyDown(e: KeyboardEvent): void { if (e.key === 'ArrowDown') { e.preventDefault(); if (!open) setOpen(true); setFocusedIdx((i) => Math.min(i + 1, options.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setFocusedIdx((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); pick(focusedIdx); } else if (e.key === 'Escape') { e.preventDefault(); setOpen(false); inputRef.current?.blur(); } } const inputId = useMemo(() => \`entity-lookup-\${Math.random().toString(36).slice(2, 9)}\`, []); const listId = \`\${inputId}-list\`; return (
{ setSearch(e.target.value); setFocusedIdx(0); if (!open) setOpen(true); }} onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 150)} onKeyDown={onKeyDown} placeholder={placeholder ?? t('entityLookup.placeholder', { defaultValue: 'Search…' })} disabled={disabled} className="w-full pl-3 pr-16 py-2 text-sm rounded-[var(--radius-input,0.375rem)] border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)] focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed" />
{isLoading && open && ( )} {value && !disabled && ( )}
{open && (
    {isError && (
  • {t('entityLookup.error', { defaultValue: 'Failed to load' })}
  • )} {!isError && isLoading && options.length === 0 && (
  • {t('entityLookup.loading', { defaultValue: 'Loading…' })}
  • )} {!isError && !isLoading && options.length === 0 && (
  • {t('entityLookup.empty', { defaultValue: 'No results' })}
  • )} {options.map((opt, idx) => (
  • setFocusedIdx(idx)} onMouseDown={(e) => { e.preventDefault(); pick(idx); }} className={\`px-3 py-2 text-sm cursor-pointer flex flex-col text-[var(--text-primary)] \${ idx === focusedIdx ? 'bg-[var(--bg-hover)]' : '' } \${value === opt.id ? 'font-medium' : ''}\`} > {opt.label} {opt.sublabel && ( {opt.sublabel} )}
  • ))}
)}
{error && (

{error}

)}
); } `; } /** * DateInput — theme-compliant date picker (NOT the native ``, * whose calendar is browser chrome and ignores the theme). Value contract: ISO * `YYYY-MM-DD` string or null. Three zero-/one-click paths to a date: * - TYPE it (dd/mm/yyyy, tolerant of -/. or no separator) — 0 clicks; * - click a quick CHIP (Today / Tomorrow / +1 week / +1 month) — 1 click; * - click a day in the calendar — 1 click. * `inline` renders the calendar in-flow (forms: always visible, no popover to * open). The default popover stays compact for filter bars. No leading icon, so * the typed text starts at the same x as every other field on the form. */ function dateInputComponent(): string { return `${HEADER} import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Calendar, ChevronLeft, ChevronRight, X } from 'lucide-react'; export interface DateInputProps { /** Selected date as an ISO \`YYYY-MM-DD\` string, or null when empty. */ value: string | null; /** Receives the ISO date string (or null on clear). */ onChange: (value: string | null) => void; /** Visible label above the field. Omit to render the control alone (e.g. in a filter bar). */ label?: string; placeholder?: string; required?: boolean; disabled?: boolean; /** Validation message rendered under the field. */ error?: string; /** Render the calendar in-flow (always visible) with the quick chips instead * of a popover. Forms use \`inline\` so picking a day is one click and zero * popover; filter bars keep the default compact popover. */ inline?: boolean; /** Lower bound (INCLUSIVE): an ISO \`YYYY-MM-DD\` string or the literal * \`'today'\` (e.g. a deadline can't be in the past). Omit for no lower bound. * Bounds the year dropdown, greys out earlier day cells, and rejects earlier * typed dates / quick chips. */ minDate?: string; /** Upper bound (INCLUSIVE): an ISO \`YYYY-MM-DD\` string or the literal * \`'today'\` (e.g. a birth date can't be in the future). Omit for no upper bound. */ maxDate?: string; } function pad2(n: number): string { return n < 10 ? '0' + n : String(n); } function toIso(d: Date): string { return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); } function parseIso(s: string | null): Date | null { if (!s) return null; const m = /^(\\d{4})-(\\d{2})-(\\d{2})/.exec(s); if (!m) return null; const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); return isNaN(d.getTime()) ? null : d; } /** dd/mm/yyyy — the editable text representation the user types and reads. */ function formatTyped(d: Date): string { return pad2(d.getDate()) + '/' + pad2(d.getMonth() + 1) + '/' + d.getFullYear(); } /** Tolerant parse of a typed date: accepts / - . space or no separator and a * 2- or 4-digit year. Rejects impossible dates (e.g. 31/02) by round-tripping * the constructed Date against the typed components. */ function parseTyped(s: string): Date | null { const m = /^\\s*(\\d{1,2})[\\s/.\\-]?(\\d{1,2})[\\s/.\\-]?(\\d{2,4})\\s*$/.exec(s); if (!m) return null; const day = Number(m[1]); const month = Number(m[2]); let year = Number(m[3]); if (year < 100) year += 2000; const d = new Date(year, month - 1, day); if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return null; return d; } /** Resolve a min/max bound prop: an ISO date, the literal 'today', or null. */ function resolveBound(b: string | null | undefined): Date | null { if (!b) return null; if (b === 'today') return new Date(); return parseIso(b); } /** Pull a date into the inclusive [lo, hi] window (day granularity via ISO compare). */ function clampToBounds(d: Date, lo: Date | null, hi: Date | null): Date { if (lo && toIso(d) < toIso(lo)) return lo; if (hi && toIso(d) > toIso(hi)) return hi; return d; } export function DateInput({ value, onChange, label, placeholder, required = false, disabled = false, error, inline = false, minDate, maxDate }: DateInputProps) { const { t, i18n } = useTranslation('common'); const [open, setOpen] = useState(false); const [text, setText] = useState(() => { const d = parseIso(value); return d ? formatTyped(d) : ''; }); const [view, setView] = useState(() => clampToBounds(parseIso(value) ?? new Date(), resolveBound(minDate), resolveBound(maxDate))); const rootRef = useRef(null); const minD = useMemo(() => resolveBound(minDate), [minDate]); const maxD = useMemo(() => resolveBound(maxDate), [maxDate]); const outOfRange = (d: Date) => (minD !== null && toIso(d) < toIso(minD)) || (maxD !== null && toIso(d) > toIso(maxD)); // Re-sync the visible text + calendar view when \`value\` changes from outside // (form load / reset). A half-typed date never reaches here — \`value\` only // changes once a full date parses — so it can't clobber mid-typing. useEffect(() => { const d = parseIso(value); setText(d ? formatTyped(d) : ''); if (d) setView(d); }, [value]); useEffect(() => { if (!open || inline) return; function onDoc(e: MouseEvent) { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); } document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [open, inline]); const dayFmt = useMemo(() => new Intl.DateTimeFormat(i18n.language, { year: 'numeric', month: 'long', day: 'numeric' }), [i18n.language]); const monthName = useMemo(() => new Intl.DateTimeFormat(i18n.language, { month: 'long' }), [i18n.language]); const months = useMemo(() => Array.from({ length: 12 }, (_, m) => monthName.format(new Date(2021, m, 1))), [monthName]); // Year list for the dropdown: bounded by min/max (so a past-only field lists no // future year and vice-versa), always stretched to include the viewed year so a // historic/far-future value is never absent from the list. Recent years first. const years = useMemo(() => { const now = new Date().getFullYear(); const vy = view.getFullYear(); let lo = minD !== null ? minD.getFullYear() : now - 120; let hi = maxD !== null ? maxD.getFullYear() : now + 30; lo = Math.min(lo, vy); hi = Math.max(hi, vy); const out: number[] = []; for (let y = hi; y >= lo; y--) out.push(y); return out; }, [view, minD, maxD]); const weekdayFmt = useMemo(() => new Intl.DateTimeFormat(i18n.language, { weekday: 'short' }), [i18n.language]); const weekdays = useMemo(() => { // Monday-first weekday labels (Mon Nov 1 2021 is a Monday). const base = new Date(2021, 10, 1); return Array.from({ length: 7 }, (_, i) => weekdayFmt.format(new Date(base.getFullYear(), base.getMonth(), base.getDate() + i))); }, [weekdayFmt]); const cells = useMemo(() => { const year = view.getFullYear(); const month = view.getMonth(); const startOffset = (new Date(year, month, 1).getDay() + 6) % 7; const daysInMonth = new Date(year, month + 1, 0).getDate(); const out: (Date | null)[] = []; for (let i = 0; i < startOffset; i++) out.push(null); for (let d = 1; d <= daysInMonth; d++) out.push(new Date(year, month, d)); while (out.length % 7 !== 0) out.push(null); return out; }, [view]); const todayIso = toIso(new Date()); function commit(d: Date) { if (outOfRange(d)) return; onChange(toIso(d)); setView(d); if (!inline) setOpen(false); } function shiftFromToday(days: number, months: number): Date { const n = new Date(); return new Date(n.getFullYear(), n.getMonth() + months, n.getDate() + days); } function onTextChange(raw: string) { setText(raw); if (raw.trim() === '') { onChange(null); return; } const d = parseTyped(raw); if (d && !outOfRange(d)) { onChange(toIso(d)); setView(d); } } function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') { setOpen(false); return; } if (e.key === 'Enter') { const d = parseTyped(text); if (d) { e.preventDefault(); commit(d); } } } const inputId = useMemo(() => 'date-input-' + Math.random().toString(36).slice(2, 9), []); const chipDefs = [ { key: 'chipToday', d: shiftFromToday(0, 0), fb: 'Today' }, { key: 'chipTomorrow', d: shiftFromToday(1, 0), fb: 'Tomorrow' }, { key: 'chipNextWeek', d: shiftFromToday(7, 0), fb: '+1 week' }, { key: 'chipNextMonth', d: shiftFromToday(0, 1), fb: '+1 month' }, ].filter((c) => !outOfRange(c.d)); const chips = chipDefs.length === 0 ? null : (
{chipDefs.map((c) => ( ))}
); const calendar = (
{weekdays.map((w, i) => ( {w} ))}
{cells.map((d, i) => { if (!d) return ; const iso = toIso(d); const isSelected = value === iso; const isToday = iso === todayIso; const isDisabled = outOfRange(d); const cls = 'h-8 w-8 mx-auto flex items-center justify-center text-sm rounded-[var(--radius-input,0.375rem)] ' + (isDisabled ? 'text-[var(--text-muted)] opacity-40 cursor-not-allowed' : isSelected ? 'bg-[var(--color-accent-500)] text-[var(--text-inverse)] font-medium' : 'text-[var(--text-primary)] hover:bg-[var(--bg-hover)]') + (isToday && !isSelected && !isDisabled ? ' ring-1 ring-[var(--color-accent-500)]' : ''); return ( ); })}
); const panel = (
{chips} {calendar}
); return (
{label && ( )}
onTextChange(e.target.value)} onFocus={() => { if (!inline) setOpen(true); }} onKeyDown={onKeyDown} className="w-full pl-3 pr-16 py-2 text-sm rounded-[var(--radius-input,0.375rem)] border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)] focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed" />
{value && !disabled && ( )} {!inline && ( )}
{!inline && open && (
{panel}
)}
{inline && (
{panel}
)} {error &&

{error}

}
); } `; } /** * EnumSelect — theme-compliant single-select dropdown for enum / fixed-list * fields (replaces the native `` * fallback). Value contract: the selected option value string, or null. */ function enumSelectComponent(): string { return `${HEADER} import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Check, ChevronDown, X } from 'lucide-react'; export interface SelectOption { value: string; label: string; } export interface EnumSelectProps { options: SelectOption[]; /** Selected option value, or null when empty. */ value: string | null; onChange: (value: string | null) => void; /** Visible label above the field. Omit to render the control alone. */ label?: string; placeholder?: string; required?: boolean; disabled?: boolean; /** Show the inline clear (X) button. Default true. */ clearable?: boolean; error?: string; } export function EnumSelect({ options, value, onChange, label, placeholder, required = false, disabled = false, clearable = true, error }: EnumSelectProps) { const { t } = useTranslation('common'); const [open, setOpen] = useState(false); const [focusedIdx, setFocusedIdx] = useState(0); const rootRef = useRef(null); const selectedOption = useMemo(() => options.find((o) => o.value === value) ?? null, [options, value]); useEffect(() => { if (!open) return; function onDoc(e: MouseEvent) { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); } document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [open]); function pick(idx: number) { const opt = options[idx]; if (!opt) return; onChange(opt.value); setOpen(false); } function onKeyDown(e: KeyboardEvent) { if (disabled) return; if (e.key === 'ArrowDown') { e.preventDefault(); if (!open) setOpen(true); setFocusedIdx((i) => Math.min(i + 1, options.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setFocusedIdx((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (open) pick(focusedIdx); else setOpen(true); } else if (e.key === 'Escape') { setOpen(false); } } const inputId = useMemo(() => 'enum-select-' + Math.random().toString(36).slice(2, 9), []); const listId = inputId + '-list'; return (
{label && ( )}
{clearable && value && !disabled && ( )} {open && (
    {options.length === 0 && (
  • {t('enumSelect.empty', { defaultValue: 'No options' })}
  • )} {options.map((opt, idx) => { const isSel = opt.value === value; const cls = 'px-3 py-2 text-sm cursor-pointer flex items-center justify-between text-[var(--text-primary)] ' + (idx === focusedIdx ? 'bg-[var(--bg-hover)]' : '') + (isSel ? ' font-medium' : ''); return (
  • setFocusedIdx(idx)} onMouseDown={(e) => { e.preventDefault(); pick(idx); }} className={cls}> {opt.label} {isSel && }
  • ); })}
)}
{error &&

{error}

}
); } `; } /** * RowActionsMenu — the overflow "…" menu for table row actions. Collapses every * custom row action behind a single MoreHorizontal trigger so the actions column * never grows an unbounded strip of ambiguous icon-only buttons (the fix for the * bare-chevron "actions that aren't actions"). Each item carries its own label * TEXT + optional icon, and is permission-gated through useAuth() — the whole * trigger disappears when the user may see none of the items. Reuses the same * floating mechanics as EnumSelect (open state, outside-click close, keyboard nav). */ function rowActionsMenuComponent(): string { return `${HEADER} import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { MoreHorizontal } from 'lucide-react'; import { useAuth } from '@/business/auth/useAuth'; export interface RowActionItem { /** Stable key — usually the action code. */ key: string; /** Visible menu label (already localised by the caller). */ label: string; /** Optional leading icon node, e.g. \`\`. */ icon?: ReactNode; /** Destructive styling (uses the --error-* tokens). */ danger?: boolean; /** Permission path gating this item — hidden when the user lacks it. */ permission?: string; /** Greys the item out and blocks the click. */ disabled?: boolean; /** Fired on select. The menu stops the row-click propagation + closes for you, * so pass a plain \`() => …\`. */ onClick: () => void; } export interface RowActionsMenuProps { items: RowActionItem[]; /** Trigger aria-label / tooltip. Defaults to the localised "More actions". */ ariaLabel?: string; } const MENU_MAX_H = 260; /** * The dropdown is PORTALED to document.body with fixed positioning: the row sits * inside the DataTable's \`overflow-x-auto\` wrapper (which computes overflow-y:auto), * so an absolutely-positioned menu would be clipped on the last rows. Fixed coords * anchored to the trigger's getBoundingClientRect escape every overflow ancestor. */ export function RowActionsMenu({ items, ariaLabel }: RowActionsMenuProps) { const { t } = useTranslation('common'); const { hasPermission } = useAuth(); const [open, setOpen] = useState(false); const [focusedIdx, setFocusedIdx] = useState(0); const [pos, setPos] = useState({}); const triggerRef = useRef(null); const menuRef = useRef(null); // Only the items the current user may actually see. Computed every render so a // late permission bootstrap (useAuth is async) reveals items without a remount. const visible = items.filter((it) => !it.permission || hasPermission(it.permission)); function reposition() { const el = triggerRef.current; if (!el) return; const r = el.getBoundingClientRect(); // Right-align the menu to the trigger; drop up when there isn't room below. const right = Math.max(8, window.innerWidth - r.right); const dropUp = r.bottom + MENU_MAX_H > window.innerHeight && r.top > MENU_MAX_H; setPos(dropUp ? { position: 'fixed', bottom: window.innerHeight - r.top + 4, right } : { position: 'fixed', top: r.bottom + 4, right }); } useEffect(() => { if (!open) return; reposition(); function onDoc(e: MouseEvent) { const target = e.target as Node; if (triggerRef.current?.contains(target)) return; if (menuRef.current?.contains(target)) return; setOpen(false); } // A scroll/resize invalidates the fixed coords — close rather than chase them. function onDismiss() { setOpen(false); } document.addEventListener('mousedown', onDoc); window.addEventListener('scroll', onDismiss, true); window.addEventListener('resize', onDismiss); return () => { document.removeEventListener('mousedown', onDoc); window.removeEventListener('scroll', onDismiss, true); window.removeEventListener('resize', onDismiss); }; }, [open]); function choose(idx: number) { const it = visible[idx]; if (!it || it.disabled) return; it.onClick(); setOpen(false); } function onKeyDown(e: KeyboardEvent) { if (e.key === 'ArrowDown') { e.preventDefault(); if (!open) setOpen(true); setFocusedIdx((i) => Math.min(i + 1, visible.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setFocusedIdx((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (open) choose(focusedIdx); else setOpen(true); } else if (e.key === 'Escape') { setOpen(false); } } // Nothing the user may see → render no trigger at all (no empty "…" button). if (visible.length === 0) return null; const label = ariaLabel ?? t('rowActions.more', { defaultValue: 'More actions' }); return (
{open && createPortal(
    e.stopPropagation()} className="z-50 min-w-[12rem] max-h-[260px] overflow-y-auto py-1 rounded-[var(--radius-input,0.375rem)] border border-[var(--border-color)] bg-[var(--bg-card)] shadow-[var(--shadow-overlay)]" > {visible.map((it, idx) => (
  • ))}
, document.body, )}
); } `; } /** * HeaderActionsMenu — the Priority+ overflow menu for PAGE HEADER actions. * The header shows the primary CTA plus at most the two promoted secondary * actions (`hidden lg:inline-flex` buttons); every custom action ALSO rides * this menu, flagged `promoted` when it owns a visible button. At desktop * width the menu drops the promoted items (and unmounts entirely when nothing * overflows); below lg it carries the full set — the title never gets crushed * by a strip of text buttons again. Same floating mechanics + permission * gating as RowActionsMenu; the trigger is a `btn btn-secondary` "…" button * so it reads as a sibling of the visible actions. */ function headerActionsMenuComponent(): string { return `${HEADER} import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { MoreHorizontal } from 'lucide-react'; import { useAuth } from '@/business/auth/useAuth'; import { BREAKPOINTS } from '@/components/ui/tableRepresentation'; export interface HeaderActionItem { /** Stable key — usually the action code. */ key: string; /** Visible menu label (already localised by the caller). */ label: string; /** Optional leading icon node, e.g. \`\`. */ icon?: ReactNode; /** Destructive styling (uses the --error-* tokens). */ danger?: boolean; /** Permission path gating this item — hidden when the user lacks it. */ permission?: string; /** Greys the item out and blocks the click. */ disabled?: boolean; /** A promoted action is ALSO rendered by the page as a visible header button * at >= lg (\`hidden lg:inline-flex\`): the menu keeps it below the * breakpoint and drops it at desktop width, so each action lives in exactly * one place at any given viewport. */ promoted?: boolean; /** data-testid forwarded onto the menu item (audit / driver anchor). */ testId?: string; onClick: () => void; } export interface HeaderActionsMenuProps { items: HeaderActionItem[]; /** Trigger aria-label / tooltip. Defaults to the localised "More actions". */ ariaLabel?: string; } const MENU_MAX_H = 320; // Tailwind's lg — read from the single sanctioned copy so the JS gate and the // promoted buttons' \`lg:\` classes flip at the same width. const DESKTOP_QUERY = \`(min-width: \${BREAKPOINTS.lg}px)\`; function useIsDesktop(): boolean { const [isDesktop, setIsDesktop] = useState(() => window.matchMedia(DESKTOP_QUERY).matches); useEffect(() => { const mql = window.matchMedia(DESKTOP_QUERY); const onChange = () => setIsDesktop(mql.matches); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, []); return isDesktop; } /** * Portaled to document.body with fixed positioning (same rationale as * RowActionsMenu): fixed coords anchored on the trigger escape any * overflow/stacking ancestor the header may sit in. */ export function HeaderActionsMenu({ items, ariaLabel }: HeaderActionsMenuProps) { const { t } = useTranslation('common'); const { hasPermission } = useAuth(); const isDesktop = useIsDesktop(); const [open, setOpen] = useState(false); const [focusedIdx, setFocusedIdx] = useState(0); const [pos, setPos] = useState({}); const triggerRef = useRef(null); const menuRef = useRef(null); // Permission gate first (recomputed every render — useAuth bootstraps async), // then the Priority+ gate: promoted items only ride the menu below lg. const allowed = items.filter((it) => !it.permission || hasPermission(it.permission)); const shown = isDesktop ? allowed.filter((it) => !it.promoted) : allowed; function reposition() { const el = triggerRef.current; if (!el) return; const r = el.getBoundingClientRect(); const right = Math.max(8, window.innerWidth - r.right); const dropUp = r.bottom + MENU_MAX_H > window.innerHeight && r.top > MENU_MAX_H; setPos(dropUp ? { position: 'fixed', bottom: window.innerHeight - r.top + 4, right } : { position: 'fixed', top: r.bottom + 4, right }); } useEffect(() => { if (!open) return; reposition(); function onDoc(e: MouseEvent) { const target = e.target as Node; if (triggerRef.current?.contains(target)) return; if (menuRef.current?.contains(target)) return; setOpen(false); } // A scroll/resize invalidates the fixed coords — close rather than chase them. function onDismiss() { setOpen(false); } document.addEventListener('mousedown', onDoc); window.addEventListener('scroll', onDismiss, true); window.addEventListener('resize', onDismiss); return () => { document.removeEventListener('mousedown', onDoc); window.removeEventListener('scroll', onDismiss, true); window.removeEventListener('resize', onDismiss); }; }, [open]); function choose(idx: number) { const it = shown[idx]; if (!it || it.disabled) return; it.onClick(); setOpen(false); } function onKeyDown(e: KeyboardEvent) { if (e.key === 'ArrowDown') { e.preventDefault(); if (!open) setOpen(true); setFocusedIdx((i) => Math.min(i + 1, shown.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setFocusedIdx((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (open) choose(focusedIdx); else setOpen(true); } else if (e.key === 'Escape') { setOpen(false); } } // Nothing to overflow at this viewport → no trigger at all. This is what // hides the "…" on desktop when every action fits as a visible button. if (shown.length === 0) return null; const label = ariaLabel ?? t('headerActions.more', { defaultValue: 'More actions' }); return (
{open && createPortal(
    {shown.map((it, idx) => (
  • ))}
, document.body, )}
); } `; } /** * MultiSelect — theme-compliant multi-select with removable chips. Value * contract: an array of selected option values (string[]). */ function multiSelectComponent(): string { return `${HEADER} import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Check, ChevronDown, X } from 'lucide-react'; export interface MultiSelectOption { value: string; label: string; } export interface MultiSelectProps { options: MultiSelectOption[]; /** Selected option values. */ value: string[]; onChange: (value: string[]) => void; /** Visible label above the field. Omit to render the control alone. */ label?: string; placeholder?: string; required?: boolean; disabled?: boolean; error?: string; } export function MultiSelect({ options, value, onChange, label, placeholder, required = false, disabled = false, error }: MultiSelectProps) { const { t } = useTranslation('common'); const [open, setOpen] = useState(false); const [focusedIdx, setFocusedIdx] = useState(0); const rootRef = useRef(null); const selectedSet = useMemo(() => new Set(value), [value]); const selectedOptions = useMemo(() => options.filter((o) => selectedSet.has(o.value)), [options, selectedSet]); useEffect(() => { if (!open) return; function onDoc(e: MouseEvent) { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); } document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [open]); function toggle(v: string) { if (selectedSet.has(v)) onChange(value.filter((x) => x !== v)); else onChange([...value, v]); } function onKeyDown(e: KeyboardEvent) { if (disabled) return; if (e.key === 'ArrowDown') { e.preventDefault(); if (!open) setOpen(true); setFocusedIdx((i) => Math.min(i + 1, options.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setFocusedIdx((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (open) { const opt = options[focusedIdx]; if (opt) toggle(opt.value); } else setOpen(true); } else if (e.key === 'Escape') { setOpen(false); } } const inputId = useMemo(() => 'multi-select-' + Math.random().toString(36).slice(2, 9), []); const listId = inputId + '-list'; return (
{label && ( )}
{ if (!disabled) setOpen((o) => !o); }} onKeyDown={onKeyDown} className="w-full min-h-[2.5rem] flex flex-wrap items-center gap-1 pl-2 pr-9 py-1.5 text-sm cursor-pointer rounded-[var(--radius-input,0.375rem)] border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)] focus:border-transparent" > {selectedOptions.length === 0 && ( {placeholder ?? t('multiSelect.placeholder', { defaultValue: 'Select…' })} )} {selectedOptions.map((opt) => ( {opt.label} ))}
{open && (
    {options.length === 0 && (
  • {t('multiSelect.empty', { defaultValue: 'No options' })}
  • )} {options.map((opt, idx) => { const isSel = selectedSet.has(opt.value); const cls = 'px-3 py-2 text-sm cursor-pointer flex items-center justify-between text-[var(--text-primary)] ' + (idx === focusedIdx ? 'bg-[var(--bg-hover)]' : ''); return (
  • setFocusedIdx(idx)} onMouseDown={(e) => { e.preventDefault(); toggle(opt.value); }} className={cls}> {opt.label} {isSel && }
  • ); })}
)}
{error &&

{error}

}
); } `; } /** * TruncatedText — clips its content with an ellipsis when it overflows and shows * the full value in a tooltip on hover/focus (the "popup si le libellé est * tronqué" requirement). Pure layout: no colors, no i18n. Reuses the package * `Tooltip`, gated by its `disabled` prop so the popup only appears when the * text is actually truncated (`scrollWidth > clientWidth`). */ function truncatedTextComponent(): string { return `${HEADER} import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Tooltip } from '@atlashub/smartstack'; export interface TruncatedTextProps { /** Cell content. When it overflows \`maxWidth\` it is clipped with an ellipsis * and the full text is shown in a tooltip on hover/focus. */ children: ReactNode; /** Full text for the tooltip. Defaults to \`children\` when it is a string. */ title?: string; /** Max content width before clipping. A fixed cap is used so the ellipsis * triggers even when the host has no fixed table layout; once the * DataTable engine ships \`table-layout: fixed\` you can pass \`maxWidth="100%"\`. */ maxWidth?: string; className?: string; } export function TruncatedText({ children, title, maxWidth = '28rem', className = '' }: TruncatedTextProps) { const ref = useRef(null); const [truncated, setTruncated] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; const measure = () => setTruncated(el.scrollWidth > el.clientWidth + 1); measure(); if (typeof ResizeObserver === 'undefined') return; const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [children, maxWidth]); const full = title ?? (typeof children === 'string' ? children : undefined); return ( {children} ); } export default TruncatedText; `; } /** * DataTable — the SmartStack design-system data table (the customisation-ui * baseline). OWNED here so a generated app always has it: ResponsiveDataTable * and every scaffolded *ListPage import `@/components/ui/DataTable`. Ported from * SmartStack.app's reference component — sorting (client + controlled), pagination, * global search, row selection, sticky/responsive columns, empty/loading states — * styled exclusively through the theme tokens scaffold-theme emits (--table-*, * --text-*, --bg-*, --color-accent-500) so the project theme + dark mode apply * automatically. The single adaptation vs the reference: --color-primary-500 → * --color-accent-500 (the CLI theme uses the accent ramp). */ function dataTableComponent(): string { return `${HEADER} import { useState, useMemo, useEffect, type ReactNode, type ReactElement } from 'react'; import { isIsoDateLike, formatIsoSmart } from '@atlashub/smartstack'; import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search, X } from 'lucide-react'; import { Skeleton } from '@/components/ui/Skeleton'; import { EmptyState } from '@/components/ui/EmptyState'; // ============================================================================ // Types // ============================================================================ export interface DataTableColumn { /** Unique key (matches data field or custom) */ key: string; /** Column header label */ label: string; /** Is column sortable */ sortable?: boolean; /** Custom render function */ render?: (item: T, index: number) => ReactNode; /** Column width (CSS value) */ width?: string; /** Column alignment */ align?: 'left' | 'center' | 'right'; /** Numeric column (amounts, quantities): right-aligned with tabular figures * so magnitudes line up. An explicit \`align\` wins over the implied right. */ numeric?: boolean; /** Hide on mobile */ hideOnMobile?: boolean; /** Sticky column */ sticky?: 'left' | 'right'; /** Server-side sort field sent to onSortChange (defaults to key). */ sortKey?: string; /** Hide the column below a Tailwind breakpoint (e.g. 'lg'). Generalises hideOnMobile (= 'md'). */ hideBelow?: 'sm' | 'md' | 'lg' | 'xl'; } export interface DataTablePagination { /** Items per page */ pageSize: number; /** Available page sizes */ pageSizeOptions?: number[]; /** Show page size selector */ showSizeSelector?: boolean; } export interface DataTableProps { /** Data array */ data: T[]; /** Column definitions */ columns: DataTableColumn[]; /** Loading state */ loading?: boolean; /** Enable global search */ searchable?: boolean; /** Search placeholder */ searchPlaceholder?: string; /** Controlled global search term. When provided together with onSearchChange, * DataTable filters by it and renders NO internal search input (the parent owns * the box). Mirrors the controlled-sort pattern (onSortChange). */ searchTerm?: string; /** Controlled search callback. Presence switches search to controlled mode. */ onSearchChange?: (value: string) => void; /** Pagination config */ pagination?: DataTablePagination; /** Row click handler */ onRowClick?: (item: T, index: number) => void; /** Get unique key for each row */ getRowKey?: (item: T, index: number) => string; /** Empty state message */ emptyMessage?: string; /** Empty state icon */ emptyIcon?: ReactNode; /** Empty state secondary line (why / what to do about it). */ emptyDescription?: string; /** Empty state CTA slot (already permission-gated by the caller). */ emptyAction?: ReactNode; /** Custom header actions */ headerActions?: ReactNode; /** Stripe rows */ striped?: boolean; /** Compact mode */ compact?: boolean; /** Custom className */ className?: string; /** Enable row selection */ selectable?: boolean; /** Selected row keys */ selectedKeys?: Set; /** Selection change handler */ onSelectionChange?: (keys: Set) => void; /** Custom search filter function */ searchFilter?: (item: T, searchTerm: string) => boolean; /** Default sort column */ defaultSortKey?: string; /** Default sort direction */ defaultSortDirection?: 'asc' | 'desc'; /** Controlled active sort key (server-side sort). */ sortKey?: string; /** Controlled active sort direction. */ sortDirection?: 'asc' | 'desc'; /** Controlled sort callback. When provided, DataTable delegates sorting to the * parent and does NOT reorder data itself — use for server-side sorted lists. */ onSortChange?: (key: string, direction: 'asc' | 'desc') => void; /** Server-driven mode: the parent fetches ONE page at a time. DataTable then * does NOT filter, sort or slice \`data\` itself — it renders the rows as * received and drives the pager from page / pageCount|totalCount / onPageChange. * Pair with searchTerm+onSearchChange (server search) and sortKey+onSortChange * (server sort). Without it the table stays fully client-side (legacy behaviour). */ serverMode?: boolean; /** Controlled current page (1-based) — server mode. */ page?: number; /** Total page count from the server — server mode (else derived from totalCount/pageSize). */ pageCount?: number; /** Total row count across ALL pages — server mode (drives the "x-y sur N" label). */ totalCount?: number; /** Page-change callback — server mode. Presence + serverMode drive the pager. */ onPageChange?: (page: number) => void; /** Page-size-change callback — server mode. When provided, the size selector calls it. */ onPageSizeChange?: (size: number) => void; /** Minimum table width in px — the sum of what the rendered columns each need. * Without it the wrapper's overflow-x-auto is inert and the columns compress. * ResponsiveDataTable computes and passes it; see tableRepresentation.ts. */ minWidth?: number; } // ============================================================================ // Helpers // ============================================================================ function getNestedValue(obj: unknown, path: string): unknown { return path.split('.').reduce((acc: unknown, part: string) => { if (acc && typeof acc === 'object' && part in acc) { return (acc as Record)[part]; } return undefined; }, obj); } /** * Cell fallback used when a column declares no \`render\`: a full-ISO date string is formatted * with the platform display settings (the column key decides date vs date+time — \`…At\` is an * instant, \`…Date|On|Until\` a day); anything else renders as before. Full-match detection * only, so codes and free text never trip it. ResponsiveDataTable's truncate fallback goes * through the same door. */ export function formatCellValue(value: unknown, columnKey: string): string { return isIsoDateLike(value) ? formatIsoSmart(value, columnKey) : String(value ?? ''); } function getAlignmentClass(align?: 'left' | 'center' | 'right'): string { if (align === 'right') return 'justify-end'; if (align === 'center') return 'justify-center'; return ''; } /** \`numeric\` implies right alignment unless the column pins one explicitly. */ function getEffectiveAlign(column: DataTableColumn): 'left' | 'center' | 'right' { return column.align ?? (column.numeric ? 'right' : 'left'); } // Literal classes so Tailwind's JIT scanner keeps them (a dynamically constructed // hidden-:table-cell class would be purged from the build). const HIDE_BELOW_CLASS: Record<'sm' | 'md' | 'lg' | 'xl', string> = { sm: 'hidden sm:table-cell', md: 'hidden md:table-cell', lg: 'hidden lg:table-cell', xl: 'hidden xl:table-cell', }; function getResponsiveHideClass(column: DataTableColumn): string { if (column.hideBelow) return HIDE_BELOW_CLASS[column.hideBelow]; if (column.hideOnMobile) return 'hidden md:table-cell'; return ''; } /** * Sticky column classes — a column pinning itself \`left\`/\`right\` stays visible * while the table scrolls horizontally (narrow screens). Opaque backgrounds * hide the content sliding beneath: the header cell keeps the header bg, body * cells default to the card bg (themable via --table-sticky-bg). */ function getStickyClass(column: DataTableColumn, kind: 'th' | 'td'): string { if (!column.sticky) return ''; const side = column.sticky === 'left' ? 'left-0' : 'right-0'; return kind === 'th' ? \`sticky \${side} z-[3] bg-[var(--table-header-bg)]\` : \`sticky \${side} z-[1] bg-[var(--table-sticky-bg,var(--bg-card))]\`; } function defaultSearchFilter(item: T, searchTerm: string): boolean { const search = searchTerm.toLowerCase(); return Object.values(item as Record).some((value) => { if (value === null || value === undefined) return false; return String(value).toLowerCase().includes(search); }); } // ============================================================================ // Component // ============================================================================ export function DataTable({ data, columns, loading = false, searchable = false, searchPlaceholder = 'Rechercher...', searchTerm: searchTermProp, onSearchChange, pagination, onRowClick, getRowKey = (_item, index) => String(index), emptyMessage = 'Aucune donnée', emptyIcon, emptyDescription, emptyAction, headerActions, striped = true, compact = false, className = '', selectable = false, selectedKeys = new Set(), onSelectionChange, searchFilter = defaultSearchFilter, defaultSortKey, defaultSortDirection = 'asc', sortKey: controlledSortKey, sortDirection: controlledSortDirection, onSortChange, serverMode = false, page, pageCount, totalCount, onPageChange, onPageSizeChange, minWidth, }: DataTableProps): ReactElement { const [searchTerm, setSearchTerm] = useState(''); const [internalSortKey, setInternalSortKey] = useState(defaultSortKey || null); const [internalSortDirection, setInternalSortDirection] = useState<'asc' | 'desc'>(defaultSortDirection); const [currentPage, setCurrentPage] = useState(1); // Controlled sort: when onSortChange is provided the parent owns the sort state // (e.g. server-side sort + pagination); DataTable renders the affordances but // never reorders data itself. const isSortControlled = onSortChange != null; const sortKey = isSortControlled ? (controlledSortKey ?? null) : internalSortKey; const sortDirection = isSortControlled ? (controlledSortDirection ?? 'asc') : internalSortDirection; const [pageSize, setPageSize] = useState(pagination?.pageSize || 10); // Controlled search: when onSearchChange is provided the parent owns the search // term + the input box; DataTable filters by it but renders no input of its own. const isSearchControlled = onSearchChange != null; const effectiveSearchTerm = isSearchControlled ? (searchTermProp ?? '') : searchTerm; useEffect(() => { setCurrentPage(1); }, [effectiveSearchTerm]); const safeData = useMemo(() => Array.isArray(data) ? data : [], [data]); // Server-driven mode — the parent fetched exactly this page. Filtering, sorting // and slicing are the server's job, so every client-side transform below // short-circuits to the rows as received (mirrors how controlled sort already // short-circuits). The pager is then driven by page / pageCount / totalCount. const isServer = serverMode === true; const filteredData = useMemo(() => { if (isServer || !effectiveSearchTerm) return safeData; return safeData.filter((item) => searchFilter(item, effectiveSearchTerm)); }, [safeData, effectiveSearchTerm, searchFilter, isServer]); const sortedData = useMemo(() => { if (isServer || isSortControlled || !sortKey) return filteredData; return [...filteredData].sort((a: T, b: T) => { const aValue = getNestedValue(a, sortKey); const bValue = getNestedValue(b, sortKey); if (aValue === bValue) return 0; if (aValue === null || aValue === undefined) return 1; if (bValue === null || bValue === undefined) return -1; const comparison = String(aValue).localeCompare(String(bValue), undefined, { numeric: true }); return sortDirection === 'asc' ? comparison : -comparison; }); }, [filteredData, sortKey, sortDirection, isSortControlled, isServer]); const paginatedData = useMemo(() => { if (isServer || !pagination) return sortedData; const start = (currentPage - 1) * pageSize; return sortedData.slice(start, start + pageSize); }, [sortedData, pagination, currentPage, pageSize, isServer]); // Page math: server mode reads page/pageCount/totalCount from props (the client // holds only one page); client mode derives them from the local array length. const effectivePageSize = isServer ? (pagination?.pageSize ?? pageSize) : pageSize; const effectiveCurrentPage = isServer ? (page ?? 1) : currentPage; const totalItems = isServer ? (totalCount ?? safeData.length) : sortedData.length; const totalPages = isServer ? (pageCount ?? Math.max(1, Math.ceil(totalItems / effectivePageSize))) : (pagination ? Math.ceil(sortedData.length / pageSize) : 1); const startItem = totalItems === 0 ? 0 : (isServer ? (effectiveCurrentPage - 1) * effectivePageSize + 1 : (pagination ? (currentPage - 1) * pageSize + 1 : 1)); const endItem = isServer ? Math.min(effectiveCurrentPage * effectivePageSize, totalItems) : (pagination ? Math.min(currentPage * pageSize, totalItems) : totalItems); const handleSort = (key: string) => { if (onSortChange) { const nextDirection: 'asc' | 'desc' = sortKey === key && sortDirection === 'asc' ? 'desc' : 'asc'; onSortChange(key, nextDirection); return; } if (internalSortKey === key) { setInternalSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); } else { setInternalSortKey(key); setInternalSortDirection('asc'); } }; const handlePageChange = (targetPage: number) => { const clamped = Math.max(1, Math.min(targetPage, totalPages)); if (isServer) { onPageChange?.(clamped); return; } setCurrentPage(clamped); }; const handleSelectAll = () => { if (!onSelectionChange) return; const allKeys = paginatedData.map((item: T, index: number) => getRowKey(item, index)); const allSelected = allKeys.every((key: string) => selectedKeys.has(key)); if (allSelected) { const newKeys = new Set(selectedKeys); allKeys.forEach((key: string) => newKeys.delete(key)); onSelectionChange(newKeys); } else { const newKeys = new Set(selectedKeys); allKeys.forEach((key: string) => newKeys.add(key)); onSelectionChange(newKeys); } }; const handleSelectRow = (key: string) => { if (!onSelectionChange) return; const newKeys = new Set(selectedKeys); if (newKeys.has(key)) { newKeys.delete(key); } else { newKeys.add(key); } onSelectionChange(newKeys); }; const handleSearchChange = (value: string) => { setSearchTerm(value); setCurrentPage(1); }; // Cell density is theme-driven (--table-cell-px/py). compact overrides per instance. const cellPadding = compact ? 'px-3 py-2' : 'px-[var(--table-cell-px)] py-[var(--table-cell-py)]'; const headerPadding = compact ? 'px-3 py-2' : 'px-[var(--table-cell-px)] py-[var(--table-cell-py)]'; const renderSortIcon = (column: DataTableColumn) => { if (!column.sortable) return null; if (sortKey === (column.sortKey ?? column.key)) { return sortDirection === 'asc' ? ( ) : ( ); } return ; }; return (
{((searchable && !isSearchControlled) || headerActions) && (
{searchable && !isSearchControlled && (
handleSearchChange(e.target.value)} placeholder={searchPlaceholder} className="input text-sm w-full pl-10 pr-10" /> {searchTerm && ( )}
)} {headerActions &&
{headerActions}
}
)}
{/* minWidth is what makes the wrapper's overflow-x-auto do anything at all: a \`w-full\` table in \`table-layout: auto\` COMPRESSES instead of overflowing, so the scrollbar never appeared and the columns were simply crushed. ResponsiveDataTable passes the surviving columns' width budget. */} {selectable && ( )} {columns.map((column) => ( ))} {loading && ( Array.from({ length: Math.min(pagination?.pageSize ?? 8, 8) }).map((_, skIndex) => ( {selectable && ( )} {columns.map((column) => ( ))} )) )} {!loading && paginatedData.length === 0 && ( )} {!loading && paginatedData.length > 0 && ( paginatedData.map((item: T, index: number) => { const rowKey = getRowKey(item, index); const isSelected = selectedKeys.has(rowKey); const globalIndex = (effectiveCurrentPage - 1) * effectivePageSize + index; return ( onRowClick?.(item, globalIndex)} onKeyDown={onRowClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRowClick(item, globalIndex); } } : undefined} tabIndex={onRowClick ? 0 : undefined} className={\` border-b border-[var(--table-border-color)] last:border-b-0 \${striped && index % 2 === 1 ? 'bg-[var(--table-zebra-bg)]/50' : ''} \${onRowClick ? 'cursor-pointer hover:bg-[var(--table-row-hover-bg)]' : ''} \${isSelected ? 'bg-[var(--table-row-selected-bg)]' : ''} transition-colors \`} > {selectable && ( )} {columns.map((column) => ( ))} ); }) )}
0 && paginatedData.every((item: T, index: number) => selectedKeys.has(getRowKey(item, index))) } onChange={handleSelectAll} className="rounded border-[var(--border-color)]" /> column.sortable && handleSort(column.sortKey ?? column.key)} >
{column.label} {renderSortIcon(column)}
e.stopPropagation()}> handleSelectRow(rowKey)} className="rounded border-[var(--border-color)]" /> {column.render ? column.render(item, globalIndex) : formatCellValue(getNestedValue(item, column.key), column.key)}
{pagination && totalItems > 0 && (
{startItem}-{endItem} sur {totalItems} {pagination.showSizeSelector && (!isServer || onPageSizeChange != null) && ( )}
Page {effectiveCurrentPage} / {totalPages}
)}
); } export default DataTable; `; } /** * ResponsiveDataTable — wrapper around the local that: * 1. shows/hides columns by viewport breakpoint. The default columns (code, * label, actions) carry NO minBreakpoint so they are ALWAYS visible; extra * columns declare a minBreakpoint and appear as the screen widens. * 2. clips `truncate` columns with an ellipsis + tooltip (via TruncatedText). * The column drop happens in JS (window.innerWidth) BEFORE reaching , * so it works on any DataTable version — it does not rely on the package's CSS * column hiding. Pure layout/logic: no colors, no i18n. */ /** * tableRepresentation — the width budget that decides whether a list still * READS as a table. * * A table only earns its layout when the reader compares rows column by column. * Once a column drops under the width where its value can be recognised at a * glance, the comparison is dead and the grid costs without paying. Three ways * out exist and only one is honest: * * - hiding columns amputates the data SILENTLY — the reader cannot know what is * missing; * - horizontal scrolling fights the vertical page scroll on touch, and hides the * actions column, which lives on the right; * - switching representation keeps every field, stacked in a readable order. * * So below the budget the list renders as cards. Two invariants: * 1. the threshold is a BUDGET (what the surviving columns each need), never a * viewport breakpoint — the same 1024px window holds a very different table * with a sidebar pinned than without; * 2. it is measured on the CONTAINER, never on `window.innerWidth` — the pane * can be 256-320px narrower than the window. */ function tableRepresentationModule(): string { return `${HEADER} import { useEffect, useRef, useState, type RefObject } from 'react'; export type ColumnKind = 'actions' | 'status' | 'number' | 'code' | 'date' | 'text' | 'label'; /** * Minimum width, in px, at which a column of each kind is still worth reading. * * Calibrated in BOTH directions: * - a 7-column list must STAY a table on a normal desktop (>=1280px window with * the sidebar pinned, i.e. ~944px of content); * - the same list must become cards on a tablet (768px window, ~720px of content). * Raising these flips desktop lists to cards; lowering them brings back the * crushed 100px-per-column table. Change them against the running app, not from taste. */ export const COLUMN_MIN_WIDTH: Record = { actions: 88, status: 96, number: 96, code: 104, date: 112, text: 128, label: 144, }; /** Width of the leading checkbox column when a table is selectable. */ export const SELECTION_COLUMN_WIDTH = 48; /** * Split a column key into lowercase words: \`startDate\` -> [start, date], * \`__actions\` -> [actions], \`created_at\` -> [created, at]. * * Matching WORDS, not substrings, is the whole point: a case-insensitive * \`/On$/\` reads "descripti-on" as a date column, and \`/[^a-z]date/i\` refuses * the "t" in front of \`startDate\`. */ function keyWords(key: string): readonly string[] { return key .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .split(/[^A-Za-zÀ-ÿ0-9]+/) .filter(Boolean) .map((word) => word.toLowerCase()); } // Order matters: the first kind whose vocabulary is hit wins. const KIND_WORDS: ReadonlyArray]> = [ ['actions', new Set(['action', 'actions'])], ['status', new Set(['status', 'statut', 'state', 'etat', 'état', 'enabled', 'active', 'actif', 'archived', 'archive'])], ['date', new Set(['date', 'at', 'on', 'until', 'expiry', 'expires', 'expiration', 'deadline', 'echeance', 'échéance', 'since', 'day'])], ['number', new Set(['count', 'total', 'amount', 'montant', 'price', 'prix', 'qty', 'quantity', 'quantite', 'quantité', 'priority', 'priorite', 'priorité', 'percent', 'rate', 'taux', 'nb', 'number', 'sum', 'size'])], ['code', new Set(['id', 'code', 'reference', 'référence', 'ref', 'slug', 'trigram', 'numero', 'numéro'])], ['label', new Set(['name', 'nom', 'label', 'libelle', 'libellé', 'title', 'titre', 'description', 'designation', 'désignation', 'subject', 'objet', 'intitule', 'intitulé'])], ]; /** Classify a column from its key, to pick a default minimum width. */ export function inferColumnKind(key: string): ColumnKind { const words = keyWords(key); for (const [kind, vocabulary] of KIND_WORDS) { if (words.some((word) => vocabulary.has(word))) return kind; } return 'text'; } export interface MeasurableColumn { readonly key: string; /** Explicit minimum, in px. Wins over every inference. */ readonly minWidth?: number; /** Declared CSS width — honoured only when expressed in px. */ readonly width?: string; } /** The width one column needs before it stops being readable. */ export function columnMinWidth(column: MeasurableColumn): number { if (typeof column.minWidth === 'number' && column.minWidth > 0) return column.minWidth; const px = /^(\\d+(?:\\.\\d+)?)px$/.exec(column.width ?? ''); if (px) return Number(px[1]); return COLUMN_MIN_WIDTH[inferColumnKind(column.key)]; } /** The width the whole table needs — its budget, and its \`min-width\` when forced. */ export function tableMinWidth(columns: readonly MeasurableColumn[], selectable = false): number { const base = selectable ? SELECTION_COLUMN_WIDTH : 0; return columns.reduce((sum, column) => sum + columnMinWidth(column), base); } export type Representation = 'table' | 'cards'; /** * The verdict. A \`null\` width means "not measured yet" — stay on the table * rather than flashing cards for one frame before the measurement lands. */ export function decideRepresentation(containerWidth: number | null, budget: number): Representation { if (containerWidth === null || containerWidth <= 0) return 'table'; return containerWidth < budget ? 'cards' : 'table'; } /** * Measure an element, not the window. Where \`ResizeObserver\` does not exist * (jsdom, and only jsdom in practice) the width stays \`null\` and the list stays * a table. */ export function useContainerWidth(ref: RefObject): number | null { const [width, setWidth] = useState(null); useEffect(() => { const element = ref.current; if (!element || typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver((entries) => { const measured = entries[0]?.contentRect.width; if (typeof measured === 'number' && measured > 0) setWidth(measured); }); observer.observe(element); return () => observer.disconnect(); }, [ref]); return width; } export type ColumnBreakpoint = 'sm' | 'md' | 'lg' | 'xl'; /** Tailwind's default breakpoints (px). The ONLY copy — width knowledge lives here. */ export const BREAKPOINTS: Record = { sm: 640, md: 768, lg: 1024, xl: 1280 }; /** The actions column is never dropped, whatever the width. */ export const ALWAYS_KEY = '__actions'; export interface ResponsiveMeasurableColumn extends MeasurableColumn { readonly minBreakpoint?: ColumnBreakpoint; } /** The columns that actually render at a given width. */ export function survivingColumns( columns: readonly C[], width: number, hiddenColumnKeys?: ReadonlySet, ): C[] { return columns .filter((c) => c.key === ALWAYS_KEY || !hiddenColumnKeys?.has(c.key)) .filter((c) => c.key === ALWAYS_KEY || !c.minBreakpoint || width >= BREAKPOINTS[c.minBreakpoint]); } /** * Table or cards, for a list that owns its container. * * The budget is computed on the SURVIVING columns, so an authored minBreakpoint * still does its job first: cards are the last resort, taken only when even the * columns a reader kept cannot each get their minimum width. A pinned value * (the view toggle, or ?view= in the URL) always wins over the measurement -- * a reader who chose a representation keeps it. */ export function useListRepresentation( columns: readonly C[], options: { hiddenColumnKeys?: ReadonlySet; selectable?: boolean; pinned?: Representation; } = {}, ): { mode: Representation; hostRef: RefObject } { const hostRef = useRef(null); const width = useContainerWidth(hostRef); if (options.pinned) return { mode: options.pinned, hostRef }; const effective = width ?? BREAKPOINTS.xl; const budget = tableMinWidth( survivingColumns(columns, effective, options.hiddenColumnKeys), options.selectable, ); return { mode: decideRepresentation(width, budget), hostRef }; } `; } function responsiveDataTableComponent(): string { return `${HEADER} import { useRef } from 'react'; import { DataTable, formatCellValue, type DataTableColumn, type DataTableProps } from '@/components/ui/DataTable'; import { TruncatedText } from '@/components/ui/TruncatedText'; import { BREAKPOINTS, survivingColumns, tableMinWidth, useContainerWidth, type ColumnBreakpoint, } from '@/components/ui/tableRepresentation'; export type { ColumnBreakpoint }; /** A DataTable column enriched with responsive + truncation metadata. */ export type ResponsiveColumn = DataTableColumn & { /** Minimum viewport breakpoint at which the column appears. Omit = always visible. */ minBreakpoint?: ColumnBreakpoint; /** Clip overflow with an ellipsis + show the full value in a tooltip on hover. */ truncate?: boolean; /** Width in px below which this column stops being readable. Feeds the table's * min-width budget; inferred from the key when omitted. */ minWidth?: number; }; export interface ResponsiveDataTableProps extends Omit, 'columns'> { columns: ResponsiveColumn[]; /** Column keys the USER hid (column picker). Applied BEFORE the breakpoint * filter and with higher precedence: a user-hidden column never renders, * while a user-SHOWN column (absent from the set) follows its own * minBreakpoint — default-hidden columns carry none, so re-enabling one * shows it at every width. The actions column can never be hidden. */ hiddenColumnKeys?: ReadonlySet; } function getNestedValue(obj: unknown, path: string): unknown { return path.split('.').reduce((acc: unknown, part: string) => { if (acc && typeof acc === 'object' && part in acc) { return (acc as Record)[part]; } return undefined; }, obj); } export function ResponsiveDataTable({ columns, hiddenColumnKeys, ...rest }: ResponsiveDataTableProps) { // Measured on the WRAPPER, never on the browser window. The table sits in a // pane that can be 256-320px narrower than the window (pinned sidebar, // resizable doc panel), so a window-based rule keeps columns that do not fit: // in a 768px window the content is only ~720px wide, yet every \`md\` column was // retained because the WINDOW cleared 768. Until the first measurement lands we // assume the widest tier, which is the pre-existing behaviour. const hostRef = useRef(null); const measured = useContainerWidth(hostRef); const width = measured ?? BREAKPOINTS.xl; const visible = survivingColumns(columns, width, hiddenColumnKeys) .map((c): DataTableColumn => { if (!c.truncate || c.render) return c; const key = c.key; // Same cell door as DataTable's own fallback: full-ISO date strings come out // formatted with the platform display settings instead of raw. return { ...c, render: (item: T) => {formatCellValue(getNestedValue(item, key), key)} }; }); return (
columns={visible} minWidth={tableMinWidth(visible, rest.selectable)} {...rest} />
); } export default ResponsiveDataTable; `; } /** * useColumnVisibility — per-page persisted column visibility state. The list * page owns it (controlled pattern, like searchTerm/onSortChange) and feeds * ResponsiveDataTable's `hiddenColumnKeys` + ColumnPicker. Pure logic, no JSX. */ /** * useListState — URL-backed list state (plan UI 3.1). useState-compatible * [value, setter] pairs persisted in the query string, so a filtered/sorted/ * paged list view is SHAREABLE (copy the URL), survives refresh and back/ * forward, and gives SavedViewsMenu something durable to save. Values equal * to their default are REMOVED from the URL (clean links); every write uses * the functional setSearchParams form with replace:true (consecutive setters * compose, no history spam). */ function useListStateModule(): string { return `${HEADER} import { useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; type SetParams = (updater: (prev: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => void; function write(setSearchParams: SetParams, key: string, value: string | undefined, defaultValue: string | undefined) { setSearchParams((prev) => { const next = new URLSearchParams(prev); if (value === undefined || value === '' || value === defaultValue) next.delete(key); else next.set(key, value); return next; }, { replace: true }); } /** A required string param (falls back to its default when absent). */ export function useListParam(key: string, defaultValue: T): [T, (v: T) => void] { const [searchParams, setSearchParams] = useSearchParams(); const value = (searchParams.get(key) ?? defaultValue) as T; const set = useCallback((v: T) => write(setSearchParams as SetParams, key, v, defaultValue), [setSearchParams, key, defaultValue]); return [value, set]; } /** An optional string param (undefined when absent). */ export function useListParamOpt(key: string, defaultValue?: T): [T | undefined, (v: T | undefined) => void] { const [searchParams, setSearchParams] = useSearchParams(); const value = (searchParams.get(key) ?? defaultValue) as T | undefined; const set = useCallback((v: T | undefined) => write(setSearchParams as SetParams, key, v, defaultValue), [setSearchParams, key, defaultValue]); return [value, set]; } /** A number param. */ export function useListNumberParam(key: string, defaultValue: number): [number, (n: number) => void] { const [searchParams, setSearchParams] = useSearchParams(); const raw = searchParams.get(key); const parsed = raw === null ? NaN : Number(raw); const value = Number.isFinite(parsed) ? parsed : defaultValue; const set = useCallback((n: number) => write(setSearchParams as SetParams, key, String(n), String(defaultValue)), [setSearchParams, key, defaultValue]); return [value, set]; } /** * A record param — each entry rides as \`.\` (only non-empty, * non-default entries). Identity of the returned record is STABLE unless the * prefixed params actually change (safe as a useEffect dep — a page change * must never retrigger the filter debounce). The setter accepts a value or a * functional updater, like useState. */ export function useListRecordParam( prefix: string, defaults: Record, ): [Record, (v: Record | ((p: Record) => Record)) => void] { const [searchParams, setSearchParams] = useSearchParams(); const sig = useMemo(() => { const pairs: string[] = []; searchParams.forEach((v, k) => { if (k.startsWith(prefix + '.')) pairs.push(\`\${k}=\${v}\`); }); return pairs.sort().join('&'); }, [searchParams, prefix]); const value = useMemo(() => { const out: Record = { ...defaults }; for (const pair of sig ? sig.split('&') : []) { const eq = pair.indexOf('='); out[pair.slice(prefix.length + 1, eq)] = pair.slice(eq + 1); } return out; // eslint-disable-next-line react-hooks/exhaustive-deps }, [sig]); const set = useCallback((updater: Record | ((p: Record) => Record)) => { setSearchParams((prev: URLSearchParams) => { const current: Record = { ...defaults }; prev.forEach((v, k) => { if (k.startsWith(prefix + '.')) current[k.slice(prefix.length + 1)] = v; }); const nextRecord = typeof updater === 'function' ? updater(current) : updater; const next = new URLSearchParams(prev); for (const k of Array.from(next.keys())) { if (k.startsWith(prefix + '.')) next.delete(k); } for (const [k, v] of Object.entries(nextRecord)) { if (v !== '' && v !== undefined && v !== (defaults[k] ?? '')) next.set(\`\${prefix}.\${k}\`, v); } return next; }, { replace: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [setSearchParams, prefix]); return [value, set]; } `; } /** * SavedViewsMenu — named saved views of the CURRENT list state (plan UI 3.1). * Reads/applies the URL query string (the useListState contract) and persists * the views per page in localStorage. Self-contained: the page only passes a * storage key and i18n-resolved labels. */ function savedViewsMenuComponent(): string { return `${HEADER} import { useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Bookmark, Trash2 } from 'lucide-react'; interface SavedView { name: string; /** The serialized query string (without the leading '?'). */ search: string; } export interface SavedViewsMenuProps { /** localStorage key, e.g. \`ss.views.v1.{app}.{module}.{section}.{entity}\`. */ storageKey: string; labels: { menu: string; save: string; savePrompt: string; empty: string; remove: string; }; } function readViews(storageKey: string): SavedView[] { try { const raw = localStorage.getItem(storageKey); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed.filter((v): v is SavedView => typeof v?.name === 'string' && typeof v?.search === 'string') : []; } catch { return []; } } export function SavedViewsMenu({ storageKey, labels }: SavedViewsMenuProps) { const [searchParams, setSearchParams] = useSearchParams(); const [open, setOpen] = useState(false); const [views, setViews] = useState(() => readViews(storageKey)); const rootRef = useRef(null); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [open]); const persist = (next: SavedView[]) => { setViews(next); try { localStorage.setItem(storageKey, JSON.stringify(next)); } catch { /* quota / private mode */ } }; const saveCurrent = () => { const name = window.prompt(labels.savePrompt)?.trim(); if (!name) return; const search = searchParams.toString(); persist([...views.filter((v) => v.name !== name), { name, search }]); }; const apply = (view: SavedView) => { setSearchParams(new URLSearchParams(view.search), { replace: false }); setOpen(false); }; return (
{open && (
{views.length === 0 && (

{labels.empty}

)} {views.map((v) => (
))}
)}
); } export default SavedViewsMenu; `; } function useColumnVisibilityModule(): string { return `${HEADER} import { useCallback, useRef, useState } from 'react'; export interface ColumnVisibilityDefault { /** Column key (matches the table column). */ key: string; /** Visible when the user has stored no choice. */ defaultVisible: boolean; /** Locked columns (identity, actions) can never be hidden. */ locked?: boolean; } export interface ColumnVisibilityState { /** Keys currently hidden — feed ResponsiveDataTable's hiddenColumnKeys. */ hiddenKeys: ReadonlySet; toggle: (key: string) => void; reset: () => void; } function defaultHidden(defaults: readonly ColumnVisibilityDefault[]): Set { return new Set(defaults.filter((d) => !d.defaultVisible && !d.locked).map((d) => d.key)); } /** * Persisted per-page column visibility (localStorage, payload \`{ hidden: string[] }\`). * Stored keys unknown to the current column set are PRUNED (re-scaffold drift) and * locked keys are ignored, so a stale store can never hide an identity column. * Storage errors (private mode, SSR/jsdom) fall back to the declared defaults. */ export function useColumnVisibility( storageKey: string, defaults: readonly ColumnVisibilityDefault[], ): ColumnVisibilityState { // The generated page re-declares the defaults array each render — reading it // through a ref keeps toggle/reset stable without deep-comparing it. const defaultsRef = useRef(defaults); defaultsRef.current = defaults; const [hiddenKeys, setHiddenKeys] = useState>(() => { const fallback = defaultHidden(defaults); if (typeof window === 'undefined') return fallback; try { const raw = window.localStorage.getItem(storageKey); if (!raw) return fallback; const parsed = JSON.parse(raw) as { hidden?: unknown }; if (!Array.isArray(parsed?.hidden)) return fallback; const known = new Map(defaults.map((d) => [d.key, d])); return new Set(parsed.hidden.filter((k): k is string => typeof k === 'string' && known.has(k) && !known.get(k)?.locked)); } catch { return fallback; } }); const persist = useCallback((next: ReadonlySet) => { try { window.localStorage.setItem(storageKey, JSON.stringify({ hidden: Array.from(next) })); } catch { /* storage unavailable — visibility stays session-local */ } }, [storageKey]); const toggle = useCallback((key: string) => { setHiddenKeys((prev) => { const decl = defaultsRef.current.find((d) => d.key === key); if (!decl || decl.locked) return prev; const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); persist(next); return next; }); }, [persist]); const reset = useCallback(() => { try { window.localStorage.removeItem(storageKey); } catch { /* nothing stored to clear */ } setHiddenKeys(defaultHidden(defaultsRef.current)); }, [storageKey]); return { hiddenKeys, toggle, reset }; } `; } /** * useKanbanColumnPrefs — persisted per-board column ORDER + visibility * (localStorage, payload `{ order: string[], hidden: string[] }`). The kanban * sibling of useColumnVisibility: the board's columns can be REORDERED by * dragging their headers and HIDDEN through the ColumnPicker, and both * choices survive reloads. Stored keys unknown to the current column set are * PRUNED (re-scaffold drift) and new columns are APPENDED at their declared * position; storage errors fall back to the declared defaults. */ /** * `useModuleAvailability` — is this application/module part of what THIS tenant was given? * * A thin adapter over the hook `@atlashub/smartstack` exports, so exactly ONE generated file * names the package symbol. Pages import it from here; when the installed package predates * the hook, only this file changes shape (a permissive stub) and every page still compiles. * * The question it answers is NOT `useLicense().hasModule()`, which is about what the customer * BOUGHT (license scopes). This one is about what the tenant HAS: the nav catalogue * (tenant_TenantApplications / TenantModules / TenantSections), already resolved server-side. */ function useModuleAvailabilityModule(spec: ScaffoldUiPrimitivesInput): string { if (meetsFloor(spec.smartstackVersion, MODULE_AVAILABILITY_MIN_VERSION)) { return `${HEADER} export type { ModuleAvailability, TenantAccessLevel } from '@atlashub/smartstack'; export { useModuleAvailability } from '@atlashub/smartstack'; `; } return `${HEADER} /* * PERMISSIVE STUB — the installed @atlashub/smartstack predates ${MODULE_AVAILABILITY_MIN_VERSION}, * which is the first release exporting useModuleAvailability. Every module reads as available, * so guarded surfaces behave exactly as they did before the guard existed. Run \`ss upgrade\` * and re-scaffold to switch this file to the real adapter. */ export type TenantAccessLevel = 'FullAccess' | 'RequestAccess'; export interface ModuleAvailability { readonly isResolved: boolean; readonly isIndeterminate: boolean; hasApplication(appCode: string): boolean; hasModule(appCode: string, moduleCode: string): boolean; hasSection(appCode: string, moduleCode: string, sectionCode: string): boolean; accessLevelOf(appCode: string): TenantAccessLevel | undefined; } export function useModuleAvailability(): ModuleAvailability { return { isResolved: false, isIndeterminate: true, hasApplication: () => true, hasModule: () => true, hasSection: () => true, accessLevelOf: () => undefined, }; } `; } function useKanbanColumnPrefsModule(): string { return `${HEADER} import { useCallback, useRef, useState } from 'react'; export interface KanbanColumnPrefDefault { /** Column key (the status enum value, verbatim). */ key: string; /** Hidden when the user has stored no choice (pagespec initiallyHidden). */ initiallyHidden?: boolean; } export interface KanbanColumnPrefsState { /** Every known column key, in the user's order — render the board from it. */ order: readonly string[]; /** Keys currently hidden — filter the rendered columns. */ hiddenKeys: ReadonlySet; /** Move \`sourceKey\` to \`targetKey\`'s position (header drag & drop). */ moveColumn: (sourceKey: string, targetKey: string) => void; toggleColumn: (key: string) => void; reset: () => void; } interface StoredPrefs { order?: unknown; hidden?: unknown; } function defaultOrder(defaults: readonly KanbanColumnPrefDefault[]): string[] { return defaults.map((d) => d.key); } function defaultHidden(defaults: readonly KanbanColumnPrefDefault[]): Set { return new Set(defaults.filter((d) => d.initiallyHidden).map((d) => d.key)); } /** Stored order ∩ known keys, then NEW keys spliced in at declared position. */ function reconcileOrder(stored: string[], declared: readonly string[]): string[] { const known = new Set(declared); const kept = stored.filter((k) => known.has(k)); const seen = new Set(kept); const next = [...kept]; declared.forEach((k, i) => { if (seen.has(k)) return; // Insert after the previous declared key already present, else append. const prev = declared.slice(0, i).reverse().find((p) => seen.has(p)); const at = prev ? next.indexOf(prev) + 1 : next.length; next.splice(at, 0, k); seen.add(k); }); return next; } /** * Persisted per-board column order + visibility. Key the store per page * (\`ss.kanban.v1.{app}.{module}.{section}.{entity}\`) so two boards never * share preferences. */ export function useKanbanColumnPrefs( storageKey: string, defaults: readonly KanbanColumnPrefDefault[], ): KanbanColumnPrefsState { // The generated page re-declares the defaults array each render — reading it // through a ref keeps the callbacks stable without deep-comparing it. const defaultsRef = useRef(defaults); defaultsRef.current = defaults; const [state, setState] = useState<{ order: string[]; hidden: ReadonlySet }>(() => { const fallback = { order: defaultOrder(defaults), hidden: defaultHidden(defaults) }; if (typeof window === 'undefined') return fallback; try { const raw = window.localStorage.getItem(storageKey); if (!raw) return fallback; const parsed = JSON.parse(raw) as StoredPrefs; const known = new Set(defaults.map((d) => d.key)); const order = Array.isArray(parsed?.order) ? reconcileOrder(parsed.order.filter((k): k is string => typeof k === 'string'), fallback.order) : fallback.order; const hidden = Array.isArray(parsed?.hidden) ? new Set(parsed.hidden.filter((k): k is string => typeof k === 'string' && known.has(k))) : fallback.hidden; return { order, hidden }; } catch { return fallback; } }); const persist = useCallback((order: string[], hidden: ReadonlySet) => { try { window.localStorage.setItem(storageKey, JSON.stringify({ order, hidden: Array.from(hidden) })); } catch { /* storage unavailable — prefs stay session-local */ } }, [storageKey]); const moveColumn = useCallback((sourceKey: string, targetKey: string) => { setState((prev) => { if (sourceKey === targetKey) return prev; const from = prev.order.indexOf(sourceKey); const to = prev.order.indexOf(targetKey); if (from < 0 || to < 0) return prev; const order = [...prev.order]; order.splice(from, 1); order.splice(to, 0, sourceKey); persist(order, prev.hidden); return { order, hidden: prev.hidden }; }); }, [persist]); const toggleColumn = useCallback((key: string) => { setState((prev) => { if (!prev.order.includes(key)) return prev; const hidden = new Set(prev.hidden); if (hidden.has(key)) hidden.delete(key); else hidden.add(key); persist(prev.order, hidden); return { order: prev.order, hidden }; }); }, [persist]); const reset = useCallback(() => { try { window.localStorage.removeItem(storageKey); } catch { /* nothing stored to clear */ } setState({ order: defaultOrder(defaultsRef.current), hidden: defaultHidden(defaultsRef.current) }); }, [storageKey]); return { order: state.order, hiddenKeys: state.hidden, moveColumn, toggleColumn, reset }; } `; } /** * ColumnPicker — the "view options" trigger + checkbox popover through which the * user shows/hides table columns. Layout-only: labels arrive by props (already * localised), state lives in useColumnVisibility on the page. Locked columns * (identity, actions) render checked + disabled. */ function columnPickerComponent(): string { return `${HEADER} import { useEffect, useRef, useState } from 'react'; import { Columns3 } from 'lucide-react'; export interface ColumnPickerItem { /** Column key (matches the table column). */ key: string; /** Visible label — already localised by the caller. */ label: string; visible: boolean; /** Locked columns (identity, actions) render checked + disabled. */ locked?: boolean; } export interface ColumnPickerProps { items: ColumnPickerItem[]; onToggle: (key: string) => void; onReset: () => void; /** Trigger label (localised by the caller). */ label: string; /** Popover heading — defaults to the trigger label. */ title?: string; resetLabel: string; } export function ColumnPicker({ items, onToggle, onReset, label, title, resetLabel }: ColumnPickerProps) { const [open, setOpen] = useState(false); const rootRef = useRef(null); useEffect(() => { if (!open) return; function onDoc(e: MouseEvent) { if (rootRef.current?.contains(e.target as Node)) return; setOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false); } document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); }; }, [open]); if (items.length === 0) return null; const hiddenCount = items.filter((it) => !it.visible).length; return (
{open && (

{title ?? label}

    {items.map((it) => (
  • ))}
)}
); } export default ColumnPicker; `; } /** * FilterBar — progressive-disclosure toolbar above a list table: global search + * primary filters always visible, advanced filters behind a "More filters" * toggle (badge = active count, auto-open when one is active on mount), active * filters echoed as removable chips. Pure layout: the PAGE owns the filter * state and renders the inputs; this primitive owns only the open/closed state. * No i18n inside (labels by props), no colors outside the design tokens. */ function filterBarComponent(): string { return `${HEADER} import { useState, type ReactNode } from 'react'; import { ChevronDown, ChevronUp, SlidersHorizontal, X } from 'lucide-react'; /** * The filter grid — declared ONCE and used by both the primary row and the * advanced panel. Two separate layouts (a flex row of intrinsic widths up top, * a grid below, each declared in a different file) is what made the column * origins irreconcilable: on a tablet the top row showed three controls while * the panel showed two, and no two rows started at the same x. * * Filters themselves are uniform \`min-w-0\` cells; a control that genuinely needs * two tracks asks for it (\`sm:col-span-2\`, e.g. a date range). */ const FILTER_GRID = 'grid gap-3 sm:grid-cols-2 lg:grid-cols-3'; export interface FilterChip { /** Filter key — passed back to onRemoveChip. */ key: string; /** Filter label — already localised by the caller. */ label: string; /** Human-readable active value. */ value: string; } export interface FilterBarLabels { /** "More filters" toggle. */ more: string; /** "Reset" — clears every filter. */ reset: string; /** aria-label prefix of a chip's remove button. */ clear: string; } export interface FilterBarProps { /** Global search input, rendered by the page. */ search?: ReactNode; /** Always-visible (primary) filter controls. */ primary?: ReactNode; /** Collapsed (advanced) controls behind the "More filters" toggle. Omit = no toggle. */ advanced?: ReactNode; /** ACTIVE advanced filters — badge on the toggle; the panel opens on mount when > 0. */ advancedActiveCount?: number; /** Active filters rendered as removable chips under the controls. */ chips?: FilterChip[]; onRemoveChip?: (key: string) => void; /** Clears every filter — rendered next to the chips. */ onReset?: () => void; /** Trailing toolbar control (the column picker). */ columnPicker?: ReactNode; labels: FilterBarLabels; } export function FilterBar({ search, primary, advanced, advancedActiveCount = 0, chips = [], onRemoveChip, onReset, columnPicker, labels, }: FilterBarProps) { // Auto-open when an advanced filter is already active on mount (seeded // defaultValue) — a hidden active criterion would read as wrong data. const [open, setOpen] = useState(advancedActiveCount > 0); return (
{/* ONE track definition for BOTH rows. The primary row used to be a plain flex of intrinsic widths while the advanced panel was its own grid declared in another file: no column origin could line up between the two, and every advanced row left 170-260px dead on the right. Filters are now uniform min-w-0 cells and the grid alone decides the width. */}
{search} {primary}
{advanced != null && ( )} {columnPicker}
{advanced != null && open && (
{advanced}
)} {chips.length > 0 && (
{chips.map((chip) => ( {chip.label}: {chip.value} {onRemoveChip && ( )} ))} {onReset && ( )}
)}
); } export default FilterBar; `; } /** * Textarea — theme-compliant multi-line text input (auto-growing, with a live * character counter). Replaces the single-line `` the form used to emit * for long-text / `control: "textarea"` fields. Value contract: string or null. */ function textareaComponent(): string { return `${HEADER} import { useEffect, useMemo, useRef } from 'react'; export interface TextareaProps { /** Current text, or null when empty. */ value: string | null; /** Receives the text (or null when cleared). */ onChange: (value: string | null) => void; /** Visible label above the field. */ label?: string; placeholder?: string; required?: boolean; disabled?: boolean; /** Minimum visible rows before auto-grow takes over. Default 3. */ rows?: number; /** Optional hard cap; when set, the live counter shows \`used / max\`. */ maxLength?: number; /** Validation message rendered under the field. */ error?: string; } export function Textarea({ value, onChange, label, placeholder, required = false, disabled = false, rows = 3, maxLength, error }: TextareaProps) { const ref = useRef(null); const inputId = useMemo(() => 'textarea-' + Math.random().toString(36).slice(2, 9), []); const length = (value ?? '').length; // Auto-grow: reset to auto then snap to the content height on every change. useEffect(() => { const el = ref.current; if (!el) return; el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; }, [value]); return (
{label && ( )}