/** * cli:scaffold-component — render/home.ts * * Hub pages (AppHome / ModuleHome / SectionHome): KPI cards + navigation * links read from pageSpec.widgets[] / pageSpec.quickLinks[]. LIVE since plan * UI 1.3 — the historical output was a dead mockup (KPI value `—` hardcoded, * every quickLink navigate('#')): * - a count-shaped widget on the page's own entity reads its figure from the * entity's server-driven list hook (`use{Plural}({page:1,pageSize:1})` → * `totalCount`) and renders through the StatCard primitive; * - a widget the generator cannot wire deterministically (foreign entity, * field aggregation, chart/list type) renders StatCard value "—" and emits * a ctx warning — visible, never silently live-looking; * - a quickLink resolves its screenTarget (SCR-{MODULE}-{SECTION}-{VIEW}[-n]) * against the module's own routes families; unresolvable → the link is * OMITTED + ctx warning (a dead '#' navigation never ships). Resolved links * are permission-gated when the spec declares one. * Renders nothing when the view is absent from spec.views. */ import type { GeneratedFile } from '../types.js' import { statCardJsx, STAT_CARD_IMPORT } from '../../../../../../lib/render-widgets.js' import { GENERATED_MARKER, WIDGET_TYPE_ICONS, kebabToPascal, resolveScreenNav } from './shared.js' import { extensionsModuleId } from '../../../../../../lib/app-classification.js' import type { RenderContext } from './context.js' export function renderHomes(rc: RenderContext): GeneratedFile[] { const files: GeneratedFile[] = [] const { spec, ctx, e, eLower, featurePath, pathFor, permKey, plural } = rc type HubViewKind = 'app-home' | 'module-home' | 'section-home' type HubViewSuffix = 'AppHome' | 'ModuleHome' | 'SectionHome' const hubViews: Array<[HubViewKind, HubViewSuffix]> = [ ['app-home', 'AppHome'], ['module-home', 'ModuleHome'], ['section-home', 'SectionHome'], ] const ownFamilies = ctx.routesFamilies?.[extensionsModuleId(spec.appCode, spec.module)] /** Resolve a quickLink screenTarget to a routes helper call, or null * (shared resolver — also drives the dashboard drill-down). */ const resolveQuickLinkNav = (code: string | undefined): string | null => resolveScreenNav(spec.module, ownFamilies, code) for (const [view, suffix] of hubViews) { if (!spec.views.includes(view)) continue const componentName = `${plural}${suffix}Page` const widgets = spec.pageSpec?.widgets ?? [] const quickLinks = spec.pageSpec?.quickLinks ?? [] const hasWidgets = widgets.length > 0 // ── KPI wiring ──────────────────────────────────────────────────────── // Deterministically wirable: a count on the page's OWN entity (the list // hook's totalCount IS the count — one row fetched). Everything else // stays visibly unwired ("—") with a warning. const isOwnCount = (w: (typeof widgets)[number]): boolean => (!w.entity || w.entity === e) && (!w.aggregation || /^count$/i.test(w.aggregation)) && (!w.type || w.type === 'kpi' || w.type === 'counter') const wiredCount = hasWidgets && widgets.some(isOwnCount) // ── Icon imports from the data ──────────────────────────────────────── const iconSet = new Set(['LayoutGrid', 'ChevronRight']) for (const w of widgets) { iconSet.add(w.icon ? kebabToPascal(w.icon) : (WIDGET_TYPE_ICONS[w.type ?? 'kpi'] ?? 'TrendingUp')) } for (const ql of quickLinks) { if (ql.icon) iconSet.add(kebabToPascal(ql.icon)) } const iconImports = Array.from(iconSet).sort().join(', ') // ── KPI card markup ─────────────────────────────────────────────────── let kpiContent: string if (hasWidgets) { const cards = widgets.map(w => { const icon = w.icon ? kebabToPascal(w.icon) : (WIDGET_TYPE_ICONS[w.type ?? 'kpi'] ?? 'TrendingUp') const labelExpr = w.labelKey ? `t('${eLower}.${w.labelKey}')` : `'${w.label ?? w.key}'` if (isOwnCount(w)) { return statCardJsx({ labelExpr, valueAttr: '{countData?.totalCount ?? 0}', iconName: icon, loadingExpr: 'countLoading', }, ' ') } ctx.warnings?.push( `scaffold-component: ${componentName} widget '${w.key}' is not deterministically wirable ` + `(entity '${w.entity ?? e}', aggregation '${w.aggregation ?? 'count'}', type '${w.type ?? 'kpi'}') — ` + `rendered as an unwired StatCard ("—").`, ) return statCardJsx({ labelExpr, valueAttr: '"—"', iconName: icon }, ' ') }) cards.push(` `) kpiContent = cards.join('\n') } else { kpiContent = ` ` } // ── QuickLink markup ────────────────────────────────────────────────── let anyResolvedLink = false let navContent: string if (quickLinks.length > 0) { const cards = quickLinks.flatMap(ql => { const nav = resolveQuickLinkNav(ql.screenTarget) if (!nav) { ctx.warnings?.push( `scaffold-component: ${componentName} quickLink '${ql.key}' has no resolvable route ` + `(screenTarget '${ql.screenTarget ?? '∅'}') — link omitted (a dead '#' navigation never ships).`, ) return [] } anyResolvedLink = true const icon = ql.icon ? kebabToPascal(ql.icon) : 'ChevronRight' const label = ql.labelKey ? `t('${eLower}.${ql.labelKey}')` : `'${ql.label ?? ql.key}'` const button = ` ` return [ql.permission ? ` \n${button.replace(/^ {12}/gm, ' ')}\n ` : button] }) cards.push(` `) navContent = cards.join('\n') } else { navContent = ` ` } // ── Conditional imports/decls (an unused import fails the type-check) ─ const navigateImport = anyResolvedLink ? `import { useNavigate } from 'react-router-dom'\n` : '' const navigateDecl = anyResolvedLink ? `\n const navigate = useNavigate()` : '' const routesImport = anyResolvedLink ? `import { routes } from '@/extensions/${extensionsModuleId(spec.appCode, spec.module)}Routes'\n` : '' const statCardImport = hasWidgets ? `${STAT_CARD_IMPORT}\n` : '' const hookImport = wiredCount ? `import { use${plural} } from '${featurePath}/hooks/use${e}'\n` : '' const hookDecl = wiredCount ? `\n const { data: countData, isLoading: countLoading } = use${plural}({ page: 1, pageSize: 1 })` : '' files.push({ path: pathFor(view, `${componentName}.tsx`), content: `${GENERATED_MARKER}${navigateImport}import { useTranslation } from 'react-i18next' import { ${iconImports} } from 'lucide-react' import { Slot } from '@atlashub/smartstack' import { PermissionGuard } from '@/components/auth/PermissionGuard' import { PageTemplate } from '@/components/ui/PageTemplate' ${statCardImport}${routesImport}${hookImport} export function ${componentName}() { const { t } = useTranslation('${spec.module}')${navigateDecl}${hookDecl} return ( } >

{t('${eLower}.home.kpis')}

${kpiContent}

{t('${eLower}.home.navigation')}

${navContent}
) } export default ${componentName} `, }) } return files }