/**
* 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
}