@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,73 @@
1
+ // Server-side only. Forwards a DataTable POST /query request to a backend service
2
+ // using a delegated JWT (RFC 8693 token exchange).
3
+ import { NextRequest, NextResponse } from "next/server";
4
+ import { exchangeUserToken } from "../auth7/token-exchange";
5
+
6
+ export interface ProxyBackendPostOptions {
7
+ backendUrl: string;
8
+ path: string;
9
+ audience: string;
10
+ accessTokenCookie?: string;
11
+ }
12
+
13
+ export async function proxyBackendPost(
14
+ req: NextRequest,
15
+ opts: ProxyBackendPostOptions,
16
+ ): Promise<NextResponse> {
17
+ const accessToken = req.cookies.get(opts.accessTokenCookie ?? "access_token")?.value;
18
+ const result = await exchangeUserToken(accessToken ?? "", opts.audience);
19
+ const body = await req.json();
20
+ const res = await fetch(`${opts.backendUrl}${opts.path}`, {
21
+ method: "POST",
22
+ headers: {
23
+ "Content-Type": "application/json",
24
+ Authorization: `Bearer ${result.accessToken}`,
25
+ },
26
+ body: JSON.stringify(body),
27
+ cache: "no-store",
28
+ });
29
+ const data = await res.json();
30
+ return NextResponse.json(data, { status: res.status });
31
+ }
32
+
33
+ export interface ProxyQueryRouteOptions extends ProxyBackendPostOptions {
34
+ /**
35
+ * Extra column type metadata to merge into the backend response's columnTypes.
36
+ * Useful when the BFF layer knows additional column types not present in the backend response.
37
+ * Optional — if omitted, backend response is forwarded as-is.
38
+ */
39
+ extraColumnTypes?: Record<string, string>;
40
+ }
41
+
42
+ /**
43
+ * Forwards a DataTable POST /query request to a backend Pattern B endpoint.
44
+ * Semantically identical to proxyBackendPost but signals "this is a query/lookup
45
+ * endpoint" — used by ServerDataTable and LookupInput interchangeably.
46
+ *
47
+ * If extraColumnTypes is provided, merges them into the response's columnTypes field.
48
+ */
49
+ export async function proxyQueryRoute(
50
+ req: NextRequest,
51
+ opts: ProxyQueryRouteOptions,
52
+ ): Promise<NextResponse> {
53
+ if (!opts.extraColumnTypes) {
54
+ return proxyBackendPost(req, opts);
55
+ }
56
+ // Re-implement forward to avoid double body-stream consumption when merging columnTypes.
57
+ const accessToken = req.cookies.get(opts.accessTokenCookie ?? "access_token")?.value;
58
+ const result = await exchangeUserToken(accessToken ?? "", opts.audience);
59
+ const body = await req.json();
60
+ const res = await fetch(`${opts.backendUrl}${opts.path}`, {
61
+ method: "POST",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ Authorization: `Bearer ${result.accessToken}`,
65
+ },
66
+ body: JSON.stringify(body),
67
+ cache: "no-store",
68
+ });
69
+ const data = await res.json() as Record<string, unknown>;
70
+ const existing = (data.columnTypes as Record<string, string>) ?? {};
71
+ data.columnTypes = { ...existing, ...opts.extraColumnTypes };
72
+ return NextResponse.json(data, { status: res.status });
73
+ }
@@ -1,6 +1,29 @@
1
1
  /* Form contract — Carbon overrides scoped to .ui7-form-contract
2
2
  Token source of truth: UI7_FORM_VISUAL_TOKENS in style-contract.ts */
3
3
 
4
+ /* Panel width — desktop-first (matches the rest of this file).
5
+ The width modifier (one of --width-half / --width-two-thirds / --width-full)
6
+ is always present on the root — `getUi7FormContractClassName` emits one,
7
+ defaulting to two-thirds when the consumer omits the option. Base scope
8
+ sets only the hard ceiling + alignment; modifier sets the width. The
9
+ breakpoint override at the bottom of the file releases the cap on tablet
10
+ + mobile (<1056px) so fields can use full width on narrow screens. */
11
+ .ui7-form-contract {
12
+ max-width: 1200px;
13
+ }
14
+
15
+ .ui7-form-contract--width-half {
16
+ width: 50%;
17
+ }
18
+
19
+ .ui7-form-contract--width-two-thirds {
20
+ width: 66.6667%;
21
+ }
22
+
23
+ .ui7-form-contract--width-full {
24
+ width: 100%;
25
+ }
26
+
4
27
  /* Label: 12px / 500 / 16px */
5
28
  .ui7-form-contract .cds--label {
6
29
  margin-block-end: 0;
@@ -21,15 +44,54 @@
21
44
  line-height: 1.25rem;
22
45
  }
23
46
 
47
+ /* Control height — keep Select aligned with TextInput / DatePicker per density.
48
+ Carbon uses logical `block-size` (not `height`) on every input element and
49
+ resolves it through --cds-layout-size-height-* tokens that the React `size`
50
+ prop sets on the wrapper. A `height` rule alone loses the cascade because
51
+ `block-size` is a separate physical→logical property in horizontal writing
52
+ mode. We do TWO things to win cleanly:
53
+ 1. Redirect Carbon's height tokens at the density-modifier class
54
+ (see @isi-ui7/corporate-themes globals.scss).
55
+ 2. Set both `block-size` AND `height` here as a safety net, anchored to
56
+ the same var so density swaps stay in sync across all input flavours.
57
+ --ui7-form-control-height resolves to 1.75rem (compact) / 2.5rem (comfortable). */
58
+ .ui7-form-contract .cds--text-input,
59
+ .ui7-form-contract .cds--select-input,
60
+ .ui7-form-contract .cds--date-picker__input,
61
+ .ui7-form-contract .cds--number input,
62
+ .ui7-form-contract .cds--combo-box__input,
63
+ .ui7-form-contract .cds--dropdown {
64
+ block-size: var(--ui7-form-control-height);
65
+ min-block-size: var(--ui7-form-control-height);
66
+ max-block-size: var(--ui7-form-control-height);
67
+ height: var(--ui7-form-control-height);
68
+ min-height: var(--ui7-form-control-height);
69
+ }
70
+
24
71
  /* Full-width date picker */
25
72
  .ui7-form-contract .cds--date-picker,
26
73
  .ui7-form-contract .cds--date-picker-container {
27
74
  width: 100%;
28
75
  }
29
76
 
77
+ /* ── Tablet + mobile: release the desktop width cap ─────────────────────── */
78
+ /* Below Carbon's lg breakpoint (1056px), narrow viewports need every
79
+ available pixel — any desktop width modifier (half / two-thirds / full)
80
+ would leave fields cramped. Release back to full width here, beating
81
+ the modifier selectors via specificity (compound) + later declaration. */
82
+ @media (max-width: 1055px) {
83
+ .ui7-form-contract,
84
+ .ui7-form-contract.ui7-form-contract--width-half,
85
+ .ui7-form-contract.ui7-form-contract--width-two-thirds,
86
+ .ui7-form-contract.ui7-form-contract--width-full {
87
+ width: 100%;
88
+ max-width: 100%;
89
+ }
90
+ }
91
+
30
92
  /* ── Mobile: collapse all form panels to single column ───────────────────── */
31
- /* Desktop-first: multi-column span values set via inline style are overridden
32
- below Carbon's md breakpoint (671px). !important required to beat inline style. */
93
+ /* Multi-column span values set via inline style are overridden below Carbon's
94
+ md breakpoint (671px). !important required to beat inline style. */
33
95
  @media (max-width: 671px) {
34
96
  .ui7-form-contract .ui7-form-row > .ui7-form-column {
35
97
  grid-column: 1 / -1 !important;
@@ -171,3 +171,19 @@ export const INDONESIA_CURRENCY_CONFIG: CurrencyPrecisionConfig = {
171
171
  precisionByCurrency: { IDR: 2, USD: 4 },
172
172
  defaultPrecision: 2,
173
173
  };
174
+
175
+ /**
176
+ * Whole-rupiah currency config — used for ledger-style fields like plafond,
177
+ * subsidi, agent limit, where the convention in Indonesian retail banking is
178
+ * to round to whole IDR (no sen). USD/USD-denominated fields still keep 2
179
+ * decimals (standard cents). Default precision is 0 so any other currency
180
+ * defaults to whole units in these contexts.
181
+ *
182
+ * Use this for "agent plafond / saldo / subsidi" style fields. For amounts
183
+ * that genuinely need sub-rupiah precision (e.g. percentage-derived fees,
184
+ * accrued interest), prefer `INDONESIA_CURRENCY_CONFIG` instead.
185
+ */
186
+ export const INDONESIA_RUPIAH_WHOLE_CONFIG: CurrencyPrecisionConfig = {
187
+ precisionByCurrency: { IDR: 0, USD: 2 },
188
+ defaultPrecision: 0,
189
+ };
@@ -12,6 +12,7 @@ import {
12
12
  Toggle,
13
13
  } from "@carbon/react";
14
14
  import { LookupInput } from "@isi-ui7/lookup-input";
15
+ import { EditableTable } from "@isi-ui7/editable-table";
15
16
  import { useI18n } from "@isi-ui7/i18n";
16
17
  import type { Ui7Locale } from "@isi-ui7/i18n";
17
18
  import {
@@ -27,7 +28,7 @@ import {
27
28
  UI7_FORM_VISUAL_CLASSNAMES,
28
29
  UI7_FORM_VISUAL_TOKENS,
29
30
  } from "./style-contract";
30
- import type { Ui7FormDensity } from "./style-contract";
31
+ import type { Ui7FormDensity, Ui7FormWidth } from "./style-contract";
31
32
  import { useBosSharedI18n } from "./i18n";
32
33
  import type { FormField, FormMode, FormSection } from "./form-types";
33
34
 
@@ -44,6 +45,8 @@ export type SchemaFormRendererProps<TData extends Record<string, unknown>> = {
44
45
  scopeClassName?: string;
45
46
  /** Form layout density. Defaults to "compact". */
46
47
  density?: Ui7FormDensity;
48
+ /** Desktop form-panel width. Defaults to "two-thirds". */
49
+ width?: Ui7FormWidth;
47
50
  };
48
51
 
49
52
  function clampSpan(span: number | undefined, defaultSpan: number): number {
@@ -80,7 +83,27 @@ function makeDisplayRenderer(
80
83
  ): ReactNode {
81
84
  if (field.format) return field.format(value as never, data as never);
82
85
  if (field.type === "checkbox") return value ? labels.boolYes : labels.boolNo;
83
- if (field.type === "toggle") return value ? labels.toggleOn : labels.toggleOff;
86
+ if (field.type === "toggle") {
87
+ // valueOn / valueOff override the default boolean truthiness check
88
+ // for non-boolean stored values (e.g., "Y"/"N").
89
+ if (field.toggle && field.toggle.valueOn !== undefined) {
90
+ return value === field.toggle.valueOn ? labels.toggleOn : labels.toggleOff;
91
+ }
92
+ return value ? labels.toggleOn : labels.toggleOff;
93
+ }
94
+ if (field.type === "lookup") {
95
+ // View/read-only mode mirrors the LookupInput initialDisplay logic:
96
+ // when initialDisplayFields is set and all referenced FormData
97
+ // values are non-empty, render the compound "kode - nama". Falls
98
+ // back to the raw primary key when companion fields are missing.
99
+ const lookup = field.lookup;
100
+ if (lookup?.initialDisplayFields?.length) {
101
+ const sep = lookup.initialDisplaySeparator ?? " ";
102
+ const parts = lookup.initialDisplayFields.map((k) => asString(data[String(k)]));
103
+ if (parts.every((p) => p !== "")) return parts.join(sep);
104
+ }
105
+ return asString(value) || labels.emptyValue;
106
+ }
84
107
  if (field.type === "number" && typeof value === "number") {
85
108
  const nloc = locale === "en" ? "en-US" : "id-ID";
86
109
  const numeric = field.numeric;
@@ -158,6 +181,7 @@ function NumericField({
158
181
  value,
159
182
  numericKind,
160
183
  prec,
184
+ currencyCode,
161
185
  locale,
162
186
  size,
163
187
  disabled,
@@ -172,6 +196,13 @@ function NumericField({
172
196
  value: number;
173
197
  numericKind: "currency" | "percent" | "integer" | "decimal";
174
198
  prec?: number;
199
+ /**
200
+ * Currency ISO code (e.g. "IDR", "USD") — only used when numericKind ===
201
+ * "currency". When set, the code is shown as a subdued suffix next to the
202
+ * field label so the user sees which currency they're entering, mirroring
203
+ * the view-mode renderer which shows `Rp 1.000.000` / `IDR 1.000.000`.
204
+ */
205
+ currencyCode?: string;
175
206
  locale: Ui7Locale;
176
207
  size?: "sm" | "md" | "lg";
177
208
  disabled?: boolean;
@@ -209,19 +240,94 @@ function NumericField({
209
240
  return parsed.ok ? parsed.value : 0;
210
241
  };
211
242
 
243
+ /**
244
+ * Input-time sanitizer — drops any character the user types that isn't part
245
+ * of a valid number in the active locale. Without this, typing "abc" would
246
+ * stick in the textbox (because `raw` stores the user's verbatim keystrokes)
247
+ * while the parsed numeric value silently becomes 0. Filtering at input gives
248
+ * immediate visual feedback: rejected chars never appear at all.
249
+ *
250
+ * Rules:
251
+ * - Integer: 0–9 plus a single leading minus.
252
+ * - Decimal / currency / percent: 0–9, thousand separator, a single decimal
253
+ * separator, leading minus. Decimal separator is rejected entirely when
254
+ * `prec === 0` (no fractional part allowed), and excess fraction digits
255
+ * beyond `prec` are clipped on the fly so users see the limit enforced.
256
+ */
257
+ const sanitizeInput = (input: string): string => {
258
+ let out = "";
259
+ let seenMinus = false;
260
+ if (numericKind === "integer") {
261
+ for (let i = 0; i < input.length; i++) {
262
+ const c = input[i];
263
+ if (c >= "0" && c <= "9") out += c;
264
+ else if (c === "-" && out === "" && !seenMinus) {
265
+ out += c;
266
+ seenMinus = true;
267
+ }
268
+ }
269
+ return out;
270
+ }
271
+ let seenDecimal = false;
272
+ const allowDecimal = prec === undefined || prec > 0;
273
+ for (let i = 0; i < input.length; i++) {
274
+ const c = input[i];
275
+ if (c >= "0" && c <= "9") out += c;
276
+ else if (c === decimalSep && !seenDecimal && allowDecimal) {
277
+ out += c;
278
+ seenDecimal = true;
279
+ } else if (c === thousandSep) out += c;
280
+ else if (c === "-" && out === "" && !seenMinus) {
281
+ out += c;
282
+ seenMinus = true;
283
+ }
284
+ }
285
+ // Clip excess fraction digits to declared precision.
286
+ if (typeof prec === "number" && prec > 0 && seenDecimal) {
287
+ const decIdx = out.indexOf(decimalSep);
288
+ const intPart = out.slice(0, decIdx);
289
+ const fracPart = out.slice(decIdx + 1);
290
+ if (fracPart.length > prec) {
291
+ out = intPart + decimalSep + fracPart.slice(0, prec);
292
+ }
293
+ }
294
+ return out;
295
+ };
296
+
212
297
  const displayValue = raw !== null ? raw : formatDisplay(value);
213
298
 
214
- return (
299
+ // Auto helper-text for currency fields with non-zero precision — surfaces
300
+ // the decimal limit to the user so the silent truncation in parseRaw
301
+ // doesn't surprise them. Skipped when prec === 0 (the display format
302
+ // already shows no decimal separator, no extra explanation needed) or
303
+ // when caller already supplied a helperText.
304
+ const autoCurrencyHelper = (() => {
305
+ if (helperText !== undefined) return undefined;
306
+ if (numericKind !== "currency" || !currencyCode) return undefined;
307
+ const p = prec ?? 0;
308
+ if (p <= 0) return undefined;
309
+ if (locale === "en") {
310
+ return `Up to ${p} decimal place${p > 1 ? "s" : ""}`;
311
+ }
312
+ return `Maksimal ${p} angka desimal`;
313
+ })();
314
+ const effectiveHelperText = helperText ?? autoCurrencyHelper;
315
+
316
+ const input = (
215
317
  <TextInput
216
318
  id={id}
217
319
  labelText={labelText}
218
- helperText={helperText}
320
+ helperText={effectiveHelperText}
219
321
  value={displayValue}
220
322
  onFocus={() => {
221
323
  setRaw(value === 0 ? "" : String(value).replace(".", decimalSep));
222
324
  }}
223
325
  onChange={(e) => {
224
- const next = e.target.value;
326
+ // Filter non-numeric input at the source so the textbox can never
327
+ // visibly display characters that aren't part of a valid number.
328
+ // Caret behaviour stays natural because we only ever DROP chars —
329
+ // never reorder.
330
+ const next = sanitizeInput(e.target.value);
225
331
  setRaw(next);
226
332
  onChange(parseRaw(next));
227
333
  }}
@@ -238,6 +344,79 @@ function NumericField({
238
344
  placeholder={placeholder}
239
345
  />
240
346
  );
347
+
348
+ // Currency chip — rendered to the right of the TextInput, height + Y position
349
+ // matched to the actual input field. We anchor via `align-items: flex-end` so
350
+ // the chip bottom sits flush with the input bottom (which is just above the
351
+ // helper-text / invalid-text row), then offset the chip up past those rows
352
+ // so its bottom lands on the input bottom edge regardless of whether the
353
+ // label wraps to two lines. `box-sizing: border-box` keeps height parity
354
+ // with the Carbon input (which also includes its 1px border in 2rem/2.5rem).
355
+ if (numericKind === "currency" && currencyCode) {
356
+ // Resolve locale-aware currency display so the chip matches what view-mode
357
+ // shows — `Intl.NumberFormat(... style:'currency')` renders IDR as "Rp" in
358
+ // id-ID and as "IDR" in en-US.
359
+ const chipText = (() => {
360
+ try {
361
+ const parts = new Intl.NumberFormat(nloc, {
362
+ style: "currency",
363
+ currency: currencyCode,
364
+ minimumFractionDigits: 0,
365
+ maximumFractionDigits: 0,
366
+ }).formatToParts(0);
367
+ const sym = parts.find((p) => p.type === "currency")?.value;
368
+ return sym && sym.trim() ? sym.trim() : currencyCode;
369
+ } catch {
370
+ return currencyCode;
371
+ }
372
+ })();
373
+
374
+ // Chip height + horizontal min-width follow the same CSS variable that the
375
+ // form contract uses to size .cds--text-input (1.75rem compact / 2.5rem
376
+ // comfortable). Fallback covers the rare host that mounts NumericField
377
+ // outside the .ui7-form-contract scope; map by Carbon `size` so the chip
378
+ // still matches Carbon's native input height there.
379
+ const chipHeightFallback = size === "lg" ? "3rem" : size === "md" ? "2.5rem" : "1.75rem";
380
+ const chipHeight = `var(--ui7-form-control-height, ${chipHeightFallback})`;
381
+ // `.cds--form-requirement` (invalid) ~1rem + 0.25rem margin; same for
382
+ // `.cds--form__helper-text`. Offset the chip up by that combined ~1.25rem
383
+ // so its bottom sits flush with the input bottom regardless of subtext.
384
+ const subtextRowOffset =
385
+ invalid && invalidText ? "1.25rem" : effectiveHelperText ? "1.25rem" : "0";
386
+
387
+ return (
388
+ <div style={{ display: "flex", alignItems: "flex-end", gap: "0.5rem" }}>
389
+ <div style={{ flex: "1 1 auto", minWidth: 0 }}>{input}</div>
390
+ <span
391
+ aria-label={`Mata uang: ${chipText}`}
392
+ title={currencyCode}
393
+ style={{
394
+ flex: "0 0 auto",
395
+ boxSizing: "border-box",
396
+ marginBlockEnd: subtextRowOffset,
397
+ height: chipHeight,
398
+ minWidth: chipHeight,
399
+ paddingInline: "0.625rem",
400
+ display: "inline-flex",
401
+ alignItems: "center",
402
+ justifyContent: "center",
403
+ fontSize: "0.75rem",
404
+ fontWeight: 500,
405
+ letterSpacing: "0.02em",
406
+ color: "var(--cds-text-primary, #161616)",
407
+ background: "var(--cds-layer-accent-01, #e8e8e8)",
408
+ border: "1px solid var(--cds-border-subtle, #e0e0e0)",
409
+ whiteSpace: "nowrap",
410
+ userSelect: "none",
411
+ }}
412
+ >
413
+ {chipText}
414
+ </span>
415
+ </div>
416
+ );
417
+ }
418
+
419
+ return input;
241
420
  }
242
421
 
243
422
  export function SchemaFormRenderer<TData extends Record<string, unknown>>({
@@ -251,12 +430,13 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
251
430
  showSectionHeader = true,
252
431
  scopeClassName,
253
432
  density = "compact",
433
+ width,
254
434
  }: SchemaFormRendererProps<TData>) {
255
435
  const isView = mode === "view";
256
436
  const dt = UI7_FORM_DENSITY_TOKENS[density];
257
437
  const carbonSize = density === "compact" ? "sm" : "md";
258
438
  const rootClassName =
259
- scopeClassName ?? getUi7FormContractClassName({ density, readonly: isView });
439
+ scopeClassName ?? getUi7FormContractClassName({ density, width, readonly: isView });
260
440
  const labels = useBosSharedI18n();
261
441
  const { locale } = useI18n();
262
442
  const renderDisplayValue = makeDisplayRenderer(labels, locale);
@@ -338,11 +518,65 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
338
518
  Boolean(field.validation?.required) && !effectiveReadonly;
339
519
  const invalidText = errors?.[field.key];
340
520
  const span = clampSpan(field.span, defaultSpan);
521
+ // `breakBefore` forces a new row by pinning to column 1.
522
+ // Without it, CSS grid auto-flow may fit the field on the
523
+ // current row if remaining columns are enough for `span`.
341
524
  const wrapperStyle = {
342
- gridColumn: `span ${span} / span ${span}`,
525
+ gridColumn: field.breakBefore
526
+ ? `1 / span ${span}`
527
+ : `span ${span} / span ${span}`,
343
528
  minWidth: 0,
344
529
  } as const;
345
530
 
531
+ // ── Detail-rows (master-detail editor) ──────────────────────
532
+ // Rendered BEFORE the readonly branch so view / edit / create
533
+ // share the same `<EditableTable>` instance — the table
534
+ // renders rows even when `readOnly={true}` and just hides
535
+ // the add/delete affordances. Default span = 12 (full width)
536
+ // because tables rarely look right in a half-column.
537
+ if (field.type === "detail-rows") {
538
+ const detail = field.detailRows;
539
+ if (!detail) return [];
540
+ const rows = Array.isArray(rawValue)
541
+ ? (rawValue as Record<string, unknown>[])
542
+ : [];
543
+ const detailSpan = clampSpan(field.span, 12);
544
+ const detailWrapperStyle = {
545
+ gridColumn: field.breakBefore
546
+ ? `1 / span ${detailSpan}`
547
+ : `span ${detailSpan} / span ${detailSpan}`,
548
+ minWidth: 0,
549
+ } as const;
550
+ return [
551
+ <div
552
+ key={key}
553
+ className={UI7_FORM_VISUAL_CLASSNAMES.column}
554
+ style={detailWrapperStyle}
555
+ >
556
+ <FieldShell
557
+ label={field.label}
558
+ required={effectiveRequired}
559
+ helperText={field.helperText}
560
+ >
561
+ <EditableTable
562
+ columns={detail.columns}
563
+ value={rows}
564
+ onChange={(newRows) =>
565
+ setValue(
566
+ field.key,
567
+ (newRows as unknown) as TData[keyof TData],
568
+ )
569
+ }
570
+ newRowFactory={detail.newRowFactory}
571
+ maxRows={detail.maxRows}
572
+ readOnly={effectiveReadonly}
573
+ validateRow={detail.validateRow}
574
+ />
575
+ </FieldShell>
576
+ </div>,
577
+ ];
578
+ }
579
+
346
580
  // ── View / readonly ─────────────────────────────────────────
347
581
  if (effectiveReadonly) {
348
582
  return [
@@ -376,6 +610,18 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
376
610
  // ── Lookup ──────────────────────────────────────────────────
377
611
  if (field.type === "lookup") {
378
612
  const lookup = field.lookup;
613
+ // Compose initialDisplay from sibling form fields, e.g.
614
+ // ["kode_cabang","branch_name"] → "001 KANTOR CABANG …".
615
+ // Falls back to undefined if any referenced field is
616
+ // empty so LookupInput uses its default (the key value).
617
+ let initialDisplay: string | undefined;
618
+ if (lookup?.initialDisplayFields?.length) {
619
+ const sep = lookup.initialDisplaySeparator ?? " ";
620
+ const parts = lookup.initialDisplayFields.map((k) => asString(value[k]));
621
+ if (parts.every((p) => p !== "")) {
622
+ initialDisplay = parts.join(sep);
623
+ }
624
+ }
379
625
  return [
380
626
  <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
381
627
  <FieldShell label={field.label} required={effectiveRequired} helperText={field.helperText} htmlFor={key}>
@@ -383,6 +629,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
383
629
  id={key}
384
630
  labelText=""
385
631
  value={asString(rawValue)}
632
+ initialDisplay={initialDisplay}
386
633
  onChange={(next: string) => setValue(field.key, next as TData[keyof TData])}
387
634
  onDataSelected={(row: Record<string, unknown>) => {
388
635
  if (lookup?.onDataPatch) {
@@ -465,10 +712,12 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
465
712
  const numeric = field.numeric;
466
713
  let numericKind: "currency" | "percent" | "integer" | "decimal" = "decimal";
467
714
  let prec: number | undefined;
715
+ let currencyCode: string | undefined;
468
716
 
469
717
  if (numeric?.kind === "currency") {
470
718
  numericKind = "currency";
471
719
  const code = String(value[numeric.currencyField] ?? "");
720
+ currencyCode = code || undefined;
472
721
  prec = getCurrencyPrecision(code, {
473
722
  precisionByCurrency: numeric.precisionByCurrency,
474
723
  defaultPrecision: numeric.defaultPrecision,
@@ -480,20 +729,46 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
480
729
  numericKind = "integer";
481
730
  }
482
731
 
732
+ // Live min/max validation — surfaces invalidText immediately
733
+ // as the user types instead of waiting for Save. Falls back
734
+ // to the submit-time error from the form host's errors map
735
+ // when the value is within bounds (or no bounds declared).
736
+ const numericValue = typeof rawValue === "number" ? rawValue : 0;
737
+ let liveInvalidText: string | undefined;
738
+ const v = field.validation;
739
+ if (v?.min !== undefined) {
740
+ const minV = Array.isArray(v.min) ? v.min[0] : v.min;
741
+ if (numericValue < minV) {
742
+ liveInvalidText = Array.isArray(v.min)
743
+ ? v.min[1]
744
+ : labels.validMin(minV);
745
+ }
746
+ }
747
+ if (!liveInvalidText && v?.max !== undefined) {
748
+ const maxV = Array.isArray(v.max) ? v.max[0] : v.max;
749
+ if (numericValue > maxV) {
750
+ liveInvalidText = Array.isArray(v.max)
751
+ ? v.max[1]
752
+ : labels.validMax(maxV);
753
+ }
754
+ }
755
+ const effectiveInvalidText = invalidText ?? liveInvalidText;
756
+
483
757
  return [
484
758
  <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
485
759
  <NumericField
486
760
  id={key}
487
761
  labelText={mkLabel(field.label, effectiveRequired)}
488
762
  helperText={field.helperText}
489
- value={typeof rawValue === "number" ? rawValue : 0}
763
+ value={numericValue}
490
764
  numericKind={numericKind}
491
765
  prec={prec}
766
+ currencyCode={currencyCode}
492
767
  locale={locale}
493
768
  size={carbonSize}
494
769
  disabled={disabled}
495
- invalid={Boolean(invalidText)}
496
- invalidText={invalidText}
770
+ invalid={Boolean(effectiveInvalidText)}
771
+ invalidText={effectiveInvalidText}
497
772
  placeholder={field.placeholder}
498
773
  onChange={(v) => setValue(field.key, v as TData[keyof TData])}
499
774
  />
@@ -520,6 +795,19 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
520
795
 
521
796
  // ── Toggle ──────────────────────────────────────────────────
522
797
  if (field.type === "toggle") {
798
+ // Optional valueOn/valueOff for non-boolean stored state
799
+ // (e.g., "Y"/"N"). When unset, falls back to plain Boolean.
800
+ const hasCustomMapping =
801
+ field.toggle && field.toggle.valueOn !== undefined;
802
+ const toggled = hasCustomMapping
803
+ ? rawValue === field.toggle!.valueOn
804
+ : Boolean(rawValue);
805
+ const onValue: unknown = hasCustomMapping
806
+ ? field.toggle!.valueOn
807
+ : true;
808
+ const offValue: unknown = hasCustomMapping
809
+ ? field.toggle!.valueOff
810
+ : false;
523
811
  return [
524
812
  <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
525
813
  <FieldShell label={field.label} required={effectiveRequired} helperText={field.helperText}>
@@ -529,9 +817,12 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
529
817
  labelText=""
530
818
  labelA={labels.toggleOff}
531
819
  labelB={labels.toggleOn}
532
- toggled={Boolean(rawValue)}
820
+ toggled={toggled}
533
821
  onToggle={(checked) =>
534
- setValue(field.key, Boolean(checked) as TData[keyof TData])
822
+ setValue(
823
+ field.key,
824
+ (checked ? onValue : offValue) as TData[keyof TData]
825
+ )
535
826
  }
536
827
  disabled={disabled}
537
828
  />