/** * cli:scaffold-component — render/list.ts * Extracted VERBATIM from the historical generate.ts (pure move — frozen by * the 1.0 corpus hash). Renders nothing when the view is absent from * spec.views. */ import type { GeneratedFile, ComponentField } from '../types.js' import { extensionsModuleId } from '../../../../../../lib/app-classification.js' import { GENERATED_MARKER, boolPillExpr, coreNonStandardLookup, defaultLookupEndpoint, fieldToCamel, humanize, isBoolField, isDateField, isEnumField, isFkField, isMultiEnumField, isStatusFieldName, kebabToPascal, lookupHookImportLines, normalizeI18nKey, optionsArrayLiteral, renderFkLookupField, statusPillExpr, toCamel } from './shared.js' import { statCardJsx, STAT_CARD_IMPORT } from '../../../../../../lib/render-widgets.js' import { pickUiDesignOverlay } from '../../../../../../lib/ui-design-overlay.js' import { allowedTransitionsLiteral, kanbanDndEnabled, resolveKanban, type KanbanTone } from '../../../../../../lib/page-spec-kanban.js' import type { RenderContext } from './context.js' export function renderList(rc: RenderContext): GeneratedFile[] { const files: GeneratedFile[] = [] const { spec, ctx, allCustomActions, base, dialogParamsExpr, e, eLower, featurePath, hookCodeOf, iconForAction, isApi, isNavigate, isPayloadAction, isRedundantRowNav, listFileName, mobileEmptyBranch, mobileFabJsx, mobileKitImport, navCreate, navDetail, navEdit, offlineLevel, offlineStaleBanner, onlineStatusDecl, onlineStatusImport, outboxChipImport, outboxChipJsx, pathFor, permKey, plural, renderHeaderActionButton, renderHeaderActionsCluster, sectionCamel, toPascal, viewportModeDecl } = rc // ─── ListPage ──────────────────────────────────────────────────────────── if (spec.views.includes('list')) { // /ui-design judgment overlay for LISTS (plan UI 2.5): refines what the // pagespec declares — default representation, density, stat order, empty // state, card anatomy. Written by ui-design/apply-form-directives. const uiList = pickUiDesignOverlay(spec.pageSpec)?.list // ─── Kanban representation (pagespec.kanban — the board fold) ──────────── // The SmartKanban screen folds into THIS list pagespec (PRD-135): the board // renders as a third viewMode of the same page — one route, one FilterBar, // URL state and server filters shared by construction. Resolution is // fail-safe: a rejected block degrades to the legacy table/cards output // (byte-identical), every drop reported as a generation warning. const requestedViewModes = spec.pageSpec?.viewModes ?? [] const { kanban: kanbanResolved, rejected: kanbanRejected } = resolveKanban(spec.fields, spec.pageSpec) for (const r of kanbanRejected) { ctx.warnings?.push(`scaffold-component: ${plural}ListPage ${r}`) } const kanbanAuthored = requestedViewModes.includes('kanban') const kanbanEnabled = kanbanAuthored && kanbanResolved !== undefined if (kanbanAuthored && kanbanResolved === undefined) { ctx.warnings?.push( `scaffold-component: ${plural}ListPage viewModes includes 'kanban' but the kanban block is absent or unusable — ` + `board not rendered (backfill: create-prd/cli/derive-kanban-spec --mode derive).`, ) } else if (!kanbanAuthored && spec.pageSpec?.kanban != null) { ctx.warnings?.push( `scaffold-component: ${plural}ListPage carries a kanban block but viewModes does not include 'kanban' — ` + `the board toggle is not offered (PRD-135e).`, ) } // Drop row actions that just re-open the detail (redundant with onRowClick). const customActions = allCustomActions.filter(a => !isRedundantRowNav(a)) const apiActions = customActions.filter(isApi) // pageSpec.columns is the PRD-curated list (ba_screens.config.columns → // create-prd → pageSpec). When present, use it verbatim so the page // shows EXACTLY what the BA asked for — no more, no less. The data model // can carry legacy columns (e.g. `description` on the Budget entity) that // the BA did not declare for this view; using spec.fields would surface // them as untranslated `LIST.COLUMNS.DESCRIPTION` headers. // Fallback: spec.fields[0..6] for legacy projects without pageSpec.columns // (PRD predates the per-page contract). const columnSource = spec.pageSpec?.columns?.length ? spec.pageSpec.columns.map((c) => ({ name: fieldToCamel(c.key), // Fix #9 — normalise leaf to match the JSON i18n catalogue // (see normalizeI18nKey JSDoc). PRD may emit PascalCase // (`list.columns.Code`); JSON normalises to camelCase; TSX must follow. labelKey: normalizeI18nKey(c.labelKey), sortable: c.sortable ?? false, })) : spec.fields.slice(0, 6).map((f) => ({ name: fieldToCamel(f.name), labelKey: `list.columns.${fieldToCamel(f.name)}`, sortable: true, })) // Responsive column contract (consumed by ): the `code` // + `label/libellé` columns — plus the actions column appended below — stay // ALWAYS visible; the remaining columns appear progressively as the viewport // widens (first extra → md, second → lg, third+ → xl). Every text column also // gets `truncate: true` so a long value is clipped with an ellipsis and its // full text shown in a tooltip on hover ("popup si le libellé est tronqué"). // The BA pagespec overrides per column via `priority:'always'` or an explicit // `minBreakpoint` (read by index — columnSource is 1:1 with pageSpec.columns // in enriched mode). // FK columns show the target's human label, not the raw Guid. One lookup // hook + id→label Map per unique target entity (the column's render reads // it); reuses the same use{Target}Lookup hook the form imports. Resolution // is best-effort: ids beyond the first page (pageSize 100) fall back to the // Guid — reference tables are small, so they are fully covered. const fkVarBase = (entity: string) => entity.charAt(0).toLowerCase() + entity.slice(1) const fieldByCamel = new Map(spec.fields.map((f) => [fieldToCamel(f.name), f])) const listFkColumns = columnSource .map((c) => fieldByCamel.get(c.name)) .filter((f): f is ComponentField => Boolean(f && isFkField(f))) // Core targets served by a non-standard combined-DTO endpoint have NO generated // useLookup hook — keep them OUT of the hook path so the list never emits a // dangling import; their column falls back to the raw value (the form picker // resolves them via the selectItems adapter). const listFkHookColumns = listFkColumns.filter((f) => !coreNonStandardLookup(f.fkTo!)) const listFkTargets = Array.from( new Map(listFkHookColumns.map((f) => [f.fkTo!.entity, f.fkTo!])).values(), ) const listHasFkColumns = listFkTargets.length > 0 const listFkHookImports = lookupHookImportLines(listFkHookColumns, spec.appCode).map((l) => `\n${l}`).join('') const listFkHookDecls = listFkTargets .map((fk) => { const base = fkVarBase(fk.entity) return ` const { data: ${base}LookupData } = use${fk.entity}Lookup({ pageSize: 100 })\n const ${base}LookupMap = useMemo(() => new Map((${base}LookupData?.items ?? []).map((it) => [it.id, it.displayName])), [${base}LookupData])` }) .join('\n') const psColumns = spec.pageSpec?.columns const isCodeName = (n: string) => /^(code|reference|ref|numero|num)$/i.test(n) const isLabelName = (n: string) => /^(label|libelle|libell[eé]|name|nom|title|titre|designation|intitule|intitul[eé])$/i.test(n) const codeIdx = columnSource.findIndex((c) => isCodeName(c.name)) const labelIdx = columnSource.findIndex((c, i) => i !== codeIdx && isLabelName(c.name)) const alwaysIdx = new Set() if (codeIdx >= 0) alwaysIdx.add(codeIdx) if (labelIdx >= 0) alwaysIdx.add(labelIdx) columnSource.forEach((_c, i) => { if (psColumns?.[i]?.priority === 'always') alwaysIdx.add(i) }) // Fallback: with no code/label match, keep the first (and second) column // visible so a narrow screen never renders an actions-only table. if (alwaysIdx.size === 0) { alwaysIdx.add(0) if (columnSource.length > 1) alwaysIdx.add(1) } // ── Default-visibility budget ─────────────────────────────────────────── // Every pagespec column stays in the DTO and in the columns literal (the // picker can re-show any of them); the budget only decides which columns // are VISIBLE BY DEFAULT. It activates when the BA authored an explicit // medium/low priority, or when the list exceeds COLUMN_BUDGET columns — // otherwise today's rendering is preserved byte-for-byte (legacy specs). // An explicit medium/low always hides; always/high are never truncated // even beyond the budget (the BA wins — PRD-109 warns upstream, the // scaffolder never drops silently); unprioritized columns fill remaining // slots in declaration order. const COLUMN_BUDGET = 7 const colPriority = (i: number): string | undefined => psColumns?.[i]?.priority const defaultHiddenIdx = new Set() columnSource.forEach((_c, i) => { const p = colPriority(i) if ((p === 'medium' || p === 'low') && !alwaysIdx.has(i)) defaultHiddenIdx.add(i) }) if (defaultHiddenIdx.size > 0 || columnSource.length > COLUMN_BUDGET) { let visibleCount = 0 columnSource.forEach((_c, i) => { if (!defaultHiddenIdx.has(i) && (alwaysIdx.has(i) || colPriority(i) === 'high')) visibleCount++ }) columnSource.forEach((_c, i) => { if (defaultHiddenIdx.has(i) || alwaysIdx.has(i) || colPriority(i) === 'high') return if (visibleCount < COLUMN_BUDGET) { visibleCount++; return } defaultHiddenIdx.add(i) }) } const RESP_TIERS = ['md', 'lg', 'xl'] as const let respExtraTier = 0 // Package datetime helpers actually used by the columns — drives the import line. const listDateFns = new Set() // The first default-visible column pins itself left (identity stays in view // while the table scrolls horizontally on narrow screens); the __actions // column mirrors it right. Both consume DataTable's `sticky` prop. const stickyLeftIdx = columnSource.findIndex((_c, i) => !defaultHiddenIdx.has(i)) // Per-column JSX value expressions, captured alongside the table renders — // the CARDS representation (viewModes, plan UI 2.2) re-uses them verbatim // so a card cell can never drift from its table sibling (same pill, same // FK label, same date formatting). '<'-prefixed = a JSX element, '{'- // prefixed = a braced expression; both embed as-is. const cardExprs = new Map() const columnEntries = columnSource.map((c, i) => { const explicit = psColumns?.[i]?.minBreakpoint let minBreakpoint: string | undefined if (alwaysIdx.has(i)) minBreakpoint = undefined else if (explicit) minBreakpoint = explicit // Default-hidden columns get NO auto tier: re-enabling one through the // picker is a deliberate choice — it must not re-drop on a narrow screen. else if (defaultHiddenIdx.has(i)) minBreakpoint = undefined else { minBreakpoint = RESP_TIERS[Math.min(respExtraTier, RESP_TIERS.length - 1)]; respExtraTier++ } const bp = minBreakpoint ? `, minBreakpoint: '${minBreakpoint}'` : '' const field = fieldByCamel.get(c.name) const isStatus = isStatusFieldName(c.name) const accessor = `item.${c.name}` const head = ` { key: '${c.name}', label: t('${eLower}.${c.labelKey}'), ${i === stickyLeftIdx ? `sticky: 'left', ` : ''}` // Boolean → coloured Oui/Non pill instead of raw `true`/`false`. if (field && isBoolField(field)) { cardExprs.set(c.name, `{${boolPillExpr(accessor, eLower)}}`) return `${head}sortable: ${c.sortable}, truncate: true${bp}, render: (item) => (${boolPillExpr(accessor, eLower)}) }` } // FK column → render the resolved label from the lookup Map, NEVER the // Guid. An unresolved id (row beyond the first lookup page, deleted ref, // in-flight fetch) renders an em dash — a raw Guid is never user-facing. // Not server-sortable on a client-derived label. if (field && isFkField(field) && field.fkTo) { // Core targets without a useLookup hook (e.g. TenantOrganisation) have no // id→label map in scope — mask the Guid rather than reference a missing map. if (coreNonStandardLookup(field.fkTo)) { cardExprs.set(c.name, `{${accessor} ? '—' : ''}`) return `${head}sortable: false, truncate: true${bp}, render: (item) => (${accessor} ? '—' : '') }` } const base = fkVarBase(field.fkTo.entity) const fkValue = `${base}LookupMap.get(${accessor} as string) ?? (${accessor} ? '—' : '')` const render = isStatus ? `(${statusPillExpr(`{${fkValue}}`, accessor)})` : fkValue cardExprs.set(c.name, isStatus ? statusPillExpr(`{${fkValue}}`, accessor) : `{${fkValue}}`) return `${head}sortable: false, truncate: true${bp}, render: (item) => ${render} }` } // Enum / multi-enum → resolve the stored code to its human label (was: raw code). if (field && isMultiEnumField(field)) { const ml = `((${accessor} as string[] | undefined) ?? []).map((v) => ${optionsArrayLiteral(field.options ?? [])}.find((o) => o.value === v)?.label ?? v).join(', ')` cardExprs.set(c.name, `{${ml}}`) return `${head}sortable: false, truncate: true${bp}, render: (item) => ${ml} }` } if (field && isEnumField(field)) { const el = `${optionsArrayLiteral(field.options ?? [])}.find((o) => o.value === ${accessor})?.label ?? String(${accessor} ?? '')` const render = isStatus ? `(${statusPillExpr(`{${el}}`, accessor)})` : el cardExprs.set(c.name, isStatus ? statusPillExpr(`{${el}}`, accessor) : `{${el}}`) return `${head}sortable: ${c.sortable}, truncate: true${bp}, render: (item) => ${render} }` } // date / datetime → formatted through the platform display settings (the // package datetime module). The pagespec formatHint wins; the entity field // type is the fallback for legacy specs without hints. const hint = psColumns?.[i]?.formatHint?.toLowerCase() if (hint === 'date' || hint === 'datetime' || (field && isDateField(field))) { const wantsTime = hint === 'datetime' || (!hint && !!field && /^datetime$/i.test(field.type)) const fmt = wantsTime ? 'formatDateTime' : 'formatDate' listDateFns.add(fmt) cardExprs.set(c.name, `{${fmt}(${accessor})}`) return `${head}sortable: ${c.sortable}, truncate: true${bp}, render: (item) => ${fmt}(${accessor}) }` } // Status column backed by a plain/formula string → coloured status pill. if (isStatus) { cardExprs.set(c.name, statusPillExpr(`{String(${accessor} ?? '')}`, accessor)) return `${head}sortable: ${c.sortable}, truncate: true${bp}, render: (item) => (${statusPillExpr(`{String(${accessor} ?? '')}`, accessor)}) }` } // Amount/quantity column (numeric formatHint or number-typed field) → // right-aligned tabular figures (the table primitive derives the align). const isNumericCol = (hint != null && ['currency', 'money', 'number', 'decimal', 'integer', 'percent'].includes(hint)) || (field != null && /^(number|int|integer|decimal|float|double|money|currency)$/i.test(field.type)) // Plain string / number → default String() rendering (no custom render). cardExprs.set(c.name, `{formatCellValue(${accessor}, '${c.name}')}`) return `${head}sortable: ${c.sortable}, truncate: true${bp}${isNumericCol ? ', numeric: true' : ''} }` }).join(',\n') const listDateImport = listDateFns.size ? `, ${Array.from(listDateFns).sort().join(', ')}` : '' // ── Column visibility (picker) ────────────────────────────────────────── // The page owns the visibility state (controlled pattern, like searchTerm): // a defaults literal + the persisted useColumnVisibility hook feed both the // and ResponsiveDataTable's hiddenColumnKeys. Locked = the // always set (identity columns) + __actions (never listed, never hidable). const hasColumnPicker = columnSource.some((_c, i) => !alwaysIdx.has(i)) const columnVisibilityEntries = columnSource.map((c, i) => ` { key: '${c.name}', label: t('${eLower}.${c.labelKey}'), defaultVisible: ${!defaultHiddenIdx.has(i)}, locked: ${alwaysIdx.has(i)} }`, ).join(',\n') const columnStorageKey = `ss.cols.v1.${spec.appCode}.${spec.module}.${spec.section}.${eLower}` const columnVisibilityBlock = hasColumnPicker ? ` const columnVisibilityDefaults = [ ${columnVisibilityEntries} ] const { hiddenKeys: hiddenColumnKeys, toggle: toggleColumn, reset: resetColumns } = useColumnVisibility('${columnStorageKey}', columnVisibilityDefaults) ` : '' const columnPickerJsx = hasColumnPicker ? ` ({ key: c.key, label: c.label, visible: !hiddenColumnKeys.has(c.key), locked: c.locked }))} onToggle={toggleColumn} onReset={resetColumns} label={t('${eLower}.list.columnsPicker.button')} title={t('${eLower}.list.columnsPicker.title')} resetLabel={t('${eLower}.list.columnsPicker.reset')} />` : '' const columnPickerImports = hasColumnPicker ? `\nimport { ColumnPicker } from '@/components/ui/ColumnPicker'\nimport { useColumnVisibility } from '@/components/ui/useColumnVisibility'` : '' const tableVisibilityProps = hasColumnPicker ? `hiddenColumnKeys={hiddenColumnKeys} ` : '' // pageSpec.filters[] generates a FilterBar above the DataTable. Each entry // produces ONE controlled input whose value lives in local React state. // For text & select controls we apply client-side filtering on data?.items // immediately; date-range UI is rendered but its filtering relies on the // hook accepting the values (not yet plumbed — left as v2 enhancement). const pageSpecFilters = ((spec.pageSpec?.filters ?? []) as Array<{ field: string labelKey: string control?: string options?: unknown[] defaultValue?: unknown tier?: 'primary' | 'advanced' fkTo?: ComponentField['fkTo'] }>).map(f => ({ ...f, // `field` was the ONLY string of the list pipeline never normalised — // columns go through fieldToCamel (see the columns block) and labelKeys // through normalizeI18nKey (Fix #9), filters through nothing. A PRD // authoring `ClientId` therefore missed the entity's `clientId` and // shipped a dead free-text box. Normalising once here fixes every // downstream site at once: the filter state key, the chip key, the // client matcher and the server param all read `f.field`. field: fieldToCamel(f.field), // Fix #9 — normalise leaf to match the JSON i18n catalogue. One pass // here means renderFilterInput() (6 substitution sites for select/text/ // date-range/boolean labels + placeholders) does not need to be touched. labelKey: normalizeI18nKey(f.labelKey), })) // A pagespec "global search" filter (field q/search/… with a text control) // is NOT rendered as a field filter: it IS the global search box. Rendering // it used to emit a dead duplicate input filtering a DTO property that does // not exist (`item['search']` on AtlasHub invoices). The pagespec keeps // declaring it (PRD-082 parity with screen.md); the rendering fuses it with // the search box, which drives the server `search` param. const GLOBAL_SEARCH_FIELD = /^(q|search|recherche|fulltext)$/i const renderedFilters = pageSpecFilters.filter( f => !(GLOBAL_SEARCH_FIELD.test(f.field) && (f.control ?? 'text').toLowerCase() === 'text'), ) const hasFilters = renderedFilters.length > 0 // Every list is SERVER-driven: pagination, search and sort always ride the // hook args, and the pagespec filters[] ride them too on BOTH strata — the // integration GetAll binds one [FromQuery] param per filter and the // generated GetAllAsync applies the predicates (scaffold-controller / // scaffold-business, same SSOT lib/page-spec-filters.ts). The legacy // client-filtered path (bare hook call + in-memory useMemo over the first // server page) is GONE: it silently truncated every filtered integration // list to the API's default page (« 1-20 sur 20 » with 33 rows in base) // and made search + filters lie about scope. const serverFilteredList = hasFilters // ─── Custom-action payload dialogs (#6) — helpers lifted to generate() scope ── // payloadActions carry collectible payloadParameters[] → a . // header/bulk open a boolean; row keys off the clicked entity; bulk fires against // the current DataTable selection (selectedKeys). const payloadActions = apiActions.filter(isPayloadAction) const customDialogStateDecls = payloadActions.map(a => { const P = toPascal(a.code) return a.scope === 'row' ? ` const [${a.code}DialogItem, set${P}DialogItem] = useState<${e}ListDto | null>(null)` : ` const [${a.code}DialogOpen, set${P}DialogOpen] = useState(false)` }).join('\n') const customDialogsJsx = payloadActions.map(a => { const P = toPascal(a.code) const hookVar = `${toCamel(hookCodeOf(a))}Mutation` const title = `t('${eLower}.${a.labelKey}')` const common = ` title={${title}}\n submitLabel={${title}}\n params={${dialogParamsExpr(a)}}` if (a.scope === 'row') { return ` { if (${a.code}DialogItem) { await ${hookVar}.mutateAsync({ id: ${a.code}DialogItem.id, payload: payload as never }); set${P}DialogItem(null); refetchScreen() } }}\n onClose={() => set${P}DialogItem(null)}\n />` } if (a.scope === 'bulk') { return ` { await ${hookVar}.mutateAsync({ ids: [...selectedKeys], payload: payload as never }); setSelectedKeys(new Set()); set${P}DialogOpen(false); refetchScreen() }}\n onClose={() => set${P}DialogOpen(false)}\n />` } // header scope return ` { await ${hookVar}.mutateAsync(payload as never); set${P}DialogOpen(false); refetchScreen() }}\n onClose={() => set${P}DialogOpen(false)}\n />` }).join('\n') const customDialogImport = payloadActions.length > 0 ? `import { CustomActionDialog } from '@/components/ui/CustomActionDialog'\n` : '' // The list page ALWAYS holds React state: server page/search/sort, plus the // filter map when filters exist. useEffect powers the search + filter // debounces; useMemo serves the chips and the FK lookup maps. const needsUseMemo = hasFilters || listHasFkColumns || kanbanEnabled const needsUseState = true const reactHooks = [needsUseMemo ? 'useMemo' : '', needsUseState ? 'useState' : '', 'useEffect'].filter(Boolean) const reactImport = reactHooks.length > 0 ? `import { ${reactHooks.join(', ')} } from 'react'\n` : '' // Filter controls that use theme-compliant primitives need their imports on // the list page: select (with options) → EnumSelect; date-range → DateInput; // FK / lookup → EntityLookup. const ctrlOf = (f: { control?: string }) => (f.control ?? 'text').toLowerCase() // A filter on a FK field ALWAYS renders as a server-searching , // whatever control the PRD declared: `text` would substring-match the raw // Guid (never matches a name) and `select` ships without options (test-RH // statutId filter = dead free-text input). // // Resolution is DATA-first and deterministic: // 1. `filter.fkTo` — authored by create-prd / backfilled by // create-prd/cli/derive-filter-fks. This is the nominal path. // 2. the entity's own field of the same (normalised) name. // There is deliberately NO alias rescue (`client` → `clientId`): the filter // `field` IS the FK property by contract, and validate.ts rejects a // reference filter that resolves to nothing rather than letting it ship as // a text box — the whole point of this gate. const resolveFilterFk = (f: { field: string; fkTo?: ComponentField['fkTo'] }): NonNullable | undefined => f.fkTo ?? fieldByCamel.get(f.field)?.fkTo // Controls that keep their own meaning on a FK column. Everything else — // `lookup`, `select`, `text`, and any vocabulary the PRD invents // (`combobox`, `autocomplete`, `entity`…) — is promoted: the old // `lookup|select|text` whitelist silently dropped the rest to a raw input. const KEEPS_OWN_CONTROL = new Set(['date-range', 'daterange', 'boolean']) const lookupFilterField = (f: { field: string; control?: string; options?: unknown[]; fkTo?: ComponentField['fkTo'] }): NonNullable | undefined => { const fkTo = resolveFilterFk(f) if (!fkTo) return undefined if (KEEPS_OWN_CONTROL.has(ctrlOf(f))) return undefined // A FK whose vocabulary the PRD ENUMERATED is a finite referential, not a // server-searched one. Promoting it anyway is what put "Rechercher…" // (common:entityLookup.placeholder) on top of a seven-value status filter // and threw away the authored "Tous" reset. The promotion above exists // because `select` usually ships WITHOUT options — that case is untouched. if (ctrlOf(f) === 'select' && Array.isArray(f.options) && f.options.length > 0) return undefined return fkTo } const usesLookupFilter = renderedFilters.some(f => Boolean(lookupFilterField(f))) const usesSelectFilter = renderedFilters.some(f => !lookupFilterField(f) && ctrlOf(f) === 'select' && Array.isArray(f.options) && f.options.length > 0) const usesDateRangeFilter = renderedFilters.some(f => ctrlOf(f) === 'date-range' || ctrlOf(f) === 'daterange') const filterPrimitiveImports = [ usesLookupFilter ? `\nimport { EntityLookup } from '@/components/ui/EntityLookup'` : '', usesSelectFilter ? `\nimport { EnumSelect } from '@/components/ui/EnumSelect'` : '', usesDateRangeFilter ? `\nimport { DateInput } from '@/components/ui/DateInput'` : '', ].join('') // ── Progressive disclosure: primary vs advanced filters ───────────────── // Authored tiers win verbatim as soon as ONE filter declares a tier // (unmarked → advanced). With no tiers at all: deterministic heuristic — // first select/status filter, first FK lookup, first date-range, cap 3. // ≤ 3 filters → all primary, no "More filters" toggle at all. const PRIMARY_FILTER_CAP = 3 const isDateRangeCtrl = (f: { control?: string }) => ctrlOf(f) === 'date-range' || ctrlOf(f) === 'daterange' let primaryFilters = renderedFilters let advancedFilters: typeof renderedFilters = [] if (renderedFilters.length > PRIMARY_FILTER_CAP) { const primaryFields = new Set() if (renderedFilters.some(f => f.tier)) { renderedFilters.forEach(f => { if (f.tier === 'primary') primaryFields.add(f.field) }) } else { const pick = (pred: (f: typeof renderedFilters[number]) => boolean) => { if (primaryFields.size >= PRIMARY_FILTER_CAP) return const f = renderedFilters.find(x => !primaryFields.has(x.field) && pred(x)) if (f) primaryFields.add(f.field) } pick(f => ctrlOf(f) === 'select' || /status/i.test(f.field)) pick(f => Boolean(lookupFilterField(f))) pick(f => isDateRangeCtrl(f)) if (primaryFields.size === 0) primaryFields.add(renderedFilters[0]!.field) } primaryFilters = renderedFilters.filter(f => primaryFields.has(f.field)) advancedFilters = renderedFilters.filter(f => !primaryFields.has(f.field)) } const hasAdvancedFilters = advancedFilters.length > 0 /** Shared `{ value, label }[]` literal of a select filter's options. */ const selectOptionsLiteral = (opts: unknown[]): string => opts.map((opt) => { if (typeof opt === 'string') return `{ value: '${opt}', label: '${opt}' }` const o = opt as { value?: string; label?: string } const val = String(o.value ?? '') const lab = String(o.label ?? o.value ?? '') return `{ value: '${val}', label: '${lab}' }` }).join(', ') function renderFilterInput(f: typeof pageSpecFilters[number]): string { const ctrl = (f.control ?? 'text').toLowerCase() // FK filter → the SAME server-searching combobox as the form (debounced // `?search=` against the target's /lookup route). The user filters by the // target's display name; the filter state stores the selected Guid. const fkTo = lookupFilterField(f) if (fkTo) { // Core targets served by a non-standard combined-DTO endpoint // (TenantOrganisation) get the explicit endpoint + `selectItems` // adapter — exactly what the FORM path does (renderFkLookupField). // The filter path used to give up on them and emit a text box over a // Guid, which DEV-UI-033 then flagged with a heal that regenerated the // very same code: an audit finding that could never clear. const adapter = coreNonStandardLookup(fkTo) const endpoint = adapter ? adapter.endpoint : defaultLookupEndpoint(fkTo) const selectItemsProp = adapter ? `\n selectItems={${adapter.selectExpr}}` : '' return `
onFilterChange('${f.field}', id ?? '')} label={t('${eLower}.${f.labelKey}')} />
` } if (ctrl === 'select') { const opts = Array.isArray(f.options) ? f.options : [] // When the PRD declares control="select" but provides no options, // fall back to a text input — an empty onFilterChange('${f.field}', event.target.value)} className="input text-sm" /> ` } const optionObjs = selectOptionsLiteral(opts) // Themed dropdown (EnumSelect) instead of the native onFilterChange('${f.field}', event.target.checked ? 'true' : '')} /> {t('${eLower}.${f.labelKey}')} ` } // text + fallback return ` ` } const filterStateInitEntries = renderedFilters.flatMap(f => { const ctrl = (f.control ?? 'text').toLowerCase() if (ctrl === 'date-range' || ctrl === 'daterange') { return [`'${f.field}From': ''`, `'${f.field}To': ''`] } // defaultValue seeds the state (text/select/boolean). A boolean only // seeds 'true' — any other value must stay '' (a seeded 'false' string // is truthy and would silently activate the filter). let seeded = f.defaultValue == null ? '' : String(f.defaultValue) if (ctrl === 'boolean' && seeded !== 'true') seeded = '' return [`'${f.field}': '${seeded.replace(/'/g, "\\'")}'`] }).join(', ') const filterStateEmptyEntries = renderedFilters.flatMap(f => { const ctrl = (f.control ?? 'text').toLowerCase() if (ctrl === 'date-range' || ctrl === 'daterange') { return [`'${f.field}From': ''`, `'${f.field}To': ''`] } return [`'${f.field}': ''`] }).join(', ') // Server-driven list state — page / size / search / sort all live here and // are sent to the hook so the SERVER paginates, searches and sorts. The // per-column filters ride the same path on BOTH strata (debounced map → // hook args → bound [FromQuery] params — see serverFilteredList above). // ─── Initial sort (list.defaultSort — plan UI 2.4) ────────────────────── // Seeds the sort state (server-driven) or the table default (client mode). // An unknown key is skipped with a warning — never a silent dead sort. let defaultSort = spec.pageSpec?.defaultSort if (defaultSort && !columnSource.some(c => c.name === fieldToCamel(defaultSort!.key))) { ctx.warnings?.push( `scaffold-component: ${plural}ListPage defaultSort key '${defaultSort.key}' matches no declared column — ignored.`, ) defaultSort = undefined } const sortByInit = defaultSort ? `'${fieldToCamel(defaultSort.key)}'` : 'undefined' const sortDirInit = defaultSort ? `'${defaultSort.direction ?? 'asc'}'` : 'undefined' // URL-backed list state (plan UI 3.1): page/size/search/sort live in the // query string (useListState primitives) — shareable links, refresh/back // survival, and the substrate SavedViewsMenu saves. Defaults stay OUT of // the URL (clean links); the debounced mirrors stay local state. const serverStateBlock = ` const [page, setPage] = useListNumberParam('page', 1) const [pageSize, setPageSize] = useListNumberParam('size', 20) const [search, setSearch] = useListParam('q', '') const [debouncedSearch, setDebouncedSearch] = useState(search) const [sortBy, setSortBy] = useListParamOpt('sort'${defaultSort ? `, ${sortByInit}` : ''}) const [sortDir, setSortDir] = useListParamOpt<'asc' | 'desc'>('dir'${defaultSort ? `, ${sortDirInit}` : ''}) // Debounce the search box → one server refetch once typing settles; reset to page 1. useEffect(() => { const timer = setTimeout(() => { setDebouncedSearch(search); setPage(1) }, 300) return () => clearTimeout(timer) }, [search]) // Server-side sort: the DataTable reports (key, direction); refetch from page 1. const handleSort = (key: string, direction: 'asc' | 'desc') => { setSortBy(key); setSortDir(direction); setPage(1) } ` // clearFilter's date-range branch is emitted ONLY when a date-range filter // exists: an empty literal `[]` infers `never[]`, whose `.includes(key)` // rejects a string → TS2345 (client defect 2026-08-25 #7). The common // no-date-range list gets the plain single-key clear. const dateRangeFilterFields = renderedFilters.filter(isDateRangeCtrl).map(f => `'${f.field}'`) const clearFilterBody = dateRangeFilterFields.length > 0 ? `setFilters((prev) => ([${dateRangeFilterFields.join(', ')}].includes(key) ? { ...prev, [key + 'From']: '', [key + 'To']: '' } : { ...prev, [key]: '' }))` : `setFilters((prev) => ({ ...prev, [key]: '' }))` const filterHandlersBlock = ` const onFilterChange = (key: string, value: string) => setFilters((prev) => ({ ...prev, [key]: value })) const clearFilter = (key: string) => ${clearFilterBody} const resetFilters = () => setFilters({ ${filterStateEmptyEntries} }) ` const filterStateBlock = serverFilteredList // Filtered list: the server state (page/size/search/sort) PLUS the // filter map, debounced into the hook args — one refetch once typing // settles, back to page 1 on every filter change. ? `${serverStateBlock} const [filters, setFilters] = useListRecordParam('f', { ${filterStateInitEntries} }) const [debouncedFilters, setDebouncedFilters] = useState>(filters) ${filterHandlersBlock} useEffect(() => { const timer = setTimeout(() => { setDebouncedFilters(filters); setPage(1) }, 300) return () => clearTimeout(timer) }, [filters]) ` : serverStateBlock // ── Active-filter chips + advanced badge count ────────────────────────── // Chips echo every active filter as "Label: value" with an individual ×. // Values resolve to what the user actually chose: select → option label, // boolean → Oui/Yes, date-range → from → to, FK → the label from an // ALREADY-IN-SCOPE lookup map (a list FK column on the same target). A FK // filter without such a map gets NO chip — a raw Guid must never surface // (DEV-UI-033); the advanced badge still counts it. const usedChipMaps = new Set() const chipEntries = renderedFilters.map(f => { const label = `t('${eLower}.${f.labelKey}')` if (isDateRangeCtrl(f)) { return ` if (filters['${f.field}From'] || filters['${f.field}To']) chips.push({ key: '${f.field}', label: ${label}, value: \`\${filters['${f.field}From'] || '…'} → \${filters['${f.field}To'] || '…'}\` })` } if (ctrlOf(f) === 'boolean') { return ` if (filters['${f.field}'] === 'true') chips.push({ key: '${f.field}', label: ${label}, value: t('${eLower}.common.yes') })` } const fkTo = lookupFilterField(f) if (fkTo) { const target = fkTo.entity if (!listFkTargets.some(t => t.entity === target)) return '' const mapVar = `${fkVarBase(target)}LookupMap` usedChipMaps.add(mapVar) return ` if (filters['${f.field}']) chips.push({ key: '${f.field}', label: ${label}, value: ${mapVar}.get(filters['${f.field}']) ?? '—' })` } if (ctrlOf(f) === 'select' && Array.isArray(f.options) && f.options.length > 0) { return ` if (filters['${f.field}']) chips.push({ key: '${f.field}', label: ${label}, value: [${selectOptionsLiteral(f.options)}].find((o) => o.value === filters['${f.field}'])?.label ?? filters['${f.field}'] })` } return ` if (filters['${f.field}']) chips.push({ key: '${f.field}', label: ${label}, value: filters['${f.field}'] })` }).filter(Boolean).join('\n') const advancedActiveExpr = advancedFilters.map(f => isDateRangeCtrl(f) ? `(filters['${f.field}From'] || filters['${f.field}To'])` : `filters['${f.field}']`, ).join(', ') const filterChipsBlock = hasFilters ? ` const activeFilterChips = useMemo(() => { const chips: { key: string; label: string; value: string }[] = [] ${chipEntries} return chips }, [filters${Array.from(usedChipMaps).map(m => `, ${m}`).join('')}]) ${hasAdvancedFilters ? ` const advancedActiveCount = [${advancedActiveExpr}].filter(Boolean).length\n` : ''}` : '' const customRowActions = customActions.filter(a => a.scope === 'row') const customHeaderActions = customActions.filter(a => a.scope === 'header') // Bulk actions fire against the DataTable row selection — rendered in a // selection toolbar shown only when at least one row is checked. const customBulkActions = customActions.filter(a => a.scope === 'bulk') const hasBulkActions = customBulkActions.length > 0 const bulkSelectionStateDecl = hasBulkActions ? ` const [selectedKeys, setSelectedKeys] = useState>(new Set())\n` : '' const tableSelectionProps = hasBulkActions ? `selectable selectedKeys={selectedKeys} onSelectionChange={setSelectedKeys} ` : '' // Hook imports — ONLY for api-bound actions. Navigate actions do not call // any hook (they just `navigate(...)`), so importing one would produce an // unused-symbol warning at best, a phantom 405 at worst. const customHookImports = apiActions .map(a => `, use${toPascal(hookCodeOf(a))}${e}`) .join('') // Base lucide imports — ONLY the icons this page actually renders (an // unused import fails the strict type-check, TS6133 — client defect // 2026-08-25 #6): Plus gates on navCreate, Pencil on navEdit; the delete // button, page icon and search box are unconditional. const baseLucideIcons = [ ...(navCreate ? ['Plus'] : []), ...(navEdit ? ['Pencil'] : []), 'Trash2', 'List', 'Search', 'X', ] // Lucide icon imports — dedup to avoid duplicate identifiers. Both api + // navigate actions need their icon, so the union covers every button. const usedIcons = new Set(customActions.map(a => iconForAction(a))) // Icons already hoisted into the base import below are dropped here so a // custom action icon never emits a duplicate identifier. for (const name of baseLucideIcons) usedIcons.delete(name) const customIconImports = Array.from(usedIcons).map(name => `, ${name}`).join('') // Mutation declarations — one per api-bound custom action, e.g. // const archiveMutation = useArchiveBudget() // Navigate actions emit nothing here. const customMutationDecls = apiActions .map(a => ` const ${toCamel(hookCodeOf(a))}Mutation = use${toPascal(hookCodeOf(a))}${e}()`) .join('\n') // Handler functions — bridge button onClick to mutation.mutateAsync (api) // or to navigate(targetRoute) (navigate). The two branches keep the same // signature shape so the JSX caller is uniform. const customHandlerDecls = customActions .map(a => { const name = `handle${toPascal(a.code)}` if (isNavigate(a)) { const target = a.targetRoute ?? (a.scope === 'row' && navDetail ? `routes.${sectionCamel}.detail(item.id)` : `routes.${sectionCamel}.list()`) if (a.scope === 'header') { return ` const ${name} = () => {\n navigate(${target})\n }` } return ` const ${name} = (item: ${e}ListDto) => {\n navigate(${target})\n }` } // Payload actions open a (collect params) instead of // mutating directly — the dialog's onSubmit fires the mutation with the payload. if (isPayloadAction(a)) { return a.scope === 'row' ? ` const ${name} = (item: ${e}ListDto) => { set${toPascal(a.code)}DialogItem(item) }` : ` const ${name} = () => { set${toPascal(a.code)}DialogOpen(true) }` } // kind: 'api' — call the matching React Query mutation. const hookVar = `${toCamel(hookCodeOf(a))}Mutation` if (a.scope === 'header') { return ` const ${name} = async () => {\n await ${hookVar}.mutateAsync()\n refetchScreen()\n }` } if (a.scope === 'bulk') { // Bulk fires against the selected rows, then clears the selection. return ` const ${name} = async () => {\n await ${hookVar}.mutateAsync([...selectedKeys])\n setSelectedKeys(new Set())\n refetchScreen()\n }` } // row scope return ` const ${name} = async (item: ${e}ListDto) => {\n await ${hookVar}.mutateAsync(item.id)\n refetchScreen()\n }` }) .join('\n\n') // Header-scope actions go through the Priority+ cluster (≤ 2 visible // buttons at lg+ plus the HeaderActionsMenu overflow); bulk actions keep // the flat strip — they live in the selection toolbar, not the header. const customHeaderActionsJsx = renderHeaderActionsCluster(customHeaderActions) const customBulkActionsJsx = customBulkActions.map(a => renderHeaderActionButton(a)).join('\n') const bulkToolbarJsx = hasBulkActions ? ` {selectedKeys.size > 0 && (
{t('${eLower}.list.selectedCount', { count: selectedKeys.size, defaultValue: '{{count}} selected' })}
${customBulkActionsJsx}
)} ` : '' // Row custom actions are collapsed into a single "…" overflow menu // () rather than an unbounded strip of icon-only buttons: the // column stays Edit + Delete + one menu trigger, and every action shows an // icon + TEXT label (no more ambiguous bare `>` chevrons). Each item is // permission-gated inside the menu (useAuth) and calls its existing // handle(item) handler. Built as an items[] literal placed inside the // column render closure so onClick closes over the row `item`. const renderRowActionMenuItem = (a: PageActionMeta): string => { const Icon = iconForAction(a) const danger = a.variant === 'danger' ? ', danger: true' : '' return ` { key: '${a.code}', label: t('${eLower}.${a.labelKey}'), icon: <${Icon} className="w-4 h-4" />, permission: '${a.permission}', onClick: () => { void handle${toPascal(a.code)}(item) }${danger} }` } const customRowActionsJsx = customRowActions.length > 0 ? ` ` : '' const rowActionsMenuImport = customRowActions.length > 0 ? `import { RowActionsMenu } from '@/components/ui/RowActionsMenu'\n` : '' const headerActionsMenuImport = customHeaderActions.length > 0 ? `import { HeaderActionsMenu } from '@/components/ui/HeaderActionsMenu'\n` : '' const filteredItemsBlock = ` const filtered = data?.items ?? [] const totalCount = data?.totalCount ?? 0 ` // Search toolbar. hasFilters → bordered card with the search box + per-column // filters (client-side). No filters → a plain search box that drives the SERVER // search (debounced). Both own the input; the DataTable receives searchTerm/ // onSearchChange and (server path) does NOT filter locally. const searchBoxJsx = `
setSearch(event.target.value)} placeholder={t('${eLower}.list.search')} className="input text-sm w-full pl-10 pr-10" /> {search && ( )}
` // Progressive-disclosure toolbar: hosts the global search, the // ≤ 3 primary filters, the collapsed advanced panel (badge = active count, // auto-open handled by the primitive), the active-filter chips and the // column picker. The page owns every piece of state; the primitive owns // only the open/closed state of the advanced panel. const filterBarLabelsJsx = `labels={{ more: t('${eLower}.list.filters.more'), reset: t('${eLower}.list.filters.reset'), clear: t('${eLower}.list.filters.clear') }}` // Saved views (plan UI 3.1): named snapshots of the URL list state, // persisted per page in localStorage — only meaningful when the state IS // in the URL (server-filtered lists). Rides the FilterBar picker slot. const savedViewsJsx = serverFilteredList ? `` : '' const pickerSlotJsx = savedViewsJsx && columnPickerJsx ? `
${savedViewsJsx}${columnPickerJsx}
` : (savedViewsJsx || columnPickerJsx) const columnPickerProp = pickerSlotJsx ? ` columnPicker={${pickerSlotJsx}}` : '' const filterBarJsx = hasFilters ? ` ${primaryFilters.map(renderFilterInput).join('\n')} }${hasAdvancedFilters ? ` advanced={<> ${advancedFilters.map(renderFilterInput).join('\n')} } advancedActiveCount={advancedActiveCount}` : ''} chips={activeFilterChips} onRemoveChip={clearFilter} onReset={resetFilters}${columnPickerProp} ${filterBarLabelsJsx} /> ` : ` ` // The list page always owns the search input (toolbar above); the DataTable runs // in controlled-search mode either way. On the server path serverMode also stops // the table from filtering/paginating/sorting locally (see tableServerProps). const tableSearchProps = `searchTerm={search} onSearchChange={setSearch}` // Server-driven wiring — every list. On the filtered path, the debounced // filter map feeds the hook args: FK lookups + select/text post their // string, booleans post true or nothing (never a 'false' equality), // date-ranges post their From/To bounds. // ─── Quick segments (list.segments[] — plan UI 2.3) ───────────────────── // One-click cohort tabs (« Tous | Actifs | Archivés ») above the FilterBar. // A segment's filter rides the SAME server params as the FilterBar; for its // field the ACTIVE segment wins over the filter input (a segment is the // stronger, one-click statement of the same predicate). Wirable only when // the field is a declared server-bound filter — else warning + no segments // (a dead cohort tab is worse than none). const declaredSegments = spec.pageSpec?.segments ?? [] let segmentsEnabled = declaredSegments.length > 0 if (segmentsEnabled) { const declaredFields = new Set(renderedFilters.map(f => f.field)) const bad = declaredSegments.filter(g => g.filter && (!serverFilteredList || !declaredFields.has(g.filter.field))) if (bad.length > 0) { ctx.warnings?.push( `scaffold-component: ${plural}ListPage segments [${bad.map(g => g.key).join(', ')}] filter on fields ` + `that are not server-bound filters of this list — segments not rendered (author the field in filters[]).`, ) segmentsEnabled = false } } const segmentFieldSet = new Set( segmentsEnabled ? declaredSegments.flatMap(g => (g.filter ? [g.filter.field] : [])) : [], ) const segmentOverride = (field: string, expr: string): string => segmentFieldSet.has(field) ? `activeSegment?.filter?.field === '${field}' ? activeSegment.filter.value : (${expr})` : expr const serverFilterArgs = serverFilteredList ? renderedFilters.map(f => { if (isDateRangeCtrl(f)) { return `, ${f.field}From: debouncedFilters['${f.field}From'] || undefined, ${f.field}To: debouncedFilters['${f.field}To'] || undefined` } if (ctrlOf(f) === 'boolean') { return `, ${f.field}: ${segmentOverride(f.field, `debouncedFilters['${f.field}'] === 'true' ? true : undefined`)}` } return `, ${f.field}: ${segmentOverride(f.field, `debouncedFilters['${f.field}'] || undefined`)}` }).join('') : '' // Board mode fetches ONE large page (the board has no pager) — the // truncated banner surfaces anything beyond KANBAN_FETCH_SIZE. const listHookArgs = kanbanEnabled ? `{ page: boardActive ? 1 : page, pageSize: boardActive ? KANBAN_FETCH_SIZE : pageSize, search: debouncedSearch || undefined, sortBy, sortDir${serverFilterArgs} }` : `{ page, pageSize, search: debouncedSearch || undefined, sortBy, sortDir${serverFilterArgs} }` const tableServerProps = `serverMode page={page} totalCount={totalCount} onPageChange={setPage} onPageSizeChange={(size) => { setPageSize(size); setPage(1) }} sortKey={sortBy} sortDirection={sortDir} onSortChange={handleSort} ` const tablePaginationProp = '{ pageSize, showSizeSelector: true }' // ─── KPI stat row (list.stats[] — plan UI 2.1) ─────────────────────────── // A StatCard row above the FilterBar. Deterministic wiring only: // - unfiltered count on the page's entity → its own list hook // ({page:1,pageSize:1} → totalCount), one hook call per stat; // - filtered count → same hook + the filter param, ONLY when the field is // a declared server-bound filter of this list (routeMode 'screens'); // - anything else renders visibly unwired ("—") + a generation warning. // PRD-115 caps authoring at 4 stats; DEV-UI-042 audits page parity. const statsDeclared = spec.pageSpec?.stats ?? [] // statsOrder (overlay): mentioned keys first in that order, the rest // appended in declared order — never a silent drop. const statsOrder = uiList?.statsOrder ?? [] const stats = statsOrder.length ? [ ...statsOrder.map(k => statsDeclared.find(x => x.key === k)).filter((x): x is typeof statsDeclared[number] => x !== undefined), ...statsDeclared.filter(x => !statsOrder.includes(x.key)), ] : statsDeclared const declaredFilterFields = new Set(renderedFilters.map(f => f.field)) const statIconSet = new Set() const statBlocks: string[] = [] const statHookDecls: string[] = [] const statRefetchNames: string[] = [] const wrapStatPerm = (jsx: string, perm?: string): string => perm ? ` \n${jsx}\n ` : jsx for (const s of stats) { const labelExpr = `t('${eLower}.${s.labelKey}')` const icon = s.icon ? kebabToPascal(s.icon) : undefined const foreign = s.entity !== undefined && s.entity !== e const nonCount = (s.aggregation !== undefined && !/^count$/i.test(s.aggregation)) || (s.type !== undefined && s.type !== 'kpi' && s.type !== 'counter') const filterWirable = !s.filter || (serverFilteredList && declaredFilterFields.has(s.filter.field) && (s.filter.op === undefined || s.filter.op === 'eq' || s.filter.op === '==')) if (foreign || nonCount || !filterWirable) { ctx.warnings?.push( `scaffold-component: ${plural}ListPage stat '${s.key}' is not deterministically wirable ` + `(entity '${s.entity ?? e}', aggregation '${s.aggregation ?? 'count'}'` + `${s.filter ? `, filter '${s.filter.field}'` : ''}) — rendered unwired ("—").`, ) if (icon) statIconSet.add(icon) statBlocks.push(wrapStatPerm(statCardJsx({ labelExpr, valueAttr: '"—"', iconName: icon }, ' '), s.permission)) continue } if (icon) statIconSet.add(icon) const v = `stat${toPascal(s.key)}` const filterArg = s.filter ? `, ${s.filter.field}: ${JSON.stringify(s.filter.value)}` : '' statHookDecls.push(` const { data: ${v}Data, isLoading: ${v}Loading, refetch: ${v}Refetch } = use${plural}({ page: 1, pageSize: 1${filterArg} })`) statRefetchNames.push(`${v}Refetch`) statBlocks.push(wrapStatPerm( statCardJsx({ labelExpr, valueAttr: `{${v}Data?.totalCount ?? 0}`, iconName: icon, loadingExpr: `${v}Loading` }, ' '), s.permission, )) } const statsRowJsx = statBlocks.length ? `
${statBlocks.join('\n')}
` : '' const statHookDeclsJsx = statHookDecls.length ? '\n' + statHookDecls.join('\n') : '' // Every mutation handler calls refetchScreen() — the KPI stat hooks count // the same entity, so they go stale together with the list. const statRefetchCalls = statRefetchNames.map(n => `\n ${n}()`).join('') const statCardPageImport = statBlocks.length ? `\n${STAT_CARD_IMPORT}` : '' // Dedup against the base lucide names and the custom-action icon imports. // MUST be the SAME conditional set as the base import (baseLucideIcons): // a hardcoded set filtered 'Plus'/'Pencil' out of the stat imports even on // a list-only entity whose base import no longer carries them → the icon // was imported nowhere (TS2304). const LUCIDE_BASE = new Set(baseLucideIcons) const statIconImports = [...statIconSet] .filter(n => !LUCIDE_BASE.has(n) && !customIconImports.includes(`, ${n}`)) .sort() .map(n => `, ${n}`) .join('') // ─── Density + business empty state (plan UI 2.4) ──────────────────────── const densityProp = (uiList?.density ?? spec.pageSpec?.density) === 'compact' ? `compact ` : '' const es = (spec.pageSpec?.emptyState || uiList?.emptyState) ? { ...spec.pageSpec?.emptyState, ...uiList?.emptyState } : undefined const emptyIconName = es?.icon ? kebabToPascal(es.icon) : 'List' const emptyTitleExpr = `t('${eLower}.${es?.titleKey ?? 'list.empty'}')` const emptyDescriptionProp = es?.descriptionKey ? ` emptyDescription={t('${eLower}.${es.descriptionKey}')}` : '' const emptyActionProp = es?.withCreate && navCreate ? ` emptyAction={ }` : '' if (es?.withCreate && !navCreate) { ctx.warnings?.push( `scaffold-component: ${plural}ListPage emptyState.withCreate authored but the entity has no form sibling — CTA omitted.`, ) } const emptyIconImport = !LUCIDE_BASE.has(emptyIconName) && !customIconImports.includes(`, ${emptyIconName}`) && !statIconSet.has(emptyIconName) ? `, ${emptyIconName}` : '' // ─── Cards representation (viewModes — plan UI 2.2) ────────────────────── // 'cards' adds a gallery view toggled through a SegmentedControl — the // SmartCard screen of a list section folds into the list pagespec (mirror // of the kanban rule: a representation, never its own route). Card anatomy // derives from the columns' priorities (title = first always column, // badge = the status column, subtitle = next always/high, meta = up to 3 // more default-visible columns), each cell re-using the table's OWN value // expression (cardExprs) so the two representations can never drift. // The cards branch is ALWAYS emitted: a list must have a narrow-screen // representation, and hiding columns to make a table fit amputates the data // silently. `viewModes` now governs only whether the MANUAL toggle is offered // on a wide screen -- the automatic switch does not need it. const cardsToggleAuthored = requestedViewModes.includes('cards') const cardsEnabled = true let viewModeStateDecl = '' let viewModeToggleJsx = '' let cardsBranchJsx = '' let cardsImports = '' let cardIconImports = '' if (cardsEnabled) { const initialMode = (uiList?.viewMode ?? spec.pageSpec?.defaultViewMode) === 'cards' ? 'cards' : 'table' const visibleIdx = columnSource.map((_c, i) => i).filter(i => !defaultHiddenIdx.has(i)) // Card anatomy: the /ui-design overlay (cardFields, by column key) wins; // an unknown key warns and falls back to the priority derivation. const idxOfKey = (key: string | undefined, slot: string): number | undefined => { if (key === undefined) return undefined const i = columnSource.findIndex(c => c.name === fieldToCamel(key)) if (i === -1) { ctx.warnings?.push( `scaffold-component: ${plural}ListPage uiDesign.list.cardFields.${slot} '${key}' matches no declared column — derived fallback used.`, ) return undefined } return i } const cf = uiList?.cardFields const statusIdx = idxOfKey(cf?.badgeKey, 'badgeKey') ?? visibleIdx.find(i => isStatusFieldName(columnSource[i].name)) const titleIdx = idxOfKey(cf?.titleKey, 'titleKey') ?? visibleIdx.find(i => alwaysIdx.has(i) && i !== statusIdx) ?? visibleIdx.find(i => i !== statusIdx) ?? 0 const subtitleIdx = idxOfKey(cf?.subtitleKey, 'subtitleKey') ?? visibleIdx.find( i => i !== titleIdx && i !== statusIdx && (alwaysIdx.has(i) || colPriority(i) === 'high'), ) const metaFromOverlay = cf?.metaKeys ?.map(k => idxOfKey(k, 'metaKeys')) .filter((i): i is number => i !== undefined && i !== titleIdx && i !== statusIdx && i !== subtitleIdx) const metaIdx = metaFromOverlay?.length ? metaFromOverlay.slice(0, 3) : visibleIdx.filter(i => i !== titleIdx && i !== statusIdx && i !== subtitleIdx).slice(0, 3) const exprOf = (i: number) => cardExprs.get(columnSource[i].name) ?? `{formatCellValue(item.${columnSource[i].name}, '${columnSource[i].name}')}` const usedExprs = [titleIdx, statusIdx, subtitleIdx, ...metaIdx] .filter((i): i is number => i !== undefined).map(exprOf).join('\n') const cardMeta = metaIdx.map(i => `
{t('${eLower}.${columnSource[i].labelKey}')}
${exprOf(i)}
`).join('\n') viewModeStateDecl = `\n const [pinnedView${cardsToggleAuthored ? ', setViewMode' : ''}] = useListParamOpt<'table' | 'cards'>('view'${initialMode === 'cards' ? ", 'cards'" : ''})` viewModeToggleJsx = !cardsToggleAuthored ? '' : `
setViewMode(v as 'table' | 'cards')} />
` cardsBranchJsx = `viewMode === 'cards' ? (
{isLoading && Array.from({ length: 6 }).map((_, skIndex) => (
))} {!isLoading && filtered.map((item) => ( ))} {!isLoading && filtered.length === 0 && (
} title={${emptyTitleExpr}}${es?.descriptionKey ? ` description={t('${eLower}.${es.descriptionKey}')}` : ''} />
)}
{totalCount}
{page} / {Math.max(1, Math.ceil(totalCount / pageSize))}
) : ` cardsImports = [ `\nimport { EmptyState } from '@/components/ui/EmptyState'`, `\nimport { Skeleton } from '@/components/ui/Skeleton'`, usedExprs.includes('formatCellValue(') ? `\nimport { formatCellValue } from '@/components/ui/DataTable'` : '', ].join('') cardIconImports = ', ChevronLeft, ChevronRight' } // ─── Kanban branch (the board viewMode — see the fold block at the top) ── // Everything below emits '' when the board is not enabled, so a spec // without the kanban fold renders byte-identical legacy output. let kanbanViewStateDecl = '' let kanbanModuleConsts = '' let kanbanImports = '' let kanbanStateDecl = '' let kanbanLogicDecls = '' let kanbanBranchJsx = '' if (kanbanEnabled) { const kb = kanbanResolved! const statusCamel = kb.statusField const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") const fallbackTitle = fieldToCamel(spec.fields.find(f => !f.formula)?.name ?? 'id') const titleCamel = kb.titleField ?? fallbackTitle // DnD requires the `move` action's mutation hook — a dndCards:true with // no action would import a hook scaffold-api-client never emitted. const moveAction = ((spec.pageSpec?.actions ?? []) as Array<{ code?: string; permission?: string }>) .find(a => a.code === 'move') let dnd = kanbanDndEnabled(kb, moveAction !== undefined) if (dnd && moveAction === undefined) { ctx.warnings?.push( `scaffold-component: ${plural}ListPage kanban dndCards is on but the pagespec declares no 'move' action — ` + `no mutation hook exists, board rendered read-only (author the action or re-run derive-kanban-spec).`, ) dnd = false } const movePerm = moveAction?.permission ?? `${permKey}.update` const hasMatrix = kb.transitions !== undefined const hasTerminal = kb.terminal.size > 0 // Column tones map onto the theme status token families — never a hex. const TONE_BORDER: Record = { neutral: 'border-t-[var(--border-color)]', info: 'border-t-[var(--info-border)]', success: 'border-t-[var(--success-border)]', error: 'border-t-[var(--error-border)]', warning: 'border-t-[var(--warning-border)]', } const columnsLiteral = kb.columns .map(c => ` { key: '${esc(c.key)}', labelKey: '${esc(c.labelKey)}', toneClass: '${TONE_BORDER[c.tone]}', initiallyHidden: ${c.initiallyHidden} }`) .join(',\n') const terminalLiteral = [...kb.terminal].sort().map(k => `'${esc(k)}'`).join(', ') const canDropToDecl = dnd ? ` const canDropTo = (from: string, target: string): boolean => from !== target${hasTerminal ? ' && !TERMINAL_COLUMNS.has(from)' : ''}${hasMatrix ? ' && (ALLOWED_TRANSITIONS[from] ?? []).includes(target)' : ''} ` : '' kanbanModuleConsts = ` const KANBAN_FETCH_SIZE = 200 const KANBAN_COLUMNS = [ ${columnsLiteral}, ] as const ${hasMatrix ? ` // BR Flow matrix (PRD-135) — drops outside these edges are refused natively; // the backend 'move' guard compiles the SAME matrix (scaffold-business). const ALLOWED_TRANSITIONS: Record = ${allowedTransitionsLiteral(kb.transitions!)} ` : ''}${hasTerminal ? ` // Closed states — their cards are never draggable out. const TERMINAL_COLUMNS: ReadonlySet = new Set([${terminalLiteral}]) ` : ''}${canDropToDecl}` kanbanImports = [ `\nimport { useKanbanColumnPrefs } from '@/components/ui/useKanbanColumnPrefs'`, hasColumnPicker ? '' : `\nimport { ColumnPicker } from '@/components/ui/ColumnPicker'`, dnd ? `\nimport { useAuth } from '@/business/auth/useAuth'` : '', ].join('') // The view param must be read BEFORE the data hook (the board changes the // fetch size) — so the kanban path declares it here and suppresses the // legacy post-hook declaration. const initial = uiList?.viewMode ?? spec.pageSpec?.defaultViewMode const initParam = initial === 'cards' ? ", 'cards'" : initial === 'kanban' ? ", 'kanban'" : '' kanbanViewStateDecl = ` const [pinnedView, setViewMode] = useListParamOpt<'table' | 'cards' | 'kanban'>('view'${initParam}) const boardActive = pinnedView === 'kanban' ` viewModeStateDecl = '' const toggleOptions = [ `{ value: 'table', label: t('${eLower}.list.viewTable', { defaultValue: 'Table' }) }`, ...(cardsToggleAuthored ? [`{ value: 'cards', label: t('${eLower}.list.viewCards', { defaultValue: 'Cards' }) }`] : []), `{ value: 'kanban', label: t('${eLower}.list.viewKanban', { defaultValue: 'Kanban' }) }`, ] viewModeToggleJsx = `
setViewMode(v as 'table' | 'cards' | 'kanban')} />
` kanbanStateDecl = ` const kanbanPrefs = useKanbanColumnPrefs('ss.kanban.v1.${spec.appCode}.${spec.module}.${spec.section}.${eLower}', KANBAN_COLUMNS) ${dnd ? ` const { hasPermission } = useAuth() const canMoveCards = hasPermission('${movePerm}') const [draggedCard, setDraggedCard] = useState<{ id: string; from: string } | null>(null) const [pendingMoves, setPendingMoves] = useState>(new Map()) const [moveError, setMoveError] = useState(false) ` : ''}` // Card cell expressions re-use the table's OWN value expressions // (cardExprs) so the board can never drift from its table sibling. const boardExprOf = (name: string) => cardExprs.get(name) ?? `{formatCellValue(item.${name}, '${name}')}` const boardNames = [titleCamel, ...(kb.subtitleField ? [kb.subtitleField] : []), ...kb.cardFields] if (boardNames.some(n => !cardExprs.has(n)) && !cardsImports.includes('formatCellValue')) { cardsImports += `\nimport { formatCellValue } from '@/components/ui/DataTable'` } kanbanLogicDecls = ` const orderedKanbanColumns = kanbanPrefs.order .map((key) => KANBAN_COLUMNS.find((c) => c.key === key)) .filter((c): c is (typeof KANBAN_COLUMNS)[number] => c !== undefined && !kanbanPrefs.hiddenKeys.has(c.key)) const kanbanBuckets = useMemo(() => { const map = new Map() for (const col of KANBAN_COLUMNS) map.set(col.key, []) const unassigned: ${e}ListDto[] = [] for (const item of data?.items ?? []) { const key = ${dnd ? 'pendingMoves.get(item.id) ?? ' : ''}String((item as unknown as Record)['${statusCamel}'] ?? '') const target = map.get(key) if (target) target.push(item) else unassigned.push(item) } return { map, unassigned } }, [data${dnd ? ', pendingMoves' : ''}]) ${dnd ? ` const handleCardDrop = async (id: string, from: string, target: string) => { if (!canDropTo(from, target)) return // Optimistic overlay: the card sits in its target column while the move // posts; a failure rolls it back and surfaces the error banner. setPendingMoves((prev) => new Map(prev).set(id, target)) try { await moveMutation.mutateAsync({ id, payload: { ${statusCamel}: target } as never }) await refetch() } catch { setMoveError(true) } finally { setPendingMoves((prev) => { const next = new Map(prev); next.delete(id); return next }) } } ` : ''}` const cardDragProps = dnd ? ` draggable={canMoveCards${hasTerminal ? ' && !TERMINAL_COLUMNS.has(col.key)' : ''}} onDragStart={(event) => { event.dataTransfer.setData('text/plain', item.id); setDraggedCard({ id: item.id, from: col.key }) }} onDragEnd={() => setDraggedCard(null)}` : '' // Column reorder rides its OWN dataTransfer type so a header drag can // never be mistaken for a card drop (types are readable during dragover, // payloads are not). const columnDropProps = ` onDragOver={(event) => { if (event.dataTransfer.types.includes('application/x-ss-column')) { event.preventDefault(); return }${dnd ? ` if (draggedCard && canDropTo(draggedCard.from, col.key)) event.preventDefault()` : ''} }} onDrop={(event) => { const colKey = event.dataTransfer.getData('application/x-ss-column') if (colKey) { event.preventDefault(); kanbanPrefs.moveColumn(colKey, col.key); return }${dnd ? ` event.preventDefault() const id = event.dataTransfer.getData('text/plain') if (draggedCard && id) void handleCardDrop(id, draggedCard.from, col.key) setDraggedCard(null)` : ''} }}` const columnDragFeedback = dnd ? `\${draggedCard && !canDropTo(draggedCard.from, col.key) ? ' opacity-50' : ''}\${draggedCard && canDropTo(draggedCard.from, col.key) ? ' ring-1 ring-[var(--color-accent-500)]' : ''}` : '' const columnAriaProps = dnd ? ` aria-disabled={draggedCard ? !canDropTo(draggedCard.from, col.key) : undefined} title={draggedCard && !canDropTo(draggedCard.from, col.key) ? t('${eLower}.kanban.moveNotAllowed') : undefined}` : '' const cardMetaJsx = kb.cardFields.map(name => { const col = columnSource.find(c => c.name === name) const labelExpr = col ? `t('${eLower}.${col.labelKey}')` : `t('${eLower}.list.columns.${name}', { defaultValue: '${esc(humanize(name))}' })` return `
{${labelExpr}}
${boardExprOf(name)}
` }).join('\n') const cardBodyJsx = `
${boardExprOf(titleCamel)}
${kb.subtitleField ? `
${boardExprOf(kb.subtitleField)}
` : ''}${cardMetaJsx ? `
${cardMetaJsx}
` : ''}` const cardClickHandler = navDetail ? `onClick={() => navigate(routes.${sectionCamel}.detail(item.id))}` : 'onClick={() => { /* no detail view for this entity */ }}' kanbanBranchJsx = `boardActive ? (
({ key: c.key, label: t(\`${eLower}.\${c.labelKey}\`), visible: !kanbanPrefs.hiddenKeys.has(c.key) }))} onToggle={kanbanPrefs.toggleColumn} onReset={kanbanPrefs.reset} label={t('${eLower}.list.columnsPicker.button')} title={t('${eLower}.list.columnsPicker.title')} resetLabel={t('${eLower}.list.columnsPicker.reset')} />
${dnd ? ` {moveError && (
{t('${eLower}.kanban.moveError')}
)} ` : ''} {totalCount > filtered.length && (
{t('${eLower}.kanban.truncated', { shown: filtered.length, total: totalCount })}
)}
{orderedKanbanColumns.map((col) => (
event.dataTransfer.setData('application/x-ss-column', col.key)} title={t('${eLower}.kanban.reorderColumn')} >

{t(\`${eLower}.\${col.labelKey}\`)} ({kanbanBuckets.map.get(col.key)?.length ?? 0})

{(kanbanBuckets.map.get(col.key) ?? []).map((item) => ( ))}
))} {kanbanBuckets.unassigned.length > 0 && (

{t('${eLower}.kanban.unassigned')} ({kanbanBuckets.unassigned.length})

{kanbanBuckets.unassigned.map((item) => ( ))}
)}
) : ` } // Table or cards, decided on the MEASURED container against the surviving // columns' width budget -- never on a viewport breakpoint, and never on // window.innerWidth (the pane can be 256-320px narrower than the window). const representationDecl = ` const { mode: viewMode, hostRef: listHostRef } = useListRepresentation(columns, {${ hasColumnPicker ? ' hiddenColumnKeys,' : '' } pinned: ${kanbanEnabled ? `pinnedView === 'kanban' ? undefined : pinnedView` : 'pinnedView'} }) ` const representationImport = `\nimport { useListRepresentation } from '@/components/ui/tableRepresentation'` // One SegmentedControl import serves the cards toggle AND the segments row. const segmentedControlImport = (cardsToggleAuthored || segmentsEnabled || kanbanEnabled) && !filterPrimitiveImports.includes('SegmentedControl') ? `\nimport { SegmentedControl } from '@/components/ui/SegmentedControl'` : '' let segmentsStateDecl = '' let segmentsJsx = '' if (segmentsEnabled) { const segLiteral = declaredSegments.map(g => g.filter ? `{ key: '${g.key}', labelKey: '${g.labelKey}', filter: { field: '${g.filter.field}', value: ${JSON.stringify(g.filter.value)} } }` : `{ key: '${g.key}', labelKey: '${g.labelKey}' }`).join(', ') segmentsStateDecl = ` const SEGMENTS: Array<{ key: string; labelKey: string; filter?: { field: string; value: string | number | boolean } }> = [${segLiteral}] const [segment, setSegment] = useListParam('seg', '${declaredSegments[0].key}') const activeSegment = SEGMENTS.find((s) => s.key === segment) ` const segOptions = declaredSegments .map(g => `{ value: '${g.key}', label: t('${eLower}.${g.labelKey}') }`) .join(', ') segmentsJsx = `
{ setSegment(v); setPage(1) }} />
` } // URL-state hook imports (plan UI 3.1) — exactly the hooks the page uses. const listStateHooks = new Set(['useListNumberParam', 'useListParam', 'useListParamOpt']) if (serverFilteredList) listStateHooks.add('useListRecordParam') const listStateImport = listStateHooks.size ? `\nimport { ${[...listStateHooks].sort().join(', ')} } from '@/components/ui/useListState'` : '' const savedViewsImport = savedViewsJsx ? `\nimport { SavedViewsMenu } from '@/components/ui/SavedViewsMenu'` : '' // Conditional navigation imports/decl (an unused import or local fails the // strict type-check — same pattern as render/home.ts): a list-only entity // with no navigate-kind action renders NOTHING that navigates, so // `useNavigate`, `routes` and the `navigate` binding are all omitted. const hasNavigateActions = customActions.some(a => isNavigate(a)) const needsNav = navCreate || navEdit || navDetail || hasNavigateActions const navigateImport = needsNav ? `import { useNavigate } from 'react-router-dom'\n` : '' const routesImport = needsNav ? `import { routes } from '@/extensions/${extensionsModuleId(spec.appCode, spec.module)}Routes'\n` : '' const navigateDecl = needsNav ? '\n const navigate = useNavigate()' : '' files.push({ path: pathFor('list', listFileName), content: `${GENERATED_MARKER}${reactImport}${navigateImport}import { useTranslation } from 'react-i18next' import { ${baseLucideIcons.join(', ')}${customIconImports}${statIconImports}${cardIconImports}${emptyIconImport} } from 'lucide-react' import { Slot${onlineStatusImport}${listDateImport}${mobileKitImport} } from '@atlashub/smartstack' import { PermissionGuard } from '@/components/auth/PermissionGuard' import { PageTemplate } from '@/components/ui/PageTemplate' import { ResponsiveDataTable, type ResponsiveColumn } from '@/components/ui/ResponsiveDataTable'${representationImport}${listStateImport}${savedViewsImport}${statCardPageImport}${segmentedControlImport}${cardsImports}${kanbanImports} import { FilterBar } from '@/components/ui/FilterBar'${filterPrimitiveImports}${columnPickerImports} ${outboxChipImport}${rowActionsMenuImport}${headerActionsMenuImport}${customDialogImport}${routesImport}import { use${plural}, useDelete${e}${customHookImports} } from '${featurePath}/hooks/use${e}'${listFkHookImports} import type { ${e}ListDto } from '${featurePath}/types' ${kanbanModuleConsts} export function ${plural}ListPage() { const { t } = useTranslation('${spec.module}')${navigateDecl} ${filterStateBlock}${segmentsStateDecl}${kanbanViewStateDecl} const { data, isLoading, error, refetch } = use${plural}(${listHookArgs})${statHookDeclsJsx}${viewModeStateDecl} const deleteMutation = useDelete${e}()${listHasFkColumns ? '\n' + listFkHookDecls : ''}${onlineStatusDecl}${viewportModeDecl} ${customMutationDecls ? customMutationDecls + '\n' : ''}${customDialogStateDecls ? customDialogStateDecls + '\n' : ''}${bulkSelectionStateDecl}${filterChipsBlock}${columnVisibilityBlock}${kanbanStateDecl} // Re-read the server after any mutation: the list is server-driven (a // default filter can even add/remove the mutated row) and the KPI stats // count the same entity — without this the screen keeps its pre-mutation // state until a manual reload. const refetchScreen = () => { refetch()${statRefetchCalls} } const handleDelete = async (item: ${e}ListDto) => { await deleteMutation.mutateAsync(item.id) refetchScreen() } ${customHandlerDecls ? '\n' + customHandlerDecls + '\n' : ''} ${filteredItemsBlock}${kanbanLogicDecls} const columns: ResponsiveColumn<${e}ListDto>[] = [ ${columnEntries}, { key: '__actions', label: t('${eLower}.list.actionsColumn'), align: 'right', sticky: 'right', render: (item) => (
${navEdit ? ` ` : ''} ${customRowActionsJsx}
), }, ] ${representationDecl} return ( } breadcrumbs={[{ label: t('${eLower}.breadcrumb.section') }]} actions={
${outboxChipJsx}${navCreate ? ` ` : ''} ${customHeaderActionsJsx}
} > ${offlineStaleBanner} {error && (
{t('${eLower}.list.error')}: {String(error)}
)} ${statsRowJsx}${segmentsJsx}${filterBarJsx}${viewModeToggleJsx} ${bulkToolbarJsx}
{${mobileEmptyBranch}${kanbanBranchJsx}${cardsBranchJsx}( data={filtered} columns={columns} loading={isLoading} ${tableVisibilityProps}${tableSelectionProps}${tableServerProps}${tableSearchProps}${densityProp}pagination={${tablePaginationProp}} getRowKey={(item) => item.id} emptyMessage={${emptyTitleExpr}} emptyIcon={<${emptyIconName} />}${emptyDescriptionProp}${emptyActionProp} ${navDetail ? `onRowClick={(item) => navigate(routes.${sectionCamel}.detail(item.id))}` : '/* no detail view for this entity — rows are not clickable */'} /> )}
${customDialogsJsx ? '\n' + customDialogsJsx : ''}${mobileFabJsx}
) } export default ${plural}ListPage `, }) } return files }