@isi-ui7/bos7-shared 0.2.3 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,495 @@
1
+ /**
2
+ * value_schema → bos7-shared form adapter (Wave C, core7-devroot#569).
3
+ *
4
+ * Turns a policy7 `parameter_categories.value_schema` (plain JSON Schema +
5
+ * the `x-ui` UI-hint extension + `x-rules` cross-field rules) into the
6
+ * `FormSection[]` + cross-field validator consumed by the EXISTING
7
+ * `CrudSchemaPage`. No new renderer.
8
+ *
9
+ * Pure: no fetch, no React. The category page fetches the schema (from #568),
10
+ * then composes `[scopeSection(t), ...buildValueSections(schema, t).sections]`
11
+ * and passes `validate` to the form.
12
+ *
13
+ * Spec: docs/plans/integration/PLAN-WC-XUI-CONVENTION.md
14
+ */
15
+ import type { EditableColumnDef, EditableColumnType } from "@isi-ui7/editable-table";
16
+ import { INDONESIA_CURRENCY_CONFIG } from "./form-numeric";
17
+ import type {
18
+ DetailRowsConfig,
19
+ FormField,
20
+ FormFieldNumericConfig,
21
+ FormFieldOption,
22
+ FormFieldType,
23
+ FormMode,
24
+ FormSection,
25
+ } from "./form-types";
26
+
27
+ // ── i18n translate function ─────────────────────────────────────────────────
28
+ export type T = (key: string, fallback?: string) => string;
29
+
30
+ // ── JSON Schema + x-ui / x-rules shapes (the subset Wave C interprets) ───────
31
+
32
+ export type XUiNumeric = {
33
+ kind: "currency" | "percent" | "integer" | "phone";
34
+ /** currency: name of the sibling field holding the currency code. */
35
+ currencyField?: string;
36
+ precisionByCurrency?: Record<string, number>;
37
+ defaultPrecision?: number;
38
+ /** percent. */
39
+ maxFractionDigits?: number;
40
+ /** phone. */
41
+ minDigits?: number;
42
+ maxDigits?: number;
43
+ allowedPrefixes?: string[];
44
+ };
45
+
46
+ export type XUiOption = { value: string; labelKey?: string; label?: string };
47
+
48
+ export type XUiWidget =
49
+ | "text"
50
+ | "textarea"
51
+ | "number"
52
+ | "select"
53
+ | "date"
54
+ | "toggle"
55
+ | "lookup"
56
+ | "detail-rows";
57
+
58
+ /** Per-property `x-ui` hint object (also array-level for `detail-rows`). */
59
+ export type XUi = {
60
+ widget?: XUiWidget;
61
+ labelKey?: string;
62
+ label?: string;
63
+ helpKey?: string;
64
+ help?: string;
65
+ /** 1–12 grid width. Default 6. */
66
+ span?: number;
67
+ /** Sort order within an inferred section. */
68
+ order?: number;
69
+ /** Modes in which the field renders read-only, e.g. ["edit"]. */
70
+ readonlyOn?: FormMode[];
71
+ placeholder?: string;
72
+ numeric?: XUiNumeric;
73
+ /** `select` options (overrides enum labels). */
74
+ options?: XUiOption[];
75
+ /** Named dynamic-options source — deferred in Wave C (resolved by FE). */
76
+ optionsRef?: string;
77
+ lookup?: { api: string; valueField?: string; displayFields?: string[] };
78
+ toggle?: { valueOn?: unknown; valueOff?: unknown };
79
+ /** array `detail-rows`: max number of rows. */
80
+ maxRows?: number;
81
+ };
82
+
83
+ /** Root-level `x-ui` (layout grouping + density). */
84
+ export type XUiRoot = XUi & {
85
+ layout?: {
86
+ sections?: Array<{ titleKey?: string; title?: string; fields: string[] }>;
87
+ };
88
+ density?: string;
89
+ };
90
+
91
+ export type JSONSchema = {
92
+ type?: string;
93
+ properties?: Record<string, JSONSchema>;
94
+ required?: string[];
95
+ enum?: unknown[];
96
+ minimum?: number;
97
+ maximum?: number;
98
+ minLength?: number;
99
+ maxLength?: number;
100
+ pattern?: string;
101
+ format?: string;
102
+ default?: unknown;
103
+ items?: JSONSchema;
104
+ "x-ui"?: XUiRoot;
105
+ "x-rules"?: XRule[];
106
+ };
107
+
108
+ export type XRuleOp = "lte" | "gte" | "lt" | "gt" | "eq" | "required-if";
109
+
110
+ export type XRule = {
111
+ op: XRuleOp;
112
+ left: string;
113
+ /** Other field to compare against (lte/gte/lt/gt/eq, or the condition field of required-if). */
114
+ right?: string;
115
+ /** Literal to compare against (eq) or the condition value (required-if). */
116
+ value?: unknown;
117
+ message?: string;
118
+ /** i18n key for the message (preferred over `message`). */
119
+ messageKey?: string;
120
+ };
121
+
122
+ type ValueData = Record<string, unknown>;
123
+ export type ValueValidatorFn = (data: ValueData) => Partial<Record<string, string>>;
124
+
125
+ // ── Helpers ───────────────────────────────────────────────────────────────────
126
+
127
+ const DEFAULT_SPAN = 6;
128
+
129
+ function resolveLabel(xui: XUi | undefined, fallback: string, t: T): string {
130
+ if (xui?.labelKey) return t(xui.labelKey, xui.label ?? fallback);
131
+ return xui?.label ?? fallback;
132
+ }
133
+
134
+ function resolveHelp(xui: XUi | undefined, t: T): string | undefined {
135
+ if (xui?.helpKey) return t(xui.helpKey, xui.help ?? "");
136
+ return xui?.help;
137
+ }
138
+
139
+ /** JSON Schema scalar type → bos7-shared field type, honoring `x-ui.widget`. */
140
+ function inferFieldType(schema: JSONSchema, xui: XUi | undefined): FormFieldType {
141
+ if (xui?.widget) {
142
+ if (xui.widget === "lookup") return "lookup";
143
+ if (xui.widget === "detail-rows") return "detail-rows";
144
+ return xui.widget as FormFieldType;
145
+ }
146
+ switch (schema.type) {
147
+ case "boolean":
148
+ return "toggle";
149
+ case "integer":
150
+ case "number":
151
+ return "number";
152
+ case "array":
153
+ return "detail-rows";
154
+ case "string":
155
+ default:
156
+ if (Array.isArray(schema.enum)) return "select";
157
+ if (schema.format === "date" || schema.format === "date-time") return "date";
158
+ return "text";
159
+ }
160
+ }
161
+
162
+ function buildNumeric(
163
+ schema: JSONSchema,
164
+ xui: XUi | undefined,
165
+ ): FormFieldNumericConfig<ValueData> | undefined {
166
+ const n = xui?.numeric;
167
+ if (n) {
168
+ switch (n.kind) {
169
+ case "currency":
170
+ return {
171
+ kind: "currency",
172
+ currencyField: (n.currencyField ?? "currency") as keyof ValueData,
173
+ precisionByCurrency: n.precisionByCurrency ?? INDONESIA_CURRENCY_CONFIG.precisionByCurrency,
174
+ defaultPrecision: n.defaultPrecision ?? INDONESIA_CURRENCY_CONFIG.defaultPrecision,
175
+ };
176
+ case "percent":
177
+ return { kind: "percent", maxFractionDigits: n.maxFractionDigits ?? 2 };
178
+ case "integer":
179
+ return { kind: "integer" };
180
+ case "phone":
181
+ return {
182
+ kind: "phone",
183
+ minDigits: n.minDigits ?? 0,
184
+ maxDigits: n.maxDigits ?? 30,
185
+ allowedPrefixes: n.allowedPrefixes,
186
+ };
187
+ }
188
+ }
189
+ // No explicit numeric hint: integer JSON type still implies an integer field.
190
+ if (schema.type === "integer") return { kind: "integer" };
191
+ return undefined;
192
+ }
193
+
194
+ function buildOptions(schema: JSONSchema, xui: XUi | undefined, t: T): FormFieldOption[] | undefined {
195
+ if (xui?.options?.length) {
196
+ return xui.options.map((o) => ({
197
+ value: o.value,
198
+ label: o.labelKey ? t(o.labelKey, o.label ?? o.value) : (o.label ?? o.value),
199
+ }));
200
+ }
201
+ if (Array.isArray(schema.enum)) {
202
+ return schema.enum.map((e) => ({ value: String(e), label: String(e) }));
203
+ }
204
+ return undefined;
205
+ }
206
+
207
+ /** plain JSON Schema constraints → FieldValidation (per-field). */
208
+ function buildValidation(schema: JSONSchema, isRequired: boolean): FormField<ValueData>["validation"] {
209
+ const v: NonNullable<FormField<ValueData>["validation"]> = {};
210
+ if (isRequired) v.required = true;
211
+ if (typeof schema.minimum === "number") v.min = schema.minimum;
212
+ if (typeof schema.maximum === "number") v.max = schema.maximum;
213
+ if (typeof schema.minLength === "number") v.minLength = schema.minLength;
214
+ if (typeof schema.maxLength === "number") v.maxLength = schema.maxLength;
215
+ if (typeof schema.pattern === "string") v.pattern = new RegExp(schema.pattern);
216
+ return Object.keys(v).length ? v : undefined;
217
+ }
218
+
219
+ // ── detail-rows (array of object) ─────────────────────────────────────────────
220
+
221
+ function defaultForColumn(schema: JSONSchema): unknown {
222
+ if (schema.default !== undefined) return schema.default;
223
+ switch (schema.type) {
224
+ case "integer":
225
+ case "number":
226
+ return 0;
227
+ case "boolean":
228
+ return false;
229
+ default:
230
+ return "";
231
+ }
232
+ }
233
+
234
+ function buildColumnType(schema: JSONSchema, xui: XUi | undefined, t: T): EditableColumnType {
235
+ const type = inferFieldType(schema, xui);
236
+ const n = xui?.numeric;
237
+ if (n?.kind === "currency") {
238
+ return {
239
+ type: "currency",
240
+ precision: n.defaultPrecision ?? INDONESIA_CURRENCY_CONFIG.defaultPrecision,
241
+ };
242
+ }
243
+ if (n?.kind === "percent") {
244
+ return { type: "number", decimals: n.maxFractionDigits ?? 2, min: schema.minimum, max: schema.maximum };
245
+ }
246
+ if (n?.kind === "integer" || schema.type === "integer") {
247
+ return { type: "number", decimals: 0, min: schema.minimum, max: schema.maximum };
248
+ }
249
+ if (type === "number") {
250
+ return { type: "number", min: schema.minimum, max: schema.maximum };
251
+ }
252
+ if (type === "select") {
253
+ return { type: "select", options: buildOptions(schema, xui, t) ?? [] };
254
+ }
255
+ if (type === "date") return { type: "date" };
256
+ return { type: "text", maxLength: schema.maxLength, placeholder: xui?.placeholder };
257
+ }
258
+
259
+ function buildDetailRows(schema: JSONSchema, t: T): DetailRowsConfig | undefined {
260
+ const items = schema.items;
261
+ if (!items?.properties) return undefined;
262
+ const props = items.properties;
263
+ const required = new Set(items.required ?? []);
264
+ const entries = Object.entries(props);
265
+
266
+ const columns: EditableColumnDef[] = entries.map(([name, propSchema]) => {
267
+ const xui = propSchema["x-ui"];
268
+ return {
269
+ field: name,
270
+ header: resolveLabel(xui, name, t),
271
+ columnType: buildColumnType(propSchema, xui, t),
272
+ required: required.has(name),
273
+ };
274
+ });
275
+
276
+ const newRowFactory = (): Record<string, unknown> => {
277
+ const row: Record<string, unknown> = {};
278
+ for (const [name, propSchema] of entries) row[name] = defaultForColumn(propSchema);
279
+ return row;
280
+ };
281
+
282
+ const validateRow = (row: Record<string, unknown>): Record<string, string> => {
283
+ const errs: Record<string, string> = {};
284
+ for (const name of required) {
285
+ const val = row[name];
286
+ if (val === undefined || val === null || val === "") {
287
+ errs[name] = t("bos7.validRequired", "Wajib diisi");
288
+ }
289
+ }
290
+ return errs;
291
+ };
292
+
293
+ const xuiArr = schema["x-ui"];
294
+ return { columns, newRowFactory, validateRow, maxRows: xuiArr?.maxRows };
295
+ }
296
+
297
+ // ── Field builder ──────────────────────────────────────────────────────────────
298
+
299
+ function buildField(name: string, schema: JSONSchema, isRequired: boolean, t: T): FormField<ValueData> {
300
+ const xui = schema["x-ui"];
301
+ const type = inferFieldType(schema, xui);
302
+ const field: FormField<ValueData> = {
303
+ key: name,
304
+ label: resolveLabel(xui, name, t),
305
+ type,
306
+ span: xui?.span ?? (type === "detail-rows" ? 12 : DEFAULT_SPAN),
307
+ };
308
+
309
+ const help = resolveHelp(xui, t);
310
+ if (help) field.helperText = help;
311
+ if (xui?.placeholder) field.placeholder = xui.placeholder;
312
+ if (typeof schema.maxLength === "number") field.maxLength = schema.maxLength;
313
+
314
+ if (xui?.readonlyOn?.length) {
315
+ const modes = xui.readonlyOn;
316
+ field.readonly = (mode) => modes.includes(mode);
317
+ }
318
+
319
+ if (type === "select") {
320
+ const options = buildOptions(schema, xui, t);
321
+ if (options) field.options = options;
322
+ }
323
+
324
+ if (type === "number") {
325
+ const numeric = buildNumeric(schema, xui);
326
+ if (numeric) field.numeric = numeric;
327
+ field.inputMode = numeric?.kind === "integer" ? "numeric" : "decimal";
328
+ }
329
+
330
+ if (type === "toggle" && xui?.toggle) {
331
+ field.toggle = { valueOn: xui.toggle.valueOn, valueOff: xui.toggle.valueOff };
332
+ }
333
+
334
+ if (type === "detail-rows") {
335
+ const detail = buildDetailRows(schema, t);
336
+ if (detail) field.detailRows = detail;
337
+ }
338
+
339
+ const validation = buildValidation(schema, isRequired);
340
+ if (validation) field.validation = validation;
341
+
342
+ return field;
343
+ }
344
+
345
+ // ── x-rules → cross-field validator ─────────────────────────────────────────────
346
+
347
+ function toNumber(v: unknown): number | undefined {
348
+ if (v === null || v === undefined || v === "") return undefined;
349
+ const n = typeof v === "number" ? v : Number(v);
350
+ return Number.isFinite(n) ? n : undefined;
351
+ }
352
+
353
+ function isEmpty(v: unknown): boolean {
354
+ return v === null || v === undefined || v === "";
355
+ }
356
+
357
+ function ruleMessage(rule: XRule, t: T, fallback: string): string {
358
+ if (rule.messageKey) return t(rule.messageKey, rule.message ?? fallback);
359
+ return rule.message ?? fallback;
360
+ }
361
+
362
+ /**
363
+ * Build a form-level validator from `x-rules`. Comparison ops skip when either
364
+ * operand is empty/non-numeric (per-field `required` handles emptiness), so the
365
+ * cross-field check only fires on otherwise-complete input. Returns errors keyed
366
+ * by the offending field so the page blocks submit before `startWorkflow`.
367
+ */
368
+ export function buildXRulesValidator(rules: XRule[] | undefined, t: T): ValueValidatorFn {
369
+ if (!rules?.length) return () => ({});
370
+ return (data) => {
371
+ const errors: Partial<Record<string, string>> = {};
372
+ for (const rule of rules) {
373
+ if (errors[rule.left]) continue; // first failing rule per field wins
374
+ switch (rule.op) {
375
+ case "lte":
376
+ case "gte":
377
+ case "lt":
378
+ case "gt": {
379
+ const left = toNumber(data[rule.left]);
380
+ const right = rule.right !== undefined ? toNumber(data[rule.right]) : toNumber(rule.value);
381
+ if (left === undefined || right === undefined) break;
382
+ const ok =
383
+ rule.op === "lte" ? left <= right :
384
+ rule.op === "gte" ? left >= right :
385
+ rule.op === "lt" ? left < right :
386
+ left > right;
387
+ if (!ok) errors[rule.left] = ruleMessage(rule, t, "Nilai tidak valid");
388
+ break;
389
+ }
390
+ case "eq": {
391
+ const expected = rule.right !== undefined ? data[rule.right] : rule.value;
392
+ if (isEmpty(data[rule.left])) break;
393
+ if (data[rule.left] !== expected) {
394
+ errors[rule.left] = ruleMessage(rule, t, "Nilai tidak sesuai");
395
+ }
396
+ break;
397
+ }
398
+ case "required-if": {
399
+ const condField = rule.right !== undefined ? data[rule.right] : undefined;
400
+ const conditionMet =
401
+ rule.value !== undefined ? condField === rule.value : !isEmpty(condField);
402
+ if (conditionMet && isEmpty(data[rule.left])) {
403
+ errors[rule.left] = ruleMessage(rule, t, "Wajib diisi");
404
+ }
405
+ break;
406
+ }
407
+ }
408
+ }
409
+ return errors;
410
+ };
411
+ }
412
+
413
+ // ── Section assembly ─────────────────────────────────────────────────────────
414
+
415
+ function buildSections(
416
+ schema: JSONSchema,
417
+ fieldsByName: Map<string, FormField<ValueData>>,
418
+ orderByName: Map<string, number>,
419
+ t: T,
420
+ ): FormSection<ValueData>[] {
421
+ const layout = schema["x-ui"]?.layout;
422
+
423
+ if (layout?.sections?.length) {
424
+ const used = new Set<string>();
425
+ const sections: FormSection<ValueData>[] = layout.sections.map((sec) => {
426
+ const fields = sec.fields
427
+ .map((name) => {
428
+ used.add(name);
429
+ return fieldsByName.get(name);
430
+ })
431
+ .filter((f): f is FormField<ValueData> => Boolean(f));
432
+ const title = sec.titleKey ? t(sec.titleKey, sec.title ?? sec.titleKey) : sec.title;
433
+ return { title: title || undefined, fields };
434
+ });
435
+
436
+ // Any property not referenced by a layout section falls into a trailing group.
437
+ const leftover = [...fieldsByName.keys()].filter((name) => !used.has(name));
438
+ if (leftover.length) {
439
+ sections.push({
440
+ fields: leftover
441
+ .sort((a, b) => (orderByName.get(a) ?? 0) - (orderByName.get(b) ?? 0))
442
+ .map((name) => fieldsByName.get(name)!),
443
+ });
444
+ }
445
+ return sections;
446
+ }
447
+
448
+ // No layout hint → single "Value" section, fields sorted by x-ui.order.
449
+ const ordered = [...fieldsByName.keys()].sort(
450
+ (a, b) => (orderByName.get(a) ?? 0) - (orderByName.get(b) ?? 0),
451
+ );
452
+ return [
453
+ {
454
+ title: t("policy.section.value", "Value"),
455
+ fields: ordered.map((name) => fieldsByName.get(name)!),
456
+ },
457
+ ];
458
+ }
459
+
460
+ // ── Public adapter ──────────────────────────────────────────────────────────────
461
+
462
+ /**
463
+ * Build the "value" form section(s) + a cross-field validator from a policy7
464
+ * `value_schema`. Pure; no fetch.
465
+ *
466
+ * @example
467
+ * const { sections, validate } = buildValueSections(valueSchema, t);
468
+ * const form: CrudForm<Data> = {
469
+ * ...,
470
+ * layout: { type: "single-page", sections: [scopeSection(t), ...sections] },
471
+ * validate: (data) => ({ ...scopeValidate(data), ...validate(data) }),
472
+ * };
473
+ */
474
+ export function buildValueSections(
475
+ valueSchema: JSONSchema,
476
+ t: T,
477
+ ): { sections: FormSection<ValueData>[]; validate: ValueValidatorFn } {
478
+ const props = valueSchema.properties ?? {};
479
+ const required = new Set(valueSchema.required ?? []);
480
+
481
+ const fieldsByName = new Map<string, FormField<ValueData>>();
482
+ const orderByName = new Map<string, number>();
483
+
484
+ Object.entries(props).forEach(([name, propSchema], index) => {
485
+ fieldsByName.set(name, buildField(name, propSchema, required.has(name), t));
486
+ // Sort key: explicit x-ui.order, else original declaration order.
487
+ const order = propSchema["x-ui"]?.order;
488
+ orderByName.set(name, typeof order === "number" ? order : index);
489
+ });
490
+
491
+ const sections = buildSections(valueSchema, fieldsByName, orderByName, t);
492
+ const validate = buildXRulesValidator(valueSchema["x-rules"], t);
493
+
494
+ return { sections, validate };
495
+ }
package/src/index.ts CHANGED
@@ -17,4 +17,7 @@ export * from './notifications-bff';
17
17
  export * from './crud-types';
18
18
  export * from './crud-hooks';
19
19
  export * from './form-types';
20
+ export * from './form-value-schema';
20
21
  export * from './i18n';
22
+ export { proxyBackendPost, proxyQueryRoute } from './data-table/proxy';
23
+ export type { ProxyBackendPostOptions, ProxyQueryRouteOptions } from './data-table/proxy';
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import type { ReactNode } from "react";
3
+ import type { ElementType, ReactNode } from "react";
4
4
  import { useCallback, useEffect, useMemo, useState } from "react";
5
5
  import { useI18n, useUi7Labels, type Ui7Locale } from "@isi-ui7/i18n";
6
6
  import { useAuth as defaultUseAuth } from "../auth7";
@@ -20,7 +20,22 @@ export interface AppShellAuthState {
20
20
  logout: () => Promise<void> | void;
21
21
  }
22
22
 
23
- export type ShellNavItemLike = any;
23
+ /**
24
+ * Structural shape of a side-nav item, mirroring ui-shell's `ShellNavItem`.
25
+ * `label`/`href` are required to stay mutually assignable with `ShellNavItem`:
26
+ * this type is used both covariantly (`navItems`) and contravariantly
27
+ * (`markActive`'s parameter), so consumers passing `ShellNavItem`-typed values
28
+ * would otherwise fail under `strict`. The index signature keeps it permissive
29
+ * for extra metadata (e.g. `requiredPermission`).
30
+ */
31
+ export interface ShellNavItemLike {
32
+ label: string;
33
+ href: string;
34
+ isActive?: boolean;
35
+ icon?: string;
36
+ children?: ShellNavItemLike[];
37
+ [key: string]: unknown;
38
+ }
24
39
 
25
40
  export interface ShellNotificationLike {
26
41
  id?: string;
@@ -57,39 +72,6 @@ interface NotificationBellOptionsLike {
57
72
  onNavigate?: (href: string) => void;
58
73
  }
59
74
 
60
- interface UiShellLikeProps {
61
- productName: string;
62
- headerPrefix: string;
63
- headerThemeVariant: any;
64
- navItems: any[];
65
- sideNavItems: ShellNavItemLike[];
66
- sideNavMode: string;
67
- onSideNavSelect: (href: string) => void;
68
- notifications: {
69
- badgeCount: number;
70
- /** Bumping this value forces ui-shell to re-fetch the panel list. */
71
- fetchKey?: number;
72
- onFetch: () => Promise<ShellNotificationLike[]>;
73
- onMarkAllRead: () => void;
74
- onItemClick: (n: ShellNotificationLike) => void;
75
- viewAllHref: string;
76
- viewAllTarget: string;
77
- };
78
- userProfile: {
79
- name: string;
80
- role?: string;
81
- email: string;
82
- };
83
- profileActions: string[];
84
- onProfileAction: (action: string) => void;
85
- profileExtra?: ReactNode;
86
- apps: {
87
- onFetch: () => Promise<Array<{ href: string }>>;
88
- onAppSelect: (app: { href: string }) => void;
89
- };
90
- children: ReactNode;
91
- }
92
-
93
75
  export type AppSwitchMode = "push" | "location";
94
76
 
95
77
  export interface AppShellLayoutDeps {
@@ -97,9 +79,9 @@ export interface AppShellLayoutDeps {
97
79
  useRouter: () => RouterLike;
98
80
  useTheme: () => { theme: ThemeLike };
99
81
  useNotificationBell: (opts: NotificationBellOptionsLike) => NotificationBellLike;
100
- GlobalTheme: any;
101
- ModalProvider: any;
102
- UiShell: any;
82
+ GlobalTheme: ElementType;
83
+ ModalProvider: ElementType;
84
+ UiShell: ElementType;
103
85
  useAuth?: () => AppShellAuthState;
104
86
  }
105
87
 
@@ -139,7 +121,13 @@ function BranchDropdown({
139
121
  <button type="button" className="profile-branch-dropdown__trigger" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
140
122
  <span className="profile-branch-dropdown__current">
141
123
  <span className="profile-branch-section__label" style={{ margin: 0 }}>{t("ui7.branchLabel", "Cabang")}</span>
142
- <span className="profile-branch-item__code">{currentBranch?.code || currentBranch?.name || "—"}</span>
124
+ <span className="profile-branch-item__code">
125
+ {currentBranch
126
+ ? (currentBranch.code && currentBranch.name && currentBranch.code !== currentBranch.name
127
+ ? `${currentBranch.code} — ${currentBranch.name}`
128
+ : (currentBranch.code || currentBranch.name || "—"))
129
+ : "—"}
130
+ </span>
143
131
  </span>
144
132
  <span className="profile-branch-dropdown__chevron" aria-hidden="true">{open ? "▲" : "▼"}</span>
145
133
  </button>
@@ -297,7 +285,6 @@ export function AppShellLayout({
297
285
  corporateId={corporateId}
298
286
  appName={appName}
299
287
  auth7UiUrl={auth7UiUrl}
300
- portalUrl={portalUrl}
301
288
  roleFallback={roleFallback}
302
289
  appSwitchMode={appSwitchMode}
303
290
  notificationsViewAllHref={notificationsViewAllHref ?? `${portalUrl}/notifications`}
@@ -306,7 +293,6 @@ export function AppShellLayout({
306
293
  disableAppsMenu={disableAppsMenu}
307
294
  sideNavMode={sideNavMode}
308
295
  sideNavItems={sideNavItems ?? resolvedSideNavItems}
309
- resolvedSideNavItems={resolvedSideNavItems}
310
296
  notifBadgeCount={badgeCount}
311
297
  userName={user?.name}
312
298
  userEmail={user?.email}
@@ -350,7 +336,6 @@ function ShellInner({
350
336
  deps,
351
337
  appName,
352
338
  auth7UiUrl,
353
- portalUrl,
354
339
  roleFallback,
355
340
  appSwitchMode,
356
341
  notificationsViewAllHref,
@@ -359,7 +344,6 @@ function ShellInner({
359
344
  disableAppsMenu,
360
345
  sideNavMode,
361
346
  sideNavItems,
362
- resolvedSideNavItems,
363
347
  notifBadgeCount,
364
348
  userName,
365
349
  userEmail,
@@ -374,7 +358,6 @@ function ShellInner({
374
358
  deps: AppShellLayoutDeps;
375
359
  appName: string;
376
360
  auth7UiUrl: string;
377
- portalUrl: string;
378
361
  roleFallback: string;
379
362
  appSwitchMode: AppSwitchMode;
380
363
  notificationsViewAllHref: string;
@@ -383,7 +366,6 @@ function ShellInner({
383
366
  disableAppsMenu: boolean;
384
367
  sideNavMode: "drilldown" | "overlay";
385
368
  sideNavItems: ShellNavItemLike[];
386
- resolvedSideNavItems: ShellNavItemLike[];
387
369
  notifBadgeCount: number;
388
370
  userName?: string | null;
389
371
  userEmail?: string | null;
@@ -400,24 +382,49 @@ function ShellInner({
400
382
  const [currentBranch, setCurrentBranch] = useState<Branch | null>(null);
401
383
 
402
384
  useEffect(() => {
385
+ // API contract is snake_case (`branch_code`, `branch_name`); UI
386
+ // canonical fields are `code` and `name`. Map at the seam so downstream
387
+ // components stay clean. Empty branch_name falls back to branch_code as
388
+ // a visible label (rather than rendering an empty string).
389
+ type ApiBranch = {
390
+ id: string;
391
+ branch_code?: string;
392
+ branch_name?: string;
393
+ code?: string;
394
+ name?: string;
395
+ is_primary?: boolean;
396
+ };
397
+ const normalize = (b: ApiBranch): Branch => {
398
+ const code = b.branch_code ?? b.code ?? "";
399
+ const apiName = b.branch_name ?? b.name ?? "";
400
+ const name = apiName !== "" ? apiName : code;
401
+ return {
402
+ id: b.id,
403
+ name,
404
+ code,
405
+ is_primary: b.is_primary,
406
+ is_current: b.id === userBranchId,
407
+ };
408
+ };
409
+
403
410
  fetch("/api/auth/branches")
404
411
  .then((r) => (r.ok ? r.json() : null))
405
- .then((d: { branches?: Branch[] } | null) => {
412
+ .then((d: { branches?: ApiBranch[] } | null) => {
406
413
  if (!d?.branches?.length) {
407
414
  if (userBranchId) {
408
- const fallback = { id: userBranchId, name: userBranchId, code: userBranchId, is_current: true };
415
+ const fallback: Branch = { id: userBranchId, name: userBranchId, code: userBranchId, is_current: true };
409
416
  setBranches([fallback]);
410
417
  setCurrentBranch(fallback);
411
418
  }
412
419
  return;
413
420
  }
414
- const normalized = d.branches.map((b: Branch) => ({ ...b, is_current: b.id === userBranchId }));
421
+ const normalized = d.branches.map(normalize);
415
422
  setBranches(normalized);
416
423
  setCurrentBranch(normalized.find((b) => b.is_current) ?? normalized[0]);
417
424
  })
418
425
  .catch(() => {
419
426
  if (userBranchId) {
420
- const fallback = { id: userBranchId, name: userBranchId, code: userBranchId, is_current: true };
427
+ const fallback: Branch = { id: userBranchId, name: userBranchId, code: userBranchId, is_current: true };
421
428
  setBranches([fallback]);
422
429
  setCurrentBranch(fallback);
423
430
  }