/** * cli:scaffold-api-client — generate.ts * * Generates TypeScript service + React hooks (useState/useEffect) for a REST entity. * Generated code depends on: * - `@atlashub/smartstack` (npm) for the pre-configured HTTP client `api` * (auth, tenant, retry built-in; returns unwrapped data, not AxiosResponse). * - When `httpClient: 'axios'`, generates a local axios instance instead. * * URL convention — two strata only, no templated paths: * integration (default) → /api/{module}/{section} (NavRoute-resolved: the * platform rewrites the controller's [NavRoute] to this * path at runtime — buildNavApiPath mirrors that mapping) * screens (useScreens) → /api/screens/{plural-kebab}/{list|detail|form|...} * * Both derive from `lib/url-conventions.ts`; the integration path comes from the * SAME navRoute that `scaffold-controller`'s [NavRoute] carries. The historical * `/api/v1/integration/{plural}` literal was a guaranteed 404 (the convention * rewrote it away). Drift is impossible by construction. */ import { buildNavApiPath, buildScreenRoute, pluralSegment } from '../../../../../lib/url-conventions.js' import { pluralize } from '../../../../../lib/string-utils.js' import { fkFilterFields } from '../../../../../lib/page-spec-related-tabs.js' import { screenFilterParams } from '../../../../../lib/page-spec-filters.js' import { createSurfaceFields, updateSurfaceFields } from '../../../../../lib/field-read-surface.js' import { isSupplied } from '../../../../../lib/page-spec-coded-entity.js' import { normalizeOffline } from '../../../../../lib/pwa-meta.js' import type { ScaffoldApiClientInput, GeneratedFile, ApiCustomAction, ApiScreenColumn } from './types.js' export function generate(spec: ScaffoldApiClientInput): GeneratedFile[] { const files: GeneratedFile[] = [] for (const entity of spec.entities) { const e = entity.name const eLower = e.charAt(0).toLowerCase() + e.slice(1) // Plural + kebab segment derive from the SAME shared helpers the backend // scaffolders use (`pluralize` guards words already ending in 's' — no more // `effectifss`; `pluralSegment` handles consecutive uppercase). Front == back // by construction. Upstream (`ba-develop`/`create-prd`) should always pass an // explicit `pluralName` from entité.md so this fallback rarely fires. const plural = entity.pluralName ?? pluralize(e) const pluralLower = plural.charAt(0).toLowerCase() + plural.slice(1) const pluralKebab = pluralSegment(plural) // Route mode: backward compat — useScreens: true overrides routeMode. The // `?? 'integration'` fallback mirrors the Zod default for callers that build // the spec literally (tests) and bypass `.parse()`. Only the two canonical // strata exist — both are served by a backend scaffolder, so the URL can // never 404 by construction. const effectiveMode = spec.useScreens ? 'screens' : (spec.routeMode ?? 'integration') const isScreenMode = effectiveMode === 'screens' // Integration route = the NavRoute-resolved runtime path the platform serves // (/api/{module}/{section}). navRoute defaults to `{module}.{section}` — the // SAME key scaffold-controller's [NavRoute] carries — so front == back. Also // used as the screen-mode lookup/delete fallback (those live on the integration // controller, not the screen controller). const navRoute = entity.navRoute ?? `${spec.module}.${entity.section}` const integrationPath = buildNavApiPath(navRoute) const apiPath = effectiveMode === 'screens' ? buildScreenRoute(pluralKebab) : integrationPath // The dashboard is ALWAYS served on the screen stratum: its widgets come from the // dashboard pagespec, which only `scaffold-screen-controller` consumes (neither // `scaffold-controller` nor `scaffold-business` emit a /dashboard endpoint). So // `getDashboard` targets the screen route regardless of the entity's routeMode, // otherwise an integration-mode entity would call an unserved integration /dashboard. const screenDashboardRoute = buildScreenRoute(pluralKebab) // Sub-resource support (Fix #1, 2026-05-27): when entity.parentPath is set, // API_PATH becomes a function `(parentId) => string` instead of a constant. // Every service method/hook takes `parentId` as its first argument, and // every URL reference uses `API_PATH(parentId)` instead of `API_PATH`. We // refuse the combination with the screen stratum — the screen stratum has // no nested URL convention and silently dropping the parent would mask the bug. const hasParent = entity.parentPath !== undefined if (hasParent && isScreenMode) { throw new Error( `scaffold-api-client: entity '${e}' carries parentPath but the spec is in screen mode — these are mutually exclusive. Use routeMode: 'integration' for sub-resources.`, ) } const parentIdParam = entity.parentIdParam ?? 'parentId' // `parentArg` is the leading argument string emitted in every method // signature (`parentId: string, `), empty when hasParent === false so all // existing CLI consumers keep their old signatures byte-for-byte. const parentArg = hasParent ? `${parentIdParam}: string, ` : '' const parentArgCall = hasParent ? `${parentIdParam}, ` : '' // `apiPathExpr` is the JS-side expression that resolves to the URL prefix: // - flat: 'API_PATH' (constant string) // - nested: 'API_PATH(parentId)' (call site of the helper) const apiPathExpr = hasParent ? `API_PATH(${parentIdParam})` : 'API_PATH' // `apiPathDecl` is the file-scope binding emitted at the top of the // service file. For nested, we emit an arrow function that substitutes // `{parentId}` in the template with the runtime parameter value. const apiPathDecl = hasParent ? `const API_PATH = (${parentIdParam}: string) => \`${entity.parentPath!.replace('{parentId}', '${' + parentIdParam + '}')}\`` : `const API_PATH = '${apiPath}'` // App/Module/Entity classification — features gain the `` segment to match // the page tree (src/pages///
). The web app is already // per-application (web/-web), so `` here keeps features + pages aligned. const featurePath = `src/features/${spec.appCode.toLowerCase()}/${spec.module}/${eLower}` const clientVar = spec.httpClient === 'axios' ? 'client' : 'api' const needsUnwrap = spec.httpClient === 'axios' const dr = needsUnwrap ? 'const { data }' : 'const data' const clientImport = spec.httpClient === 'axios' ? `import axios from 'axios';\n\nconst client = axios.create({ baseURL: '/', withCredentials: true });` : `import { api } from '@atlashub/smartstack';` // Wave F1 (2026-05-27) + Wave F5 (2026-05-27): pagespec columns shape the // TS DTOs in BOTH route modes — integration, screens. Previously gated // behind `isScreenMode`, which made integration-mode DTOs fall back to // `entity.fields.slice(0,5)` (random domain fields) and silently mismatch // the C# DTO returned by the controller. The gate is gone: `screenColumns` // now wins whenever it is provided; the legacy heuristic only kicks in for // entities whose spec never carried column data (full backward compat). const useScreenDtos = entity.screenColumns !== undefined && (entity.screenColumns.list !== undefined || entity.screenColumns.detail !== undefined) // ─── Offline write (outbox) + rowversion echo ─────────────────────────── // 'write' → emit the outbox spec module + fold pending writes into the // list/detail hooks (useOutboxOverlay). 'read'/'none' → nothing here (the // read path is a service-worker concern). validate.ts already refused // write without versioned / with parentPath / in screen mode. const offlineWrite = normalizeOffline(entity.pwa?.offline) === 'write' // Resource key = the componentKey root — the SAME key scaffold-routes // registers and scaffold-component's OutboxStatusChip filters by. const outboxResourceKey = `${spec.appCode.toLowerCase()}.${spec.module}.${entity.section}` // PascalCase → SCREAMING_SNAKE (TimeEntry → TIME_ENTRY) for the exported // resource consts, matching the socle's dogfood naming. const outboxConst = e.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase() const versionedDetailLine = entity.versioned ? `\n /** Optimistic-concurrency token (rowversion, base64 on the wire) — echo it verbatim in Update${e}Dto. */\n rowVersion?: string;` : '' const versionedUpdateLine = entity.versioned ? `\n /** Echo of ${e}DetailDto.rowVersion — enables the backend's 409 stale-edit detection. */\n rowVersion?: string;` : '' // Fallback (no screenColumns provided): ALL business fields — parity with // the backend's {E}ListDto, which carries every user field. The historical // `slice(0, 5)` made the TS type LIE about what the API returns: any list // column beyond the 5th was TS2339-untypable while the JSON carried it // (client defect 2026-08-25 #5 — 4 of 12 referential lists were affected). const listFields = useScreenDtos ? renderListFromColumns(entity.screenColumns!.list) : entity.fields.map(f => ` ${fieldCamel(f.name)}: ${mapTsType(f.type)};`).join('\n') // Bug 8 fix — DetailDto is the **union** of `screenColumns.detail` and // `entity.fields`. Prior to this, the edit form fetched DetailDto then tried // to pre-fill fields that were only declared in `entity.fields` (e.g. JSON // snapshot columns like `localityData`, `addressData`, `parcelData`). Result: // « field not found » at runtime even though every other layer (Create/Update // DTOs, the form input bindings) knew about them. Detail now always carries // every entity field. screenColumns.detail order wins for display fields; // entity.fields extras are appended. const allFields = useScreenDtos ? renderDetailFromColumns(entity.screenColumns!.detail, entity.screenColumns!.list, entity.fields) : entity.fields.map(f => ` ${fieldCamel(f.name)}${f.required ? '' : '?'}: ${mapTsType(f.type)};`).join('\n') const dashboardTypes = entity.hasDashboard ? ` /** One chart datapoint: a label + one or more numeric series. */ export interface ${e}ChartPoint { label: string; [series: string]: number | string; } /** One pie slice. */ export interface ${e}ChartSlice { name: string; value: number; } /** * The data a single dashboard widget renders (keyed by widget key in * ${e}DashboardDto.widgets). Shape per widget type: kpi/counter → value/delta; * chart-line/-bar → points; chart-pie → slices; list → rows/columns. * Structurally compatible with the local WidgetResult in * @/components/dashboard/types. */ export interface ${e}WidgetResult { value?: number | string; delta?: number; unit?: string; points?: ${e}ChartPoint[]; slices?: ${e}ChartSlice[]; rows?: Record[]; columns?: { key: string; label: string }[]; } export interface ${e}DashboardDto { /** Each widget's result, indexed by the SmartDashboard widget key. Absent * keys render the widget's muted "no data" placeholder. */ widgets: Record; }` : '' // Filter `kind: 'navigate'` BEFORE collecting custom types — navigation // actions never produce a service member, so their payload/response types // are not used and must not pollute the imports. const customActions = (entity.customActions ?? []).filter(a => a.kind !== 'navigate') // Bug 1 fix — collect + emit type declarations for every custom action's // `payloadType` / `responseType`. Must be computed BEFORE the types/index.ts // push so the declarations land at the top of the emitted file. const customTypeDecls = renderCustomTypeDeclarations(customActions, e) const customTypeNames = collectCustomTypeNames(customActions, e) const customTypeImports = customTypeNames.length > 0 ? `, ${customTypeNames.join(', ')}` : '' files.push({ path: `${featurePath}/types/index.ts`, content: `${customTypeDecls}export interface ${e}ListDto { id: string; ${listFields} createdAt: string; } export interface ${e}DetailDto { id: string; ${allFields} createdAt: string; updatedAt: string | null;${versionedDetailLine} } /** * Convenience alias — when consumers (e.g. scaffold-component) reference the * entity without a DTO suffix, resolve it to the detail shape (the most * complete view of the entity). Bug 2 fix: prior to this alias, generated * pages emitted "import { Demande }" against a module that only exported * DemandeListDto + DemandeDetailDto — TS2305 « no exported member ». Now both * names compile. */ export type ${e} = ${e}DetailDto; export interface Create${e}Dto { ${createSurfaceFields(entity.fields, entity.dataScopeOwner ?? null).map(f => ` ${fieldCamel(f.name)}${f.required ? '' : '?'}: ${mapTsType(f.type)};`).join('\n')}${isSupplied(entity.codedEntity) ? ` /** Optional user-supplied code (import/reprise/picked suggestion) — mirror of the C# \`string? Code = null\`. */ code?: string;` : ''} } export interface Update${e}Dto { ${updateSurfaceFields(entity.fields, entity.dataScopeOwner ?? null).map(f => ` ${fieldCamel(f.name)}${f.required ? '' : '?'}: ${mapTsType(f.type)};`).join('\n')}${versionedUpdateLine} } /** * Lightweight reference shape returned by GET /api/v1/integration/{plural}/lookup. * Lookup lives on the integration stratum regardless of useScreens (the * screen-driven contract intentionally omits it). Consumed by * for FK selectors — id is the FK value to persist, displayName is the human * label rendered in the combobox. */ export interface ${e}RefDto { id: string; displayName: string; } export interface PaginatedResult { items: T[]; /** Mirrors SmartStack.app PaginatedResult.TotalCount (JSON: "totalCount"). * ASP.NET serialises C# PascalCase property names to camelCase on the wire; * the previous "total" identifier silently dropped the count for every * paginated endpoint. */ totalCount: number; page: number; pageSize: number; }${dashboardTypes} `, }) const dashboardServiceMembers = entity.hasDashboard ? `, getDashboard: async (${parentArg}params?: { startDate?: string; endDate?: string }) => { // Screen stratum (widget-aware backend) — see screenDashboardRoute note in the generator. ${dr} = await ${clientVar}.get<${e}DashboardDto>(\`${screenDashboardRoute}/dashboard\`, { params }); return data; }` : '' const dashboardImports = entity.hasDashboard ? `, ${e}DashboardDto` : '' // Custom action filtering + custom type collection happens BEFORE the // types/index.ts push (see above). Here we only emit the service members // since they depend on `apiPathExpr` / `parentArg` already computed. const customActionServiceMembers = customActions .map((a) => emitCustomActionServiceMember(a, clientVar, dr, parentArg, apiPathExpr)) .join('') const integrationConstDecl = isScreenMode ? `\nconst INTEGRATION_PATH = '${integrationPath}';\n` : '' const lookupBase = isScreenMode ? 'INTEGRATION_PATH' : 'API_PATH' const deleteBase = isScreenMode ? 'INTEGRATION_PATH' : 'API_PATH' // Relation (FK) filters — every Guid FK of the entity is an optional typed // param on getAll/use{Plural} (`{ clientId?: string }`), matching the // `[FromQuery] Guid?` params both backend strata expose. The key name === // the pagespec's `relatedTabs[].relationFk` — the 360 related tabs call // `use{Plural}({ ...paging, [relationFk]: currentId })`. Axios only // serialises defined keys, so omitted filters change nothing on the wire. const fkParams = fkFilterFields(entity.fields) // Pagespec filter params — BOTH strata. The integration GetAll binds the // same ordered scalar params (scaffold-controller) and the generated // GetAllAsync applies them as guarded Where predicates (scaffold-business), // so declaring them here never advertises a param the server drops. Shared // SSOT derivation: the SAME ordered list the backend scaffolders expose. const listFilterParams = screenFilterParams(entity.screenFilters, fkParams) const listParamsType = `{ page?: number; pageSize?: number; search?: string; sortBy?: string; sortDir?: string${fkParams.map(p => `; ${p}?: string`).join('')}${listFilterParams.map(p => `; ${p.name}?: ${p.tsType}`).join('')} }` const getAllMethod = isScreenMode ? `getAll: async (${parentArg}params?: ${listParamsType}) => { ${dr} = await ${clientVar}.get>(\`\${${apiPathExpr}}/list\`, { params }); return data; }` : `getAll: async (${parentArg}params?: ${listParamsType}) => { ${dr} = await ${clientVar}.get>(${apiPathExpr}, { params }); return data; }` const getByIdMethod = isScreenMode ? `getById: async (${parentArg}id: string) => { ${dr} = await ${clientVar}.get<${e}DetailDto>(\`\${${apiPathExpr}}/detail/\${id}\`); return data; }` : `getById: async (${parentArg}id: string) => { ${dr} = await ${clientVar}.get<${e}DetailDto>(\`\${${apiPathExpr}}/\${id}\`); return data; }` const createMethod = isScreenMode ? `create: async (${parentArg}payload: Create${e}Dto) => { ${dr} = await ${clientVar}.post(\`\${${apiPathExpr}}/form\`, payload); return data; }` : `create: async (${parentArg}payload: Create${e}Dto) => { ${dr} = await ${clientVar}.post(${apiPathExpr}, payload); return data; }` const updateMethod = isScreenMode ? `update: async (${parentArg}id: string, payload: Update${e}Dto) => { await ${clientVar}.put(\`\${${apiPathExpr}}/form/\${id}\`, payload); }` : `update: async (${parentArg}id: string, payload: Update${e}Dto) => { await ${clientVar}.put(\`\${${apiPathExpr}}/\${id}\`, payload); }` // When the entity is nested, lookup / delete also need the parent ID // since their URL prefix is the parameterised API_PATH function. // INTEGRATION_PATH (used for delete/lookup fallback in screen mode) is a // flat URL and never carries a parent — but that combo is rejected above // (hasParent && isScreenMode throws), so lookupBase / deleteBase always // resolve to apiPathExpr when hasParent is true. const lookupExpr = isScreenMode ? '${INTEGRATION_PATH}' : `\${${apiPathExpr}}` const deleteExpr = isScreenMode ? '${INTEGRATION_PATH}' : `\${${apiPathExpr}}` files.push({ path: `${featurePath}/services/${eLower}Service.ts`, content: `${clientImport} import type { ${e}ListDto, ${e}DetailDto, Create${e}Dto, Update${e}Dto, ${e}RefDto, PaginatedResult${dashboardImports}${customTypeImports} } from '../types'; ${apiPathDecl};${integrationConstDecl} export const ${eLower}Service = { ${getAllMethod}, ${getByIdMethod}, getLookup: async (${parentArg}params?: { search?: string; page?: number; pageSize?: number }) => { ${dr} = await ${clientVar}.get>(\`${lookupExpr}/lookup\`, { params }); return data; }, ${createMethod}, ${updateMethod}, delete: async (${parentArg}id: string) => { await ${clientVar}.delete(\`${deleteExpr}/\${id}\`); }${dashboardServiceMembers}${customActionServiceMembers}, }; `, }) const hooksContent = generateStateHooks(e, eLower, plural, pluralLower, customActions, entity.hasDashboard ?? false, hasParent, parentIdParam, listParamsType, offlineWrite, outboxConst) files.push({ path: `${featurePath}/hooks/use${e}.ts`, content: hooksContent, }) if (offlineWrite) { files.push({ path: `${featurePath}/outbox/${eLower}Outbox.ts`, content: renderOutboxModule(e, eLower, outboxConst, outboxResourceKey, integrationPath), }) } } return files } /** * Offline-write outbox spec module for one entity — modeled line-for-line on * the socle's canonical `src/pwa/outbox/specs/timeEntryOutbox.ts` dogfood. * Emits three `OfflineMutationSpec`s (create/update/delete) sharing the * componentKey root as `type` prefix, the temp-id remap helper, and the two * deterministic overlay reducers `apply{E}ListOverlay` / `apply{E}DetailOverlay` * consumed by the generated hooks (`useOutboxOverlay`). * * Deliberate omissions (audited by DEV-PWA-007): * - `idempotencyKey` — the apiClient interceptor mints ONE UUID per logical * mutation at first attempt; the queued record replays that exact key. * - `onConflict` — default `server-wins` (the server's current record, sent in * the 409 body, stays authoritative; the queued edit drops — visible via the * `conflict` counter of `useOutboxStatus`). Custom strategies are a * hand-owned concern: mark the file `// @customised` and edit. */ function renderOutboxModule( e: string, eLower: string, outboxConst: string, resourceKey: string, basePath: string, ): string { return `import { OutboxRegistry, type OfflineMutationSpec, type OutboxRecord } from '@atlashub/smartstack'; import type { ${e}ListDto, ${e}DetailDto, Create${e}Dto, Update${e}Dto } from '../types'; /** * Offline-write outbox specs for ${e} — generated by scaffold-api-client * (pwa.offline: 'write'). The three specs share {@link ${outboxConst}_RESOURCE} * as \`type\` prefix so \`useOutboxOverlay(${outboxConst}_RESOURCE, data, apply${e}ListOverlay)\` * folds every pending write (create appears, delete hidden, update reflected) * over the fetched — offline, stale — data. * * \`idempotencyKey\` is intentionally OMITTED on every spec: the apiClient * interceptor generates ONE UUID per logical mutation on its first attempt and * the queued record replays that exact key. \`onConflict\` is intentionally * OMITTED: default \`server-wins\` (409 body stays authoritative, the queued * edit drops). Registered by \`src/extensions/outbox.generated.ts\` (re-run * aggregate-outbox after adding an entity). */ /** Resource key shared by the three specs (their \`type\` prefix) — equals the page componentKey root. */ export const ${outboxConst}_RESOURCE = '${resourceKey}'; export const ${outboxConst}_CREATE = '${resourceKey}.create'; export const ${outboxConst}_UPDATE = '${resourceKey}.update'; export const ${outboxConst}_DELETE = '${resourceKey}.delete'; /** The collection endpoint (create + list share this exact path — same source as ${eLower}Service). */ const BASE_PATH = '${basePath}'; const stripQuery = (url: string): string => url.split('?')[0]; /** The record id in \`BASE_PATH/{id}\`, or null for the collection or any deeper path. */ function idFromPath(path: string): string | null { if (!path.startsWith(BASE_PATH + '/')) return null; const rest = path.slice(BASE_PATH.length + 1); if (rest.length === 0 || rest.includes('/')) return null; return rest; } /** Shared temp-id → server-id remap for update/delete: rewrite the URL id + the snapshot id. */ function remapId(record: OutboxRecord, idMap: Record): OutboxRecord { const id = idFromPath(stripQuery(record.url)); const serverId = id ? idMap[id] : undefined; if (!id || !serverId) return record; const url = record.url.replace(BASE_PATH + '/' + id, BASE_PATH + '/' + serverId); const snap = record.optimisticSnapshot; const optimisticSnapshot = snap && typeof snap === 'object' && 'id' in snap ? { ...(snap as Record), id: serverId } : snap; return { ...record, url, optimisticSnapshot }; } // ── create ───────────────────────────────────────────────────────────────── const createSpec: OfflineMutationSpec = { type: ${outboxConst}_CREATE, match: ({ method, url }) => method === 'POST' && stripQuery(url) === BASE_PATH, optimisticApply: (_overlay, body, record) => { // Allocate the temp id and record it so the engine can remap it to the server id on replay. const tempId = crypto.randomUUID(); record.tempIds = [tempId]; // Optimistic snapshot: the request fields + the temp id. Server-computed // fields stay absent and fill in once the replay round-trips. return { ...body, id: tempId, createdAt: new Date().toISOString() } as unknown as ${e}ListDto; }, }; /** The overlay snapshot of a pending update: the record id + the patched fields. */ type ${e}Patch = Partial<${e}DetailDto> & { id: string }; // ── update ───────────────────────────────────────────────────────────────── const updateSpec: OfflineMutationSpec = { type: ${outboxConst}_UPDATE, match: ({ method, url }) => method === 'PUT' && idFromPath(stripQuery(url)) !== null, optimisticApply: (_overlay, body, record) => { const patch: ${e}Patch = { ...body, id: idFromPath(stripQuery(record.url)) ?? '' }; return patch; }, remapTempIds: remapId, }; /** The overlay snapshot of a pending delete: just the removed record id. */ interface ${e}Tombstone { id: string } // ── delete ───────────────────────────────────────────────────────────────── const deleteSpec: OfflineMutationSpec = { type: ${outboxConst}_DELETE, match: ({ method, url }) => method === 'DELETE' && idFromPath(stripQuery(url)) !== null, optimisticApply: (_overlay, _body, record) => { const tombstone: ${e}Tombstone = { id: idFromPath(stripQuery(record.url)) ?? '' }; return tombstone; }, remapTempIds: remapId, }; /** Register the three ${eLower} specs (idempotent — same-type re-register replaces in place). */ export function register${e}Outbox(): void { OutboxRegistry.register(createSpec); OutboxRegistry.register(updateSpec); OutboxRegistry.register(deleteSpec); } /** * Fold pending ${eLower} writes over a fetched item list: a pending CREATE * appears, a pending DELETE is hidden, a pending UPDATE is reflected. Records * arrive in \`createdAt\` order, so an offline create-then-edit resolves * correctly. Returns the SAME reference when nothing is affected. */ export function apply${e}ListOverlay( items: ${e}ListDto[] | null, records: readonly OutboxRecord[], ): ${e}ListDto[] | null { if (!items || records.length === 0) return items; let next = items; for (const record of records) { const snap = record.optimisticSnapshot; if (!snap || typeof snap !== 'object') continue; switch (record.type) { case ${outboxConst}_CREATE: { next = [...next, snap as ${e}ListDto]; break; } case ${outboxConst}_UPDATE: { const patch = snap as ${e}Patch; if (next.some(item => item.id === patch.id)) { next = next.map(item => (item.id === patch.id ? { ...item, ...patch } : item)); } break; } case ${outboxConst}_DELETE: { const { id } = snap as ${e}Tombstone; if (next.some(item => item.id === id)) next = next.filter(item => item.id !== id); break; } default: break; } } return next; } /** Fold pending writes over one fetched record: update patches it, a delete tombstone hides it. */ export function apply${e}DetailOverlay( item: ${e}DetailDto | null, records: readonly OutboxRecord[], ): ${e}DetailDto | null { if (!item || records.length === 0) return item; let next: ${e}DetailDto | null = item; for (const record of records) { if (next === null) break; const snap = record.optimisticSnapshot; if (!snap || typeof snap !== 'object') continue; switch (record.type) { case ${outboxConst}_UPDATE: { const patch = snap as ${e}Patch; if (patch.id === next.id) next = { ...next, ...patch }; break; } case ${outboxConst}_DELETE: { if ((snap as ${e}Tombstone).id === next.id) next = null; break; } default: break; } } return next; } ` } /** * Pre-classification feature locations (no `` segment). The CLI deletes any * that exist before writing the new `src/features////` files * so re-running /ba-develop MOVES the feature client (keeping it in lockstep with * the page imports, which also gained ``). */ export function legacyPaths(spec: ScaffoldApiClientInput): string[] { const prefix = `src/features/${spec.appCode.toLowerCase()}/` return generate(spec).map(f => f.path.startsWith(prefix) ? `src/features/${f.path.slice(prefix.length)}` : f.path, ) } function fieldCamel(name: string): string { return name.charAt(0).toLowerCase() + name.slice(1) } function mapTsType(type: string): string { const map: Record = { 'string': 'string', 'int': 'number', 'integer': 'number', 'number': 'number', 'decimal': 'number', 'bool': 'boolean', 'boolean': 'boolean', 'datetime': 'string', 'date': 'string', 'guid': 'string', } // Non-primitive types (enum names like 'OrderStatus', 'enum', 'text', // 'multiselect', 'textarea') fold to 'string' — the correct WIRE type: // SmartStack.Api registers JsonStringEnumConverter (MVC + SignalR), so an // enum travels as its string name. Throwing here used to kill the WHOLE // entity's client over one enum field (two of twelve entities in the // client run). tsTypeForColumn below always had the same fallback. return map[type.toLowerCase()] ?? 'string' } /** * Wave F1 — TS type for a pagespec column. MUST mirror `dotnetTypeFor` in * `templates/skills/development/backend/screen-controller/cli/scaffold-screen-controller/generate.ts` * one-to-one. The integration test in `__tests__/screen-strata-contract.test.ts` * locks the two tables together. */ function tsTypeForColumn(formatHint: string | undefined): string { const h = (formatHint ?? '').toLowerCase() if (h === 'currency' || h === 'number' || h === 'decimal') return 'number' if (h === 'integer' || h === 'count') return 'number' if (h === 'date' || h === 'datetime') return 'string' // ISO-8601 on the wire if (h === 'bool' || h === 'boolean') return 'boolean' if (h === 'guid' || h === 'uuid') return 'string' return 'string' } /** * Render the body of `${E}ListDto` from pagespec list-view columns. Returns * an empty body when no columns are provided — the surrounding template adds * `id` + `createdAt` so the interface is still well-formed (matches the * C# safe-minimum fallback). */ function renderListFromColumns(columns: ApiScreenColumn[] | undefined): string { if (!columns || columns.length === 0) return '' return columns.map(c => ` ${c.key}: ${tsTypeForColumn(c.formatHint)};`).join('\n') } /** * Render the body of `${E}DetailDto`. The MVP shape derives from the detail * pagespec when present; otherwise it falls back to the list columns (the * detail view is at minimum a superset of the list). Then — Bug 8 fix — appends * every `entity.fields` member that is NOT already present, so the edit form's * pre-fill (fetched as DetailDto) carries every field the user can edit. Drift * between display columns and editable fields stays harmless (extra fields are * tolerated by display code) instead of crashing the form with « field not * found ». * * Order : * 1. screenColumns.detail keys (display order from BA pagespec) * 2. screenColumns.list extras (when no detail-specific spec) * 3. entity.fields not yet covered (snapshots, hidden domain fields, etc.) * * Mirrors how `scaffold-screen-controller` derives `${E}DetailScreenDto` from * the same pagespec.columns[] — but the C# side has the domain entity * available natively, so the union is implicit there. */ function renderDetailFromColumns( detail: ApiScreenColumn[] | undefined, list: ApiScreenColumn[] | undefined, entityFields: { name: string; type: string; required: boolean }[], ): string { const columnSource = detail && detail.length > 0 ? detail : (list ?? []) const columnKeys = new Set(columnSource.map(c => c.key)) const columnLines = columnSource.map(c => ` ${c.key}: ${tsTypeForColumn(c.formatHint)};`) const extraLines = entityFields .filter(f => !columnKeys.has(fieldCamel(f.name))) .map(f => ` ${fieldCamel(f.name)}${f.required ? '' : '?'}: ${mapTsType(f.type)};`) return [...columnLines, ...extraLines].join('\n') } /** Convert kebab-case (`bulk-archive`) to camelCase (`bulkArchive`) for * TypeScript service member names. */ function toCamelCase(code: string): string { const parts = code.split('-') return parts[0]! + parts.slice(1).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('') } /** Convert kebab-case (`bulk-archive`) to PascalCase (`BulkArchive`) for * hook names (`useBulkArchive${E}`). */ function toPascalCase(code: string): string { return code.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('') } /** Built-in TS types we never re-declare. */ const TS_BUILTIN_TYPES = new Set([ 'void', 'string', 'number', 'boolean', 'string[]', 'unknown', 'never', 'object', 'any', ]) /** Compute the set of names already provided by the entity's own DTOs. We * must not re-emit these as custom types — they would collide with the * interfaces already produced for the entity. */ function entityGeneratedDtoNames(e: string): Set { return new Set([ `${e}ListDto`, `${e}DetailDto`, `Create${e}Dto`, `Update${e}Dto`, `${e}RefDto`, `${e}DashboardDto`, `${e}WidgetResult`, `${e}ChartPoint`, `${e}ChartSlice`, e, // alias ]) } /** * Bug 1 helper — collect every type name referenced by a customAction's * `payloadType` or `responseType` that needs declaration in the entity's * `types/index.ts`. Filters out TS builtins, the entity's own DTOs, and * generic forms like `T[]` (array-of-something handled separately). */ function collectCustomTypeNames( customActions: ApiCustomAction[], e: string, ): string[] { const generated = entityGeneratedDtoNames(e) const seen = new Set() const out: string[] = [] for (const a of customActions) { for (const name of [a.payloadType, a.responseType]) { if (!name) continue if (TS_BUILTIN_TYPES.has(name)) continue if (generated.has(name)) continue // Strip trailing `[]` (arrays of declared types still need the base type) const base = name.endsWith('[]') ? name.slice(0, -2) : name if (TS_BUILTIN_TYPES.has(base)) continue if (generated.has(base)) continue if (seen.has(base)) continue seen.add(base) out.push(base) } } return out } /** * Bug 1 helper — emit one `export interface ${name} { ... }` block per custom * type. Shape is taken from the matching `payloadShape` / `responseShape` * when provided; otherwise emit a permissive `[key: string]: unknown` body * plus a TODO comment so the BA author knows to firm it up. * * The returned string ends with two newlines (or is empty) so it can be * prefixed directly to the entity DTO block. */ function renderCustomTypeDeclarations( customActions: ApiCustomAction[], e: string, ): string { const generated = entityGeneratedDtoNames(e) // Map type name → first non-empty shape we encounter (payloadShape or // responseShape). When two actions reference the same name with different // shapes we pick the first — the BA contract assumes one canonical shape // per type name. const shapes = new Map>() const declared = new Set() const order: string[] = [] for (const a of customActions) { const pairs: Array<[string | null | undefined, Record | undefined]> = [ [a.payloadType, a.payloadShape], [a.responseType === 'void' ? null : a.responseType, a.responseShape], ] for (const [rawName, shape] of pairs) { if (!rawName) continue const base = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName if (TS_BUILTIN_TYPES.has(base)) continue if (generated.has(base)) continue if (!declared.has(base)) { declared.add(base) order.push(base) } if (shape && Object.keys(shape).length > 0 && !shapes.has(base)) { shapes.set(base, shape) } } } if (order.length === 0) return '' const blocks = order.map((name) => { const shape = shapes.get(name) if (shape) { const body = Object.entries(shape) .map(([field, tsType]) => ` ${field}: ${tsType};`) .join('\n') return `export interface ${name} {\n${body}\n}` } // Permissive placeholder — keeps the TypeScript compiler happy while // signalling that BA work is needed to firm up the shape. return `/**\n * TODO[BA-SHAPE]: replace this permissive placeholder with the real shape.\n * Populate \`customActions[].payloadShape\` (or \`responseShape\`) in the\n * scaffold-api-client spec so this interface lists the actual fields.\n */\nexport interface ${name} {\n [key: string]: unknown;\n}` }) return blocks.join('\n\n') + '\n\n' } /** * Emit a single service-object member (e.g. `archive: async (id) => ...`). * The leading comma is included so the member can be appended directly * after the existing CRUD methods. */ function emitCustomActionServiceMember( action: ApiCustomAction, clientVar: string, dr: string, parentArg: string, apiPathExpr: string, ): string { const memberName = toCamelCase(action.code) // ← `code` drives the TS identifier const urlSegment = action.endpoint ?? action.code // ← `endpoint` (when set) drives the URL const responseType = action.responseType const isVoid = responseType === 'void' const method = action.httpMethod ?? 'post' const isGet = method === 'get' // GET actions transport their collected parameters as a QUERY STRING // (`{ params }` — the same axios-style config the list/lookup members use), // mirroring the controller's `[FromQuery]` binding. All keys optional, like // the nullable server side. Bulk GET is not wired (a GET cannot transport // the selected ids either — the BA should model such an action as POST). const queryType = hookQueryType(action) // Wrap `apiPathExpr` for use inside an emitted JS template-literal: // non-nested → '${API_PATH}' // nested → '${API_PATH(parentId)}' const pathInTpl = `\${${apiPathExpr}}` let signature: string let body: string if (action.scope === 'row') { if (action.payloadType && !isGet) { signature = `(${parentArg}id: string, payload: ${action.payloadType})` body = isVoid ? `await ${clientVar}.${method}(\`${pathInTpl}/\${id}/${urlSegment}\`, payload);` : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/\${id}/${urlSegment}\`, payload); return data;` } else if (queryType) { signature = `(${parentArg}id: string, params?: ${queryType})` body = isVoid ? `await ${clientVar}.${method}(\`${pathInTpl}/\${id}/${urlSegment}\`, { params });` : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/\${id}/${urlSegment}\`, { params }); return data;` } else { signature = `(${parentArg}id: string)` body = isVoid ? `await ${clientVar}.${method}(\`${pathInTpl}/\${id}/${urlSegment}\`);` : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/\${id}/${urlSegment}\`); return data;` } } else if (action.scope === 'bulk') { if (action.payloadType && !isGet) { // Bulk WITH payload posts the selected ids AND the collected fields — // `{ ...payload, ids }`. Posting `payload` alone silently dropped the ids. signature = `(${parentArg}ids: string[], payload: ${action.payloadType})` body = isVoid ? `await ${clientVar}.${method}(\`${pathInTpl}/bulk/${urlSegment}\`, { ...payload, ids });` : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/bulk/${urlSegment}\`, { ...payload, ids }); return data;` } else { signature = isGet ? `(${parentArg.replace(/, $/, '')})` : `(${parentArg}ids: string[])` const args = isGet ? '' : `, { ids }` body = isVoid ? `await ${clientVar}.${method}(\`${pathInTpl}/bulk/${urlSegment}\`${args});` : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/bulk/${urlSegment}\`${args}); return data;` } } else { // header scope if (action.payloadType && !isGet) { signature = `(${parentArg}payload: ${action.payloadType})` body = isVoid ? `await ${clientVar}.${method}(\`${pathInTpl}/${urlSegment}\`, payload);` : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/${urlSegment}\`, payload); return data;` } else if (queryType) { signature = `(${parentArg}params?: ${queryType})` body = isVoid ? `await ${clientVar}.${method}(\`${pathInTpl}/${urlSegment}\`, { params });` : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/${urlSegment}\`, { params }); return data;` } else { signature = `(${parentArg.replace(/, $/, '')})` body = isVoid ? `await ${clientVar}.${method}(\`${pathInTpl}/${urlSegment}\`);` : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/${urlSegment}\`); return data;` } } return `, ${memberName}: async ${signature}: Promise<${responseType}> => { ${body} }` } /** * Emit a single React Query mutation hook for a custom action. Auto- * invalidates the LIST_KEY query and (for row-scope actions) the entity * detail query so the UI re-fetches after the mutation succeeds. */ function emitCustomActionHook(action: ApiCustomAction, entity: string, entityLower: string): string { const hookName = `use${toPascalCase(action.code)}${entity}` const memberName = toCamelCase(action.code) const responseType = action.responseType let mutationFnSignature: string let mutationFnCall: string let invalidations: string const queryType = hookQueryType(action) if (action.scope === 'row') { if (action.payloadType || queryType) { // GET + queryShape rides the same `{ id, payload }` shape as a body // action — the service member forwards `payload` as `{ params }`. mutationFnSignature = `({ id, payload }: { id: string; payload: ${action.payloadType ?? queryType} })` mutationFnCall = `${entityLower}Service.${memberName}(id, payload)` invalidations = `qc.invalidateQueries({ queryKey: LIST_KEY }); qc.invalidateQueries({ queryKey: ['${entityLower}', variables.id] });` } else { mutationFnSignature = `(id: string)` mutationFnCall = `${entityLower}Service.${memberName}(id)` invalidations = `qc.invalidateQueries({ queryKey: LIST_KEY }); qc.invalidateQueries({ queryKey: ['${entityLower}', variables] });` } } else if (action.scope === 'bulk') { if (action.payloadType) { mutationFnSignature = `({ ids, payload }: { ids: string[]; payload: ${action.payloadType} })` mutationFnCall = `${entityLower}Service.${memberName}(ids, payload)` } else { mutationFnSignature = `(ids: string[])` mutationFnCall = `${entityLower}Service.${memberName}(ids)` } invalidations = `qc.invalidateQueries({ queryKey: LIST_KEY });` } else { // header scope if (action.payloadType || queryType) { mutationFnSignature = `(payload: ${action.payloadType ?? queryType})` mutationFnCall = `${entityLower}Service.${memberName}(payload)` } else { mutationFnSignature = `()` mutationFnCall = `${entityLower}Service.${memberName}()` } invalidations = `qc.invalidateQueries({ queryKey: LIST_KEY });` } // For row-scope actions the variables symbol is the destructured arg // (id or { id, payload }). For bulk/header it's the full args object. // The `_, variables` signature works in all cases. return ` export function ${hookName}() { const qc = useQueryClient(); return useMutation<${responseType}, Error, ${rxQueryVariables(action)}>({ mutationFn: ${mutationFnSignature} => ${mutationFnCall}, onSuccess: (_data, variables) => { ${invalidations} }, }); }` } /** * Compute the TypeScript type of the `variables` argument for the * useMutation call — used as the third type parameter so onSuccess gets * a typed `variables` arg. */ function rxQueryVariables(action: ApiCustomAction): string { const queryType = hookQueryType(action) if (action.scope === 'row') { if (action.payloadType) return `{ id: string; payload: ${action.payloadType} }` // GET + queryShape: keep the SAME `{ id, payload }` variables shape as the // body case, so the page's CustomActionDialog wiring is verb-agnostic — // the service member turns `payload` into the query string. if (queryType) return `{ id: string; payload: ${queryType} }` return 'string' } if (action.scope === 'bulk') { return action.payloadType ? `{ ids: string[]; payload: ${action.payloadType} }` : 'string[]' } // header return action.payloadType ?? queryType ?? 'void' } /** Inline TS type literal of a GET action's query parameters — every key * optional, mirroring the controller's nullable `[FromQuery]` binding. */ function queryShapeTypeLiteral(shape: Record): string { return `{ ${Object.entries(shape).map(([k, t]) => `${k}?: ${t}`).join('; ')} }` } /** The hook-facing query type of a GET action, or null. Bulk GET stays * unwired (no transport for the selected ids on a GET — see the service * member emitter). */ function hookQueryType(action: ApiCustomAction): string | null { const isGet = (action.httpMethod ?? 'post') === 'get' return isGet && action.queryShape && action.scope !== 'bulk' ? queryShapeTypeLiteral(action.queryShape) : null } function generateReactQueryHooks( e: string, eLower: string, plural: string, pluralLower: string, customActions: ApiCustomAction[], hasDashboard: boolean, listParamsType: string = '{ page?: number; pageSize?: number; search?: string; sortBy?: string; sortDir?: string }', ): string { // Custom-action request/response types (e.g. JoindreDocumentRequest, PieceJointeDto) // are declared in ../types by renderCustomTypeDeclarations, but the hooks referenced // them bare → TS2304. Import them exactly like the service file does // (collectCustomTypeNames covers both payloadType and responseType, filters builtins). const customTypeNames = collectCustomTypeNames(customActions, e) const customTypeImports = customTypeNames.length > 0 ? `, ${customTypeNames.join(', ')}` : '' return `import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { ${eLower}Service } from '../services/${eLower}Service'; import type { Create${e}Dto, Update${e}Dto${customTypeImports} } from '../types'; const LIST_KEY = ['${pluralLower}'] as const; export function use${plural}(params?: ${listParamsType}) { return useQuery({ queryKey: [...LIST_KEY, params], queryFn: () => ${eLower}Service.getAll(params) }); } export function use${e}(id: string) { return useQuery({ queryKey: ['${eLower}', id], queryFn: () => ${eLower}Service.getById(id), enabled: Boolean(id) }); } export function useCreate${e}() { const qc = useQueryClient(); return useMutation({ mutationFn: (data: Create${e}Dto) => ${eLower}Service.create(data), onSuccess: () => qc.invalidateQueries({ queryKey: LIST_KEY }) }); } export function useUpdate${e}() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, data }: { id: string; data: Update${e}Dto }) => ${eLower}Service.update(id, data), onSuccess: () => qc.invalidateQueries({ queryKey: LIST_KEY }) }); } export function useDelete${e}() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => ${eLower}Service.delete(id), onSuccess: () => qc.invalidateQueries({ queryKey: LIST_KEY }) }); } export function use${e}Lookup(params?: { search?: string; page?: number; pageSize?: number }) { return useQuery({ queryKey: ['${eLower}', 'lookup', params], queryFn: () => ${eLower}Service.getLookup(params), placeholderData: (previous) => previous, staleTime: 60_000 }); } ${customActions.map((a) => emitCustomActionHook(a, e, eLower)).join('\n')}${hasDashboard ? ` export function useDashboard${e}(params?: { startDate?: string; endDate?: string }) { const query = useQuery({ queryKey: ['${eLower}', 'dashboard', params], queryFn: () => ${eLower}Service.getDashboard(params) }); return { data: query.data, isLoading: query.isLoading, error: query.error }; }` : ''} ` } function generateStateHooks( e: string, eLower: string, plural: string, _pluralLower: string, customActions: ApiCustomAction[], hasDashboard: boolean, hasParent: boolean = false, parentIdParam: string = 'parentId', listParamsType: string = '{ page?: number; pageSize?: number; search?: string; sortBy?: string; sortDir?: string }', offlineWrite: boolean = false, outboxConst: string = '', ): string { // Offline-write overlay wiring: the list/detail hooks fold the entity's // pending outbox writes over the freshly-fetched server data — the app has // no query cache, so the outbox owns the optimistic state. Empty strings in // every other mode keep the legacy emission byte-identical. const outboxImports = offlineWrite ? `\nimport { useOutboxOverlay } from '@atlashub/smartstack';\nimport { ${outboxConst}_RESOURCE, apply${e}ListOverlay, apply${e}DetailOverlay } from '../outbox/${eLower}Outbox';` : '' const listReturn = offlineWrite ? ` const overlaidItems = useOutboxOverlay(${outboxConst}_RESOURCE, data?.items ?? null, apply${e}ListOverlay); return { data: data ? { ...data, items: overlaidItems ?? data.items } : data, isLoading, error, refetch };` : ` return { data, isLoading, error, refetch };` const detailReturn = offlineWrite ? ` const overlaid = useOutboxOverlay(${outboxConst}_RESOURCE, data ?? null, apply${e}DetailOverlay); return { data: overlaid ?? undefined, isLoading, refetch };` : ` return { data, isLoading, refetch };` // When the entity is nested under a parent, the service methods take // `parentId` as their first argument — and so do the hooks. We thread the // value through both the hook signature and the underlying service call. // For non-nested entities, `parentSig` / `parentCall` / `parentDep` are all // empty strings, so the emitted code stays byte-identical to the legacy form. const parentSig = hasParent ? `${parentIdParam}: string, ` : '' const parentCall = hasParent ? `${parentIdParam}, ` : '' // useEffect deps must include parentId so the hook re-fetches when the // parent changes (e.g. selecting a different Site while viewing Rues). const parentDep = hasParent ? `${parentIdParam}, ` : '' // Mutation arg name when the only parameter is parentId (delete + custom row actions). const parentArgAlone = hasParent ? `${parentIdParam}: string` : '' // Bug 3 fix — propagate the correct number of arguments from the service // member signature to the hook's `mutateAsync`. The previous one-size-fits-all // shape `(idOrPayload: unknown)` worked only for row-scope + no payload; every // other combination got TS2554 « expected 2 arguments, got 1 ». We now match // exactly what `emitCustomActionServiceMember` declared (lines 393, 407, 423), // by scope × payloadType: // row + payload → ({ id, payload }) → service(id, payload) // row + nothing → (id) → service(id) // bulk + payload → ({ ids, payload }) → service(ids, payload) → POST { ...payload, ids } // bulk + nothing → (ids: string[]) → service(ids) // header + payload → (payload) → service(payload) // header + nothing → () → service() const customHooks = customActions.map(a => { const pascalCode = a.code.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('') const serviceMethodName = a.code.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase()) const responseType = a.responseType ?? 'void' const promiseT = `Promise<${responseType}>` // GET + queryShape rides the SAME variables shape as a body action — the // service member forwards `payload` as the `{ params }` query string, so // the page's CustomActionDialog wiring stays verb-agnostic. const queryType = hookQueryType(a) let mutateSig: string let callArgs: string if (a.scope === 'row') { if (a.payloadType || queryType) { mutateSig = `({ id, payload }: { id: string; payload: ${a.payloadType ?? queryType} })` callArgs = 'id, payload' } else { mutateSig = `(id: string)` callArgs = 'id' } } else if (a.scope === 'bulk') { if (a.payloadType) { // Bulk carries the selection ids AND the collected payload — the service // member merges them into `{ ...payload, ids }` on the wire. mutateSig = `({ ids, payload }: { ids: string[]; payload: ${a.payloadType} })` callArgs = 'ids, payload' } else { mutateSig = `(ids: string[])` callArgs = 'ids' } } else { // header scope if (a.payloadType || queryType) { mutateSig = `(payload: ${a.payloadType ?? queryType})` callArgs = 'payload' } else { mutateSig = `()` callArgs = '' } } // When the entity is nested, every service member takes parentId as its // first arg — thread it through the call site. We use parentArgAlone in // the hook signature (closure pattern, identical to useDelete/useUpdate) // rather than re-passing through mutateAsync. const fullCallArgs = hasParent ? (callArgs ? `${parentIdParam}, ${callArgs}` : parentIdParam) : callArgs return ` export function use${pascalCode}${e}(${parentArgAlone}) { const [isPending, setIsPending] = useState(false); const mutateAsync = async ${mutateSig}: ${promiseT} => { setIsPending(true); try { return await ${eLower}Service.${serviceMethodName}(${fullCallArgs}); } finally { setIsPending(false); } }; return { mutateAsync, isPending }; }` }).join('\n') // Custom-action request/response types (e.g. JoindreDocumentRequest, PieceJointeDto) // referenced in mutateSig/promiseT above are declared in ../types but were not // imported here → TS2304. Import them like the service file (both payload + response). const customTypeNames = collectCustomTypeNames(customActions, e) const customTypeImports = customTypeNames.length > 0 ? `, ${customTypeNames.join(', ')}` : '' return `import { useState, useEffect, useCallback } from 'react'; import { ${eLower}Service } from '../services/${eLower}Service'; import type { Create${e}Dto, Update${e}Dto${customTypeImports} } from '../types';${outboxImports} export function use${plural}(${parentSig}params?: ${listParamsType}) { const [data, setData] = useState> | undefined>(); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const refetch = useCallback(() => { setIsLoading(true); ${eLower}Service.getAll(${parentCall}params).then(setData).catch(setError).finally(() => setIsLoading(false)); }, [${parentDep}JSON.stringify(params)]); useEffect(() => { refetch(); }, [refetch]); ${listReturn} } export function use${e}(${parentSig}id: string) { const [data, setData] = useState> | undefined>(); const [isLoading, setIsLoading] = useState(true); const refetch = useCallback(() => { if (!id) return; setIsLoading(true); ${eLower}Service.getById(${parentCall}id).then(setData).finally(() => setIsLoading(false)); }, [${parentDep}id]); useEffect(() => { refetch(); }, [refetch]); ${detailReturn} } export function useCreate${e}(${parentArgAlone}) { const [isPending, setIsPending] = useState(false); const mutateAsync = async (data: Create${e}Dto) => { setIsPending(true); try { return await ${eLower}Service.create(${parentCall}data); } finally { setIsPending(false); } }; return { mutateAsync, isPending }; } export function useUpdate${e}(${parentArgAlone}) { const [isPending, setIsPending] = useState(false); const mutateAsync = async ({ id, data }: { id: string; data: Update${e}Dto }) => { setIsPending(true); try { return await ${eLower}Service.update(${parentCall}id, data); } finally { setIsPending(false); } }; return { mutateAsync, isPending }; } export function useDelete${e}(${parentArgAlone}) { const [isPending, setIsPending] = useState(false); const mutateAsync = async (id: string) => { setIsPending(true); try { return await ${eLower}Service.delete(${parentCall}id); } finally { setIsPending(false); } }; return { mutateAsync, isPending }; } export function use${e}Lookup(${parentSig}params?: { search?: string; page?: number; pageSize?: number }) { const [data, setData] = useState> | undefined>(); useEffect(() => { ${eLower}Service.getLookup(${parentCall}params).then(setData); }, [${parentDep}JSON.stringify(params)]); return { data }; } ${customHooks}${hasDashboard ? ` export function useDashboard${e}(${parentSig}params?: { startDate?: string; endDate?: string }) { const [data, setData] = useState>>(); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { setIsLoading(true); ${eLower}Service.getDashboard(${parentCall}params) .then((d) => { setData(d); setError(null); }) .catch((err) => setError(err)) .finally(() => setIsLoading(false)); }, [${parentDep}JSON.stringify(params)]); return { data, isLoading, error }; }` : ''} ` }