/** * cli:scaffold-component — render/detail.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 { parseRelatedTabs, relatedAppOf, relatedExtensionsId, relatedPluralOf, relatedRouteFamilyOf, relatedTabPlacementOf, requiresAvailabilityGuard, type PageRelatedTab } from '../../../../../../lib/page-spec-related-tabs.js' import { resolveSections, type ResolvedSection } from '../../../../../../lib/page-spec-sections.js' import { lifecycleVisibleWhen, resolveLifecycle } from '../../../../../../lib/page-spec-lifecycle.js' import { FormControl, GENERATED_MARKER, applyUiDesignOverlay, compileVisibleWhen, controlOf, coreNonStandardLookup, fieldToCamel, formDataTypeLiteral, humanize, isEditable, isFkField, lookupHookImportLines, renderFormSections, renderReadValueExpr, resolveFicheSectionKeys, toCamel } from './shared.js' import { pickUiDesignOverlay, resolveEditMode } from '../../../../../../lib/ui-design-overlay.js' import { resolveEditSurface } from '../../../../../../lib/edit-surface.js' import type { RenderContext } from './context.js' export function renderDetail(rc: RenderContext): GeneratedFile[] { const files: GeneratedFile[] = [] const { spec, ctx, allCustomActions, dialogParamsExpr, e, eLower, entityViews, featurePath, formFields, formLayout, headerTitleExpr, hookCodeOf, iconForAction, initialFormObj, isApi, isNavigate, isPayloadAction, navEdit, offlineLevel, onlineStatusDecl, onlineStatusImport, outboxChipImport, outboxChipJsx, pathFor, permKey, renderHeaderActionsCluster, sectionCamel, sectionMeta, toPascal, uiOverlay, versioned } = rc // ─── DetailPage ────────────────────────────────────────────────────────── if (spec.views.includes('detail')) { // Tab support: pageSpec.tabs[] groups the entity's OWN fields into panels; // pageSpec.relatedTabs[] adds 360 tabs embedding entities IN RELATION // (Client ⟷ Factures), each fetching through the FK filter // (`?{relationFk}={id}`) and gated by its own PermissionGuard. Both // collapse into ONE URL-synced strip (?tab=key). Otherwise render flat. type TabMeta = { key: string; labelKey?: string; label?: string; fields?: string[] } const fieldTabsRaw = (spec.pageSpec?.tabs ?? []) as TabMeta[] const { tabs: relatedTabs, rejected: rejectedRelatedTabs } = parseRelatedTabs(spec.pageSpec?.relatedTabs) if (rejectedRelatedTabs.length > 0) { // Fail loud: a malformed related tab silently dropped is exactly the // drift PRD-104 / DEV-UI-031 exist to prevent. ba-develop auto-heal // routes this message back to the pagespec. throw new Error( `scaffold-component: entity '${e}' has invalid pageSpec.relatedTabs entries — ` + rejectedRelatedTabs.map(r => `#${r.index}: ${r.issues.join('; ')}`).join(' | '), ) } // ── Band/strip partition (placement — lib relatedTabPlacementOf) ──────── // `summary` tabs default to the BAND: always-mounted count cartouches in a // row above the strip, occupying NO tab trigger. An explicit // `placement: 'tab'` opts a summary back into the strip (rare). const bandTabs = relatedTabs.filter(t => relatedTabPlacementOf(t) === 'band') const stripTabs = relatedTabs.filter(t => relatedTabPlacementOf(t) === 'tab') const hasStripRelated = stripTabs.length > 0 // ── Unified fiche (« fiche unique, édition en place ») ────────────────── // The view-set carries detail AND form without the 'direct' opt-out → this // DetailPage IS the edit surface: the entity's own fields render as // read-first section cards toggling to edit in place (the tabs[] grouping // seeds the sections through resolveSections), the FormPage shrinks to // create-only, and `/edit` routes here with every section opened. const unified = resolveEditSurface(entityViews, resolveEditMode(spec.pageSpec, uiOverlay)) === 'unified' // STRIP-placed related tabs without field tabs: the entity's own fields // regroup under a synthetic "info" tab so the strip always has a home for // the fiche itself. A band-only page gets NO synthetic tab and NO strip. // Unified fiche: NO field tabs at all — the strip carries only the 360 // related tabs, the own fields live above it as section cards. const detailTabs: TabMeta[] = unified ? [] : hasStripRelated && fieldTabsRaw.length === 0 ? [{ key: 'info', labelKey: 'detail.tabs.info' }] : fieldTabsRaw // The collision guard scans ALL related tabs (band included): a band key // still owns the i18n family and the child-component name, and flipping // its placement back must never surface a new collision. const dupTabKeys = relatedTabs.filter(rt => detailTabs.some(ft => ft.key === rt.key)).map(rt => rt.key) if (dupTabKeys.length > 0) { throw new Error(`scaffold-component: entity '${e}' declares tab keys used by BOTH tabs[] and relatedTabs[]: ${dupTabKeys.join(', ')}`) } const hasDetailTabs = detailTabs.length > 0 || hasStripRelated // A tab's fields[] are pagespec keys (camelCase) while spec.fields carry // the entité.md casing (PascalCase in the real pipeline) — membership is // camel-insensitive on BOTH sides, like resolveSections. A strict compare // here shipped every tabpanel with an empty
(77 declared fields, zero // rendered) because the two casings never met. const resolveTabFields = (tab: TabMeta): ComponentField[] => { if (!tab.fields) return spec.fields const wanted = new Set(tab.fields.map(fieldToCamel)) return spec.fields.filter(f => wanted.has(fieldToCamel(f.name))) } // OWN fields the detail body actually renders: the union of the tab panels // when tabs[] partition the fiche, every field otherwise (sectioned and // flat bodies render them all). The FK lookup hooks below are scoped to // this set — a hook for an unrendered FK is a dead binding (TS6133 under // noUnusedLocals) that pays a lookup request for nothing. const detailRenderedNames: ReadonlySet = !unified && hasDetailTabs ? new Set(detailTabs.flatMap(t => resolveTabFields(t).map(f => f.name))) : new Set(spec.fields.map(f => f.name)) const detailExtraImport = `${hasDetailTabs ? ', useSearchParams' : ''}${unified ? ', useLocation' : ''}` // Data (columns / display field) for each related tab — supplied by // ba-develop Phase 3a from the RELATED entity's list pagespec. A missing // entry fails open on a single createdAt column (validate-page warns). const relatedDataByKey = new Map((spec.relatedTabsData ?? []).map(d => [d.key, d])) // The routes registry a tab navigates through. Same app + same module → the page's own // `routes`; same app, another module → the historical `{module}Routes` (output stays // byte-identical for every pre-existing pagespec); ANOTHER application → the app-scoped // `{app}{Module}Routes`, because two applications may ship the same module code and a // module-only name would collide on the second import. const relatedRoutesExpr = (tab: PageRelatedTab) => { const app = relatedAppOf(tab, spec.appCode) if (app !== spec.appCode.toLowerCase()) return `${toCamel(relatedExtensionsId(tab, spec.appCode))}Routes` return tab.relatedModule === spec.module ? 'routes' : `${toCamel(tab.relatedModule)}Routes` } // ── 360 related-tab ROUTING resolution (the sub-view mis-routing guard) ── // The family a tab navigates through is DATA: pagespec `relatedRouteFamily` // with the legacy `relatedSection` fallback (lib/relatedRouteFamilyOf). It // is verified against the target module's generated *Routes.ts when // readable (ctx.routesFamilies): an unknown family is a HARD error — // emitting a navigation that happens to compile against another family // (the porteur's) is exactly the silent mis-routing this gate closes. // Create button and row-click are then emitted only when the create()/ // detail() helpers actually resolve (Routes.ts first, relatedTabsData // signals as the pre-routes fallback, legacy `true` + warning when no // signal exists at all). interface RelatedTabRouting { familyCamel: string routesExpr: string withCreate: boolean withRowOpen: boolean createPerm: string } const relatedRouting = new Map() for (const tab of relatedTabs) { const familyCamel = toCamel(relatedRouteFamilyOf(tab)) const modFamilies = ctx.routesFamilies?.[relatedExtensionsId(tab, spec.appCode)] let helpers: string[] | undefined if (modFamilies !== undefined) { helpers = modFamilies[familyCamel] if (helpers === undefined) { throw new Error( `scaffold-component: related tab '${tab.key}' navigates through route family '${familyCamel}' ` + `but src/extensions/${relatedExtensionsId(tab, spec.appCode)}Routes.ts declares no such family ` + `(available: ${Object.keys(modFamilies).join(', ') || '(none)'}). ` + `Fix the pagespec (relatedRouteFamily must copy the target list pagespec's routeFamily) ` + `or re-run scaffold-routes for module '${tab.relatedModule}'.`, ) } } else { ctx.warnings?.push( `related tab '${tab.key}': ${relatedExtensionsId(tab, spec.appCode)}Routes.ts not loaded — ` + `family '${familyCamel}' unverified (run scaffold-routes, then re-scaffold to verify)`, ) } const tabData = relatedDataByKey.get(tab.key) const canCreate = helpers !== undefined ? helpers.includes('create') : tabData?.hasCreateForm const canOpen = helpers !== undefined ? helpers.includes('detail') : tabData?.hasDetail let withCreate: boolean if (tab.withCreate === false) { withCreate = false } else if (tab.withCreate === true) { if (canCreate === false) { throw new Error( `scaffold-component: related tab '${tab.key}' declares withCreate: true but no create form resolves for ${tab.relatedEntity} ` + `(${helpers !== undefined ? `family '${familyCamel}' has no create() helper` : 'relatedTabsData.hasCreateForm is false'}). ` + `Author "withCreate": false in the pagespec (audit journal / business-flow satellite) or scaffold the create form first.`, ) } withCreate = true } else if (canCreate !== undefined) { withCreate = canCreate } else { withCreate = true // legacy default — no explicit value, no signal if (tab.displayMode !== 'summary') { ctx.warnings?.push( `related tab '${tab.key}': withCreate unresolved (no Routes.ts family, no relatedTabsData.hasCreateForm) — legacy default true`, ) } } let withRowOpen: boolean if (tab.withRowOpen === false) { withRowOpen = false } else if (tab.withRowOpen === true) { if (canOpen === false) { throw new Error( `scaffold-component: related tab '${tab.key}' declares withRowOpen: true but no detail surface resolves for ${tab.relatedEntity} ` + `(${helpers !== undefined ? `family '${familyCamel}' has no detail() helper` : 'relatedTabsData.hasDetail is false'}). ` + `Author "withRowOpen": false (inert rows) or scaffold the detail page first.`, ) } withRowOpen = true } else if (canOpen !== undefined) { withRowOpen = canOpen } else { withRowOpen = true // legacy default } // Create permission: REUSED from the target list page's own create // action (pagespec tab, else Phase 3a's relatedTabsData copy) — the // recomposition can't guess resource-scoped shapes like // `portfolio.list.work-package.create`. const createPerm = tab.createPermission ?? tabData?.createPermission ?? `${tab.relatedModule}.${tab.relatedSection}.create` if ( withCreate && tab.displayMode !== 'summary' && tab.createPermission === undefined && tabData?.createPermission === undefined && tab.relatedRouteFamily !== undefined && tab.relatedRouteFamily !== tab.relatedSection ) { ctx.warnings?.push( `related tab '${tab.key}': create permission recomposed as '${createPerm}' on a SUB-VIEW target — likely wrong ` + `(real shape is often '{module}.{section}.{resource}.create'). Author createPermission on the pagespec tab.`, ) } relatedRouting.set(tab.key, { familyCamel, routesExpr: relatedRoutesExpr(tab), withCreate, withRowOpen, createPerm, }) } // Package datetime helpers actually used by the detail page (own fields, field // tabs and related tabs) — drives the '@atlashub/smartstack' import line. const detailDateFns = new Set() // Lifecycle on the READ surface: an owned later-phase field's row renders // only while the record's status is in the phase (guard compiled over // `data`) — a draft invoice's detail shows no "Payée le" row, mirroring the // form. Only status-anchored phases guard; a statuses-less phase renders as // today (em dash on empty). Resolved over ALL fields (the detail shows // computed fields too) so the effects cover every branch below. const detailLifecycle = resolveLifecycle(spec.fields, spec.pageSpec) const detailRowGuard = (f: ComponentField): string | null => { const eff = detailLifecycle.effects.get(fieldToCamel(f.name)) if (!eff?.owned || !(eff.statuses?.length) || !detailLifecycle.statusFieldOnForm) return null return compileVisibleWhen(lifecycleVisibleWhen(detailLifecycle.statusField!, eff.statuses), 'data') } function renderFieldDl(fields: typeof spec.fields, skipLifecycleGuards = false): string { return fields.map(f => { const camel = fieldToCamel(f.name) // Value resolution shared with the read-first form grid // (renderReadValueExpr): enum/multiselect → option label, dates → // platform datetime module (collected into detailDateFns), FK → // lookup displayName, never the raw Guid. const valueExpr = renderReadValueExpr(f, 'data', eLower, detailDateFns) const item = `
{t('${eLower}.detail.fields.${camel}')}
${valueExpr}
` const guard = skipLifecycleGuards ? null : detailRowGuard(f) return guard ? ` {(${guard}) && (\n${item}\n )}` : item }).join('\n') } // First-order sections on the DETAIL: same membership/metadata resolution // as the form, WITHOUT the tabs seed (on a detail page `tabs[]` are panels, // not section groups). The uiDesign per-field overrides still win, and the // i18n keys are SHARED with the form (`form.section.`) — one label // per section, two renders. const { fields: detailSeededFields, order: detailSectionMeta } = resolveSections( spec.fields, { sections: spec.pageSpec?.sections }, uiOverlay) const detailFields = applyUiDesignOverlay(detailSeededFields, uiOverlay) const detailMetaByKey = new Map(detailSectionMeta.map(m => [m.key, m] as const)) const hasDetailSections = detailFields.some(f => (f.section ?? '').trim() !== '') const detailResolvedByName = new Map(detailFields.map(f => [f.name, f] as const)) /** Group fields by resolved section — metadata-ordered keys first, then * first appearance (same contract as renderFormSections). */ function groupDetailFields(fields: typeof spec.fields): Array<{ key: string; fields: typeof spec.fields }> { const firstAppearance: string[] = [] const groups = new Map() for (const f of fields) { const key = (f.section ?? '').trim() if (!groups.has(key)) { groups.set(key, []); firstAppearance.push(key) } groups.get(key)!.push(f) } const order: string[] = [] const seen = new Set() for (const m of detailSectionMeta) { if (groups.has(m.key) && !seen.has(m.key)) { order.push(m.key); seen.add(m.key) } } for (const k of firstAppearance) { if (!seen.has(k)) { order.push(k); seen.add(k) } } return order.map(key => ({ key, fields: groups.get(key)! })) } function detailSectionTitleExprs(key: string): { labelKeyExpr: string; fallbackLabel: string; description?: string } { const meta = detailMetaByKey.get(key) return { labelKeyExpr: meta?.labelKey ? `${eLower}.${meta.labelKey}` : `${eLower}.form.section.${toCamel(key)}`, fallbackLabel: (meta?.label ?? humanize(key)).replace(/'/g, "\\'"), description: meta?.description, } } // ── Tenant-catalogue availability (the cross-module 360 guard) ────────── // A tab pointing INTO another module — a fortiori another application — can // only be shown where that module was actually delivered to the current // tenant. The signal is the nav catalogue the package already holds // (tenant_TenantApplications / TenantModules), read through the generated // useModuleAvailability primitive. A tab on the page's OWN module is never // guarded: the page itself would be unreachable if its module were missing, // so every pre-existing pagespec keeps its exact output. const guardedTabs = relatedTabs.filter(t => requiresAvailabilityGuard(t, spec.appCode, spec.module)) const availabilityConst = (tab: PageRelatedTab) => `show${e}Related${toPascal(tab.key)}` const availabilityDecls = guardedTabs.length === 0 ? '' : ` // Cross-module 360 surfaces: rendered only where the target module is part of // what THIS tenant was given (nav catalogue, not the license). const moduleAvailability = useModuleAvailability() ${guardedTabs.map(t => ` const ${availabilityConst(t)} = moduleAvailability.hasModule('${relatedAppOf(t, spec.appCode)}', '${t.relatedModule}')`).join('\n')} ` const availabilityImport = guardedTabs.length === 0 ? '' : `import { useModuleAvailability } from '@/components/ui/useModuleAvailability'\n` const guardedKeys = new Set(guardedTabs.map(t => t.key)) let detailTabHooks = '' let detailBodyJsx: string if (hasDetailTabs) { // Unified fiche: no field tabs — the first STRIP-placed related tab is // the default (band cartouches are not activatable). const defaultTab = (detailTabs[0] ?? stripTabs[0]).key const validTabKeys = [...detailTabs.map(t => t.key), ...stripTabs.map(t => t.key)] const stripGuarded = stripTabs.filter(t => guardedKeys.has(t.key)) // Without a guarded trigger the key set is fixed at build time and the legacy // literal is emitted verbatim. With one, the set is only known at render time: // a hidden tab must neither be selectable through ?tab= nor be the fallback, // or the strip would show a body with no trigger to go back to. detailTabHooks = stripGuarded.length === 0 ? ` const [searchParams, setSearchParams] = useSearchParams() // Stale-URL guard: a ?tab= pointing at a key that no longer has a trigger // (e.g. a bookmark to a tab since moved to the band row) falls back to the // default instead of rendering a strip with an empty body. const tabParam = searchParams.get('tab') const activeTab = tabParam !== null && [${validTabKeys.map(k => `'${k}'`).join(', ')}].includes(tabParam) ? tabParam : '${defaultTab}' const switchTab = (tab: string) => { const p = new URLSearchParams(searchParams) tab === '${defaultTab}' ? p.delete('tab') : p.set('tab', tab) setSearchParams(p, { replace: true }) } ` : ` const [searchParams, setSearchParams] = useSearchParams() // Stale-URL guard, extended to the tenant catalogue: a ?tab= pointing at a key // with no trigger — removed from the strip, or belonging to a module this tenant // was not given — falls back to the first tab that IS shown. const visibleTabKeys = [${validTabKeys.map(k => `'${k}'`).join(', ')}].filter( (k) => ${stripGuarded.map(t => `(k !== '${t.key}' || ${availabilityConst(t)})`).join(' && ')} ) const tabParam = searchParams.get('tab') const defaultTabKey = visibleTabKeys[0] ?? '${defaultTab}' const activeTab = tabParam !== null && visibleTabKeys.includes(tabParam) ? tabParam : defaultTabKey const switchTab = (tab: string) => { const p = new URLSearchParams(searchParams) tab === defaultTabKey ? p.delete('tab') : p.set('tab', tab) setSearchParams(p, { replace: true }) } ` const tabButtonJsx = (key: string, labelExpr: string, indent: string) => `${indent}` const fieldTabBarJsx = detailTabs.map(tab => { const label = tab.labelKey ? `t('${eLower}.${tab.labelKey}')` : `'${tab.label ?? tab.key}'` return tabButtonJsx(tab.key, label, ' ') }).join('\n') // Related-tab triggers are permission-gated: an actor who cannot read // the entity in relation never sees the tab (nor fires its fetch). // Only STRIP-placed tabs get a trigger — band cartouches render above. const relatedTabBarJsx = stripTabs.map(tab => { const guard = guardedKeys.has(tab.key) ? availabilityConst(tab) : null const button = tabButtonJsx(tab.key, `t('${eLower}.${tab.labelKey}')`, guard ? ' ' : ' ') const inner = guard ? ` {${guard} && (\n${button}\n )}` : button return ` ${inner} ` }).join('\n') const tabBarJsx = [fieldTabBarJsx, relatedTabBarJsx].filter(Boolean).join('\n') const fieldTabPanelsJsx = detailTabs.map(tab => { const tabFields = resolveTabFields(tab) // Sub-group the tab's fields by their resolved section when at least one // carries one. Headers only (no nested card chrome — the panel IS the // card); the value markup stays renderFieldDl verbatim. const tabFieldsResolved = tabFields.map(f => detailResolvedByName.get(f.name) ?? f) const hasSubSections = tabFieldsResolved.some(f => (f.section ?? '').trim() !== '') const panelInner = hasSubSections ? `
${groupDetailFields(tabFieldsResolved).map(({ key, fields }) => { const { labelKeyExpr, fallbackLabel } = detailSectionTitleExprs(key) const header = key ? `

{t('${labelKeyExpr}', { defaultValue: '${fallbackLabel}' })}

\n` : '' return `
${header}
${renderFieldDl(fields)}
` }).join('\n')}
` : `
${renderFieldDl(tabFields)}
` return ` {activeTab === '${tab.key}' && (
${panelInner}
)}` }).join('\n\n') // Related panels mount their child component ONLY while active — hooks // live inside the child, so an inactive tab never fetches its data. const relatedTabPanelsJsx = stripTabs.map(tab => ` {activeTab === '${tab.key}'${guardedKeys.has(tab.key) ? ` && ${availabilityConst(tab)}` : ''} && (
<${e}Related${toPascal(tab.key)}Tab relatedId={id} />
)}`).join('\n\n') const tabPanelsJsx = [fieldTabPanelsJsx, relatedTabPanelsJsx].filter(Boolean).join('\n\n') // The strip container is the TabStrip primitive (scaffold-ui-primitives): // it owns the two-layer 'underlined tabs' contract of DEV-UI-027 (outer // divider + inner -mb-px row) AND replaces the horizontal scrollbar with // chevron nudge arrows on overflow. Only the triggers stay inline here — // their PermissionGuard wrapping, i18n labels and id="tab-*" ids are the // page-level contract DEV-UI-031 audits. detailBodyJsx = ` ${tabBarJsx} ${tabPanelsJsx}` } else if (hasDetailSections) { // Sectioned body: one SectionCard per resolved category (shared // form.section.* labels), fields rendered by renderFieldDl verbatim so // the DEV-UI-033 line-anchored value contract holds. detailBodyJsx = groupDetailFields(detailFields).map(({ key, fields }) => { const { labelKeyExpr, fallbackLabel, description } = detailSectionTitleExprs(key) const titleProp = key ? `\n title={t('${labelKeyExpr}', { defaultValue: '${fallbackLabel}' })}` : '' const descProp = description ? `\n description={t('${eLower}.form.sectionDescription.${toCamel(key)}', { defaultValue: '${description.replace(/'/g, "\\'")}' })}` : '' // Lifecycle: a section whose EVERY row shares one status guard hides the // whole card until the phase is reached (no empty titled shell) — the // rows inside then skip their redundant individual guards. const rowGuards = fields.map(detailRowGuard) const sharedGuard = fields.length > 0 && rowGuards[0] !== null && rowGuards.every(g => g === rowGuards[0]) ? rowGuards[0] : null const card = `
${renderFieldDl(fields, sharedGuard !== null)}
` return sharedGuard ? ` {(${sharedGuard}) && (\n${card}\n )}` : card }).join('\n') } else { detailBodyJsx = `
${renderFieldDl(spec.fields)}
` } // ─── Unified fiche assembly ───────────────────────────────────────────── // The fiche's own-field body: read-first section cards (renderFormSections, // fiche mode — permission-driven toggles) + the dirty-gated save bar, all // wrapped in ONE
. Related tabs (computed above) append after it. let ficheReactImport = '' let ficheExtraImports = '' let ficheModuleDecls = '' let ficheStateDecls = '' let ficheHandlerDecls = '' if (unified) { const ficheSectionsJsx = renderFormSections(formFields, eLower, e, formLayout, sectionMeta, true, detailDateFns, true) const sectionKeysLiteral = `[${resolveFicheSectionKeys(formFields).map(k => `'${k}'`).join(', ')}]` // 202-optimistic offline-write pages queue through the outbox — its chip // is the truthful save signal (no « Enregistré à HH:MM » stamp). const emitSavedAt = offlineLevel !== 'write' const usesControl = (c: FormControl) => formFields.some(f => isEditable(f) && controlOf(f) === c) const usesLookup = formFields.some(isFkField) const usesUserFk = formFields.some(f => isEditable(f) && f.currentUserFk === true) const needsIsEditShim = formFields.some(f => f.readonlyOn === 'create' || f.readonlyOn === 'edit') ficheReactImport = `import { useState, useEffect, useRef } from 'react'\nimport type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'\n` ficheExtraImports = [ usesLookup ? `\nimport { EntityLookup } from '@/components/ui/EntityLookup'` : '', usesControl('date') ? `\nimport { DateInput } from '@/components/ui/DateInput'` : '', usesControl('select') ? `\nimport { EnumSelect } from '@/components/ui/EnumSelect'` : '', usesControl('segmented') ? `\nimport { SegmentedControl } from '@/components/ui/SegmentedControl'` : '', usesControl('multiselect') ? `\nimport { MultiSelect } from '@/components/ui/MultiSelect'` : '', usesControl('textarea') ? `\nimport { Textarea } from '@/components/ui/Textarea'` : '', usesControl('switch') ? `\nimport { Switch } from '@/components/ui/Switch'` : '', `\nimport { SectionCard } from '@/components/ui/SectionCard'`, `\nimport { useAuth } from '@/business/auth/useAuth'`, ].join('') ficheModuleDecls = ` type ${e}FormData = ${formDataTypeLiteral(formFields)} const initial${e}FormData: ${e}FormData = ${initialFormObj} /** Toggle keys of the fiche's section cards — « Modifier » and /edit open them all. */ const ${eLower}FicheSectionKeys = ${sectionKeysLiteral} ` ficheStateDecls = ` const updateMutation = useUpdate${e}() const { ${usesUserFk ? 'user, ' : ''}hasPermission } = useAuth() const canUpdate = hasPermission('${permKey}.update') const location = useLocation() const [formData, setFormData] = useState<${e}FormData>(initial${e}FormData)${versioned ? ` const [rowVersion, setRowVersion] = useState(undefined)` : ''} const [error, setError] = useState(null) const [fieldErrors, setFieldErrors] = useState>({}) const formRef = useRef(null) // /edit arrival = the edit intent: every section opens (drivers keep their // section-edit- toggles as no-ops, form-submit stays the submit). const [editingSections, setEditingSections] = useState>( () => (location.pathname.endsWith('/edit') ? new Set(${eLower}FicheSectionKeys) : new Set()), ) const [baseline, setBaseline] = useState<${e}FormData | null>(null)${emitSavedAt ? ` const [savedAt, setSavedAt] = useState(null)` : ''} const toggleSection = (key: string) => { setEditingSections(prev => { const next = new Set(prev) if (next.has(key)) { next.delete(key) } else { next.add(key) } return next }) } // Field-level dirty check: arrays compare element-wise (MultiSelect) and // null / undefined / '' collapse to one "empty" state, so a pristine fiche // never reads dirty (the API echoes null where the form holds ''). const isEqualValue = (a: unknown, b: unknown): boolean => { if (Array.isArray(a) || Array.isArray(b)) { const aa = Array.isArray(a) ? a : [] const bb = Array.isArray(b) ? b : [] return aa.length === bb.length && aa.every((x, i) => x === bb[i]) } return (a ?? '') === (b ?? '') } const formKeys = Object.keys(initial${e}FormData) as (keyof ${e}FormData)[] const isDirty = baseline !== null && formKeys.some(k => !isEqualValue(formData[k], baseline[k])) const handleReset = () => { if (baseline) setFormData(baseline) setFieldErrors({}) setError(null) setEditingSections(new Set()) } // Resync from the server ONLY while pristine — a background refetch must // never clobber in-progress edits (the dirty guard closes that hole). useEffect(() => { if (data && !isDirty) { setFormData(data as ${e}FormData) setBaseline(data as ${e}FormData)${versioned ? ` setRowVersion((data as { rowVersion?: string }).rowVersion)` : ''} } }, [data, isDirty]) // Dirty navigation guard — a tab close / refresh with unsaved edits prompts // the browser. useEffect(() => { if (!isDirty) return const onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault() } window.addEventListener('beforeunload', onBeforeUnload) return () => window.removeEventListener('beforeunload', onBeforeUnload) }, [isDirty]) ` // Validation — the fiche is ALWAYS the edit surface, so the phase checks // carry no isEdit guard (their status predicate is the whole gate). const requiredEditable = formFields.filter(f => isEditable(f) && f.required) const phaseRequired = formFields.filter(f => isEditable(f) && !f.required && (f.requiredInPhase === true || f.requiredWhen !== undefined)) const hasRequired = requiredEditable.length > 0 || phaseRequired.length > 0 const requiredChecks = requiredEditable.map(f => { const camel = fieldToCamel(f.name) const blank = `isBlank(formData.${camel})` if (f.visibleWhen) { const g = compileVisibleWhen(f.visibleWhen) if (g) return ` if ((${g}) && ${blank}) errs.${camel} = requiredMsg` } return ` if (${blank}) errs.${camel} = requiredMsg` }).join('\n') const phaseChecks = phaseRequired.map(f => { const camel = fieldToCamel(f.name) const blank = `isBlank(formData.${camel})` const pred = f.requiredWhen ? compileVisibleWhen(f.requiredWhen) : (f.visibleWhen ? compileVisibleWhen(f.visibleWhen) : null) if (pred) return ` if ((${pred}) && ${blank}) errs.${camel} = requiredMsg` return ` if (${blank}) errs.${camel} = requiredMsg` }).join('\n') const validateChecks = [requiredChecks, phaseChecks].filter(s => s !== '').join('\n') ficheHandlerDecls = ` ${needsIsEditShim ? `// readonlyOn guards of the edit cells — the fiche has no create branch. const isEdit = true ` : ''}const isPending = updateMutation.isPending const onChange = (field: K, value: ${e}FormData[K]) => { setFormData(prev => ({ ...prev, [field]: value })) } ${hasRequired ? ` const isBlank = (v: unknown) => v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0) const requiredMsg = t('${eLower}.form.required', { defaultValue: 'This field is required' }) ` : ''} const validate = (): boolean => { const errs: Record = {} ${validateChecks ? validateChecks + '\n' : ''} setFieldErrors(errs) return Object.keys(errs).length === 0 } const handleSubmit = async (event: FormEvent) => { event.preventDefault() setError(null) if (!isDirty) return if (!validate()) return try { await updateMutation.mutateAsync({ id, data: ${versioned ? '{ ...formData, rowVersion }' : 'formData'} }) setBaseline(formData) setEditingSections(new Set())${emitSavedAt ? ` setSavedAt(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))` : ''} } catch (err) { setError(err instanceof Error ? err.message : String(err)) } } // ⌘/Ctrl+Enter submits from any field. const onFormKeyDown = (event: ReactKeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault() formRef.current?.requestSubmit() } } const handleEditAll = () => setEditingSections(new Set(${eLower}FicheSectionKeys)) ` const statusChipJsx = emitSavedAt ? ` {(isDirty || savedAt) && ( {isDirty ? t('${eLower}.form.unsavedChanges', { defaultValue: 'Unsaved changes' }) : \`\${t('${eLower}.form.savedAt', { defaultValue: 'Saved at' })} \${savedAt}\`} )}` : ` {isDirty && ( {t('${eLower}.form.unsavedChanges', { defaultValue: 'Unsaved changes' })} )}` const ficheFormJsx = ` {error && (
{error}
)} ${ficheSectionsJsx} {canUpdate && (
${statusChipJsx} {isDirty && ( )}
)} ` // Only a STRIP of related tabs survives under the fiche form; a // band-only page renders the form alone (its cartouches ride the band // row above — appending the pre-unified read body would duplicate it). detailBodyJsx = hasStripRelated ? `${ficheFormJsx} ${detailBodyJsx}` : ficheFormJsx } // ── 360 related-tab child components ──────────────────────────────────── // One component per related tab, emitted in the SAME file after the page. // Each owns its hooks (paging + the FK-filtered use{RelatedPlural} call) // so it fetches only while its tab is mounted (= active). function renderRelatedTabComponent(tab: PageRelatedTab): string { const relE = tab.relatedEntity const relPlural = relatedPluralOf(tab) const routing = relatedRouting.get(tab.key)! const { familyCamel, routesExpr, createPerm } = routing const compName = `${e}Related${toPascal(tab.key)}Tab` const kPrefix = `${eLower}.detail.related.${tab.key}` const tabData = relatedDataByKey.get(tab.key) const cols = tabData?.columns ?? [{ key: 'createdAt' }] const titleCol = tabData?.displayField ?? cols[0]!.key // Cell expression for a related-tab column: date/datetime values render // through the platform display settings. The pagespec formatHint (filled by // ba-develop Phase 3a) wins; the key-suffix convention (`…At` → date+time, // `…Date|On|Until` → date) covers columns without a hint. Formatting is // passthrough-safe: an unparsable value renders verbatim, never blank. const relatedCellExpr = (c: { key: string; formatHint?: string }, accessor: string): string => { const hint = c.formatHint?.toLowerCase() const dated = hint === 'date' || hint === 'datetime' || (!hint && /(At|Date|On|Until)$/.test(c.key)) if (!dated) return `String(${accessor} ?? '')` const fmt = hint === 'datetime' || (!hint && /At$/.test(c.key)) ? 'formatDateTime' : 'formatDate' detailDateFns.add(fmt) return `${fmt}(${accessor})` } const createButtonJsx = routing.withCreate && tab.displayMode !== 'summary' ? `
` : '' // No orphan `const navigate` (TS6133 breaks the strict typecheck): the // declaration is emitted only when at least one navigation exists in the // child (create button and/or row-open — summary always navigates via // its viewAll link). const needsNavigate = routing.withCreate || routing.withRowOpen || tab.displayMode === 'summary' const navigateDecl = needsNavigate ? '\n const navigate = useNavigate()' : '' const errorJsx = ` if (error) { return (
{t('${kPrefix}.error')}
) }` if (tab.displayMode === 'table') { const columnEntries = cols.map(c => ` { key: '${c.key}', label: t('${kPrefix}.columns.${c.key}'), sortable: false, render: (item) => ${relatedCellExpr(c, `item.${c.key}`)} },`).join('\n') // Row-open only when a real detail surface resolves — otherwise the // rows stay inert (no onRowClick prop at all). const rowClickAttr = routing.withRowOpen ? `\n onRowClick={(item) => navigate(${routesExpr}.${familyCamel}.detail(item.id))}` : '' return `/** 360 related tab «${tab.key}» — ${relE} records in relation via ${tab.relationFk} (server-paged). */ function ${compName}({ relatedId }: { relatedId: string }) { const { t } = useTranslation('${spec.module}')${navigateDecl} const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) const { data, isLoading, error } = use${relPlural}({ page, pageSize, ${tab.relationFk}: relatedId }) const items = data?.items ?? [] const totalCount = data?.totalCount ?? 0 const columns: ResponsiveColumn<${relE}ListDto>[] = [ ${columnEntries} ] ${errorJsx} return (
${createButtonJsx} {isLoading ? (
{t('${kPrefix}.loading')}
) : ( data={items} columns={columns} serverMode page={page} totalCount={totalCount} onPageChange={setPage} onPageSizeChange={(size) => { setPageSize(size); setPage(1) }} pagination={{ pageSize, showSizeSelector: false }} getRowKey={(item) => item.id} emptyMessage={t('${kPrefix}.empty')}${rowClickAttr} /> )}
) }` } if (tab.displayMode === 'cards') { const secondary = cols.filter(c => c.key !== titleCol).slice(0, 3) // Cards open the detail only when it resolves; otherwise they render // as inert
s (no onClick, no hover affordance). const cardJsx = routing.withRowOpen ? ` ` : `
{${relatedCellExpr(cols.find(c => c.key === titleCol) ?? { key: titleCol }, `item.${titleCol}`)}}
${secondary.map(c => `
{${relatedCellExpr(c, `item.${c.key}`)}}
`).join('\n')}
` return `/** 360 related tab «${tab.key}» — ${relE} cards in relation via ${tab.relationFk}. */ function ${compName}({ relatedId }: { relatedId: string }) { const { t } = useTranslation('${spec.module}')${navigateDecl} const [page, setPage] = useState(1) const pageSize = 12 const { data, isLoading, error } = use${relPlural}({ page, pageSize, ${tab.relationFk}: relatedId }) const items = data?.items ?? [] const totalCount = data?.totalCount ?? 0 const pageCount = Math.max(1, Math.ceil(totalCount / pageSize)) ${errorJsx} if (isLoading) { return (
{t('${kPrefix}.loading')}
) } return (
${createButtonJsx} {items.length === 0 ? (
{t('${kPrefix}.empty')}
) : (
{items.map((item) => ( ${cardJsx} ))}
)} {pageCount > 1 && (
{page} / {pageCount}
)}
) }` } // summary — count-only cartouche. Markup calqued on the KpiCard // primitive so the detail page carries NO dependency on the dashboard // kit (a project without dashboards still compiles). The caption is the // TAB LABEL (not the generic .count) — in a band row of several // cartouches, identity must live on the card itself. return `/** 360 related tab «${tab.key}» — ${relE} count in relation via ${tab.relationFk}. */ function ${compName}({ relatedId }: { relatedId: string }) { const { t } = useTranslation('${spec.module}') const navigate = useNavigate() const { data, isLoading } = use${relPlural}({ page: 1, pageSize: 1, ${tab.relationFk}: relatedId }) return (
{t('${eLower}.${tab.labelKey}')}
{isLoading ? (
) : (
{data?.totalCount ?? 0}
)}
) }` } const relatedChildComponents = relatedTabs.map(renderRelatedTabComponent).join('\n\n') // Deduped imports for the related tabs: hooks + ListDto types (per related // entity) and cross-module routes registries (per module). const relatedHookImportSet = new Map() const relatedTypeImportSet = new Map() const relatedRoutesImportSet = new Map() for (const tab of relatedTabs) { const relE = tab.relatedEntity const relPlural = relatedPluralOf(tab) const relLower = relE.charAt(0).toLowerCase() + relE.slice(1) // Keyed by the TARGET's application, never by the page's: a tab pointing at another // application used to build `@/features/{ownApp}/{otherModule}/…`, a path that does // not exist — the page did not compile. const relApp = relatedAppOf(tab, spec.appCode) const relId = relatedExtensionsId(tab, spec.appCode) const featureBase = `@/features/${relApp}/${tab.relatedModule}/${relLower}` relatedHookImportSet.set(`${relId}/${relE}`, `import { use${relPlural} } from '${featureBase}/hooks/use${relE}'`) if (tab.displayMode === 'table') { relatedTypeImportSet.set(`${relId}/${relE}`, `import type { ${relE}ListDto } from '${featureBase}/types'`) } if (relApp !== spec.appCode.toLowerCase() || tab.relatedModule !== spec.module) { relatedRoutesImportSet.set(relId, `import { routes as ${relatedRoutesExpr(tab)} } from '@/extensions/${relId}Routes'`) } } const relatedImportLines = [ ...relatedRoutesImportSet.values(), ...relatedHookImportSet.values(), ...relatedTypeImportSet.values(), ].map(l => `\n${l}`).join('') const relatedTableImport = relatedTabs.some(tb => tb.displayMode === 'table') ? `import { ResponsiveDataTable, type ResponsiveColumn } from '@/components/ui/ResponsiveDataTable'\n` : '' const relatedNeedsState = relatedTabs.some(tb => tb.displayMode === 'table' || tb.displayMode === 'cards') // Custom header actions for detail page — rendered alongside the Edit button. const detailHeaderActions = allCustomActions.filter(a => a.scope === 'header') const detailApiActions = detailHeaderActions.filter(isApi) const hasDetailActions = detailHeaderActions.length > 0 const detailCustomHookImports = detailApiActions .map(a => `, use${toPascal(hookCodeOf(a))}${e}`) .join('') const detailCustomIconNames = new Set(detailHeaderActions.map(a => iconForAction(a))) // Related-tab icons ride the same deduped set (Plus for the pre-filled // create button, chevrons for the cards mini-pager). if (relatedTabs.some(tb => relatedRouting.get(tb.key)?.withCreate && tb.displayMode !== 'summary')) detailCustomIconNames.add('Plus') if (relatedTabs.some(tb => tb.displayMode === 'cards')) { detailCustomIconNames.add('ChevronLeft') detailCustomIconNames.add('ChevronRight') } // Icons hoisted into the base lucide import — dropped from the custom set // so a custom action icon never emits a duplicate identifier. Pencil is // navEdit-gated in the base import (unused imports fail the strict // type-check, TS6133 — client defect 2026-08-25 #6). for (const name of ['Loader2', ...(navEdit ? ['Pencil'] : []), 'Trash2', 'FileText', ...(unified ? ['AlertTriangle'] : [])]) { detailCustomIconNames.delete(name) } const detailCustomIconImports = Array.from(detailCustomIconNames).map(name => `, ${name}`).join('') const detailCustomMutationDecls = detailApiActions .map(a => ` const ${toCamel(hookCodeOf(a))}Mutation = use${toPascal(hookCodeOf(a))}${e}()`) .join('\n') // Payload actions (with collectible payloadParameters) open a // on the detail page too — header-scoped, firing mutateAsync(payload). Non-payload // actions call the header hook with no argument (matches the api-client () signature // — passing `id` to a bodyless header endpoint was wrong and broke the arity). const detailPayloadActions = detailApiActions.filter(isPayloadAction) const detailDialogStateDecls = detailPayloadActions .map(a => ` const [${a.code}DialogOpen, set${toPascal(a.code)}DialogOpen] = useState(false)`) .join('\n') const detailDialogsJsx = detailPayloadActions.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)}}` return ` { await ${hookVar}.mutateAsync(payload as never); set${P}DialogOpen(false); refetch() }}\n onClose={() => set${P}DialogOpen(false)}\n />` }).join('\n') const detailDialogImport = detailPayloadActions.length > 0 ? `import { CustomActionDialog } from '@/components/ui/CustomActionDialog'\n` : '' const detailStateImport = unified ? ficheReactImport : (detailPayloadActions.length > 0 || relatedNeedsState) ? `import { useState } from 'react'\n` : '' // ─── Summary band (detail.summary — plan UI 3.2) ───────────────────────── // A header card above the tabs/sections: big title value, status Badge, // up to 4 label/value meta pairs. Overlay (uiDesign.detail.summary) wins // key-by-key over the pagespec block; unknown field keys warn + drop. // Values go through renderReadValueExpr — same pills / FK labels / date // formatting as the body, zero drift. Absent → no band (legacy output). const summaryAuthored = spec.pageSpec?.summary || pickUiDesignOverlay(spec.pageSpec)?.detail?.summary const summaryCfg = summaryAuthored ? { ...spec.pageSpec?.summary, ...pickUiDesignOverlay(spec.pageSpec)?.detail?.summary } : undefined let detailSummaryJsx = '' if (summaryCfg) { const fieldOf = (key: string | undefined, slot: string): ComponentField | undefined => { if (!key) return undefined const f = spec.fields.find(x => fieldToCamel(x.name) === fieldToCamel(key)) if (!f) { ctx.warnings?.push( `scaffold-component: ${e}DetailPage summary.${slot} '${key}' matches no entity field — dropped.`, ) } return f } const titleF = fieldOf(summaryCfg.titleField, 'titleField') const statusF = fieldOf(summaryCfg.statusField, 'statusField') const metaFs = (summaryCfg.fields ?? []) .map(k => fieldOf(k, 'fields')) .filter((f): f is ComponentField => f !== undefined) .slice(0, 4) const titleExpr = titleF ? renderReadValueExpr(titleF, 'data', eLower, detailDateFns) : `{headerTitle}` const statusJsx = statusF ? ` ${renderReadValueExpr(statusF, 'data', eLower, detailDateFns)}` : '' const metaJsx = metaFs.length ? `
${metaFs.map(f => `
{t('${eLower}.detail.fields.${fieldToCamel(f.name)}')}
${renderReadValueExpr(f, 'data', eLower, detailDateFns)}
`).join('\n')}
` : '' detailSummaryJsx = `

${titleExpr}

${statusJsx}
${metaJsx}
` } // ── 360 band cartouches (placement 'band' — lib relatedTabPlacementOf) ── // Always-mounted summary cartouches between the detail summary band and // the tab strip, each in its own PermissionGuard. COST CONTRACT (do not // "optimise" by lazy-mounting): each cartouche fetches // use{RelatedPlural}({ page: 1, pageSize: 1, {relationFk}: relatedId }) // and reads ONLY totalCount — one count-shaped request per cartouche, // never a row payload. At-a-glance counts are the band's whole point; an // unmounted cartouche is a blank 360 view. The per-key wrapper testid is // the DEV-UI-031 anchor for band-placed tabs. const relatedBandJsx = bandTabs.length === 0 ? '' : `
${bandTabs.map(tab => { const guard = guardedKeys.has(tab.key) ? availabilityConst(tab) : null const cartouche = `
<${e}Related${toPascal(tab.key)}Tab relatedId={id} />
` const inner = guard ? ` {${guard} && (\n${cartouche.split('\n').map(l => ` ${l}`).join('\n')}\n )}` : cartouche return ` ${inner} ` }).join('\n')}
` const tabStripImport = hasDetailTabs ? `import { TabStrip } from '@/components/ui/TabStrip'\n` : '' const detailSectionCardImport = !unified && !hasDetailTabs && hasDetailSections ? `import { SectionCard } from '@/components/ui/SectionCard'\n` : '' const detailCustomHandlerDecls = detailHeaderActions .map(a => { const name = `handle${toPascal(a.code)}` if (isNavigate(a)) { const target = a.targetRoute ?? `routes.${sectionCamel}.list()` return ` const ${name} = () => {\n navigate(${target})\n }` // detail actions are always header-scoped } // Payload action → open its dialog; the dialog fires mutateAsync(payload). if (isPayloadAction(a)) { return ` const ${name} = () => { set${toPascal(a.code)}DialogOpen(true) }` } const hookVar = `${toCamel(hookCodeOf(a))}Mutation` // The action mutates the entity on display — re-read it, or the page // keeps showing the pre-mutation state until a manual reload. return ` const ${name} = async () => {\n await ${hookVar}.mutateAsync()\n refetch()\n }` }) .join('\n\n') // Priority+ cluster (shared with list/form): ≤ 2 promoted buttons at lg+ // next to Edit/Delete, everything in the HeaderActionsMenu below lg. The // `detail-action-` anchor (DEV-UI-032) rides the promoted button or // the overflowed menu item — exactly one node per code either way. const detailCustomActionsJsx = hasDetailActions ? `\n${renderHeaderActionsCluster(detailHeaderActions, a => `detail-action-${a.code}`)}` : '' const detailHeaderMenuImport = hasDetailActions ? `import { HeaderActionsMenu } from '@/components/ui/HeaderActionsMenu'\n` : '' // FK fields resolve the target's label (not the Guid) via its lookup hook — // one hook call per FK field, searching by the stored Guid (the backend // lookup resolves a Guid by Id). Imports dedupe by target entity. Core // non-standard targets have NO useLookup hook (same exclusion as the // list page) — their dd masks the Guid instead of resolving it. Scoped to // detailRenderedNames: a tab-partitioned fiche declares hooks only for the // FKs a panel actually shows. const detailFkFields = spec.fields.filter((f) => isFkField(f) && !coreNonStandardLookup(f.fkTo!) && detailRenderedNames.has(f.name)) const detailFkHookImports = lookupHookImportLines(detailFkFields, spec.appCode).map((l) => `\n${l}`).join('') // Unified fiche: the read grid renders from formData (the editable state, // resynced from the server while pristine), so the lookups search by it — // same idiom as the read-first FormPage. Declared AFTER formData (TDZ). const detailFkHookDecls = detailFkFields .map((f) => { const camel = fieldToCamel(f.name) return unified ? ` const { data: ${camel}LookupData } = use${f.fkTo!.entity}Lookup({ search: (formData.${camel} as string | undefined) || undefined, pageSize: 1 })` : ` const { data: ${camel}LookupData } = use${f.fkTo!.entity}Lookup({ search: (data?.${camel} as string | undefined) ?? undefined, pageSize: 1 })` }) .join('\n') const detailDateImport = detailDateFns.size ? `, ${Array.from(detailDateFns).sort().join(', ')}` : '' files.push({ path: pathFor('detail', `${e}DetailPage.tsx`), content: `${GENERATED_MARKER}${detailStateImport}import { useParams, useNavigate, Navigate${detailExtraImport} } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { Loader2, ${navEdit ? 'Pencil, ' : ''}Trash2, FileText${unified ? ', AlertTriangle' : ''}${detailCustomIconImports} } from 'lucide-react' import { Slot${onlineStatusImport}${detailDateImport} } from '@atlashub/smartstack' import { PermissionGuard } from '@/components/auth/PermissionGuard' ${availabilityImport}import { PageTemplate } from '@/components/ui/PageTemplate' ${outboxChipImport}${detailHeaderMenuImport}${relatedTableImport}${detailDialogImport}${tabStripImport}${detailSectionCardImport}import { routes } from '@/extensions/${extensionsModuleId(spec.appCode, spec.module)}Routes' import { use${e}, ${unified ? `useUpdate${e}, ` : ''}useDelete${e}${detailCustomHookImports} } from '${featurePath}/hooks/use${e}'${detailFkHookImports}${relatedImportLines}${ficheExtraImports} ${ficheModuleDecls} export function ${e}DetailPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const { t } = useTranslation('${spec.module}') if (!id) return const { data, isLoading${detailApiActions.length > 0 ? ', refetch' : ''} } = use${e}(id)${!unified && detailFkFields.length ? '\n' + detailFkHookDecls : ''} const deleteMutation = useDelete${e}()${onlineStatusDecl} ${unified ? ficheStateDecls + (detailFkFields.length ? detailFkHookDecls + '\n' : '') : ''}${detailCustomMutationDecls ? detailCustomMutationDecls + '\n' : ''}${detailDialogStateDecls ? detailDialogStateDecls + '\n' : ''}${availabilityDecls}${detailTabHooks} if (isLoading) { return (
{t('${eLower}.detail.loading')}
) } if (!data) { return (
{t('${eLower}.detail.notFound')}
) } const headerTitle = ${headerTitleExpr} const handleDelete = async () => { if (!id) return await deleteMutation.mutateAsync(id) navigate(routes.${sectionCamel}.list()) } ${unified ? ficheHandlerDecls : ''}${detailCustomHandlerDecls ? '\n' + detailCustomHandlerDecls + '\n' : ''} return ( } breadcrumbs={[ { label: t('${eLower}.breadcrumb.section'), href: routes.${sectionCamel}.list() }, { label: headerTitle }, ]} actions={
${outboxChipJsx}${navEdit ? ` ` : ''} ${detailCustomActionsJsx}
} > ${detailSummaryJsx}${relatedBandJsx}${detailBodyJsx} ${detailDialogsJsx ? '\n' + detailDialogsJsx : ''}
) } ${relatedChildComponents ? '\n' + relatedChildComponents + '\n' : ''} export default ${e}DetailPage `, }) } return files }