@isi-ui7/bos7-shared 0.3.6 → 0.3.8

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.
@@ -45,3 +45,23 @@ export declare const INDONESIA_CURRENCY_CONFIG: CurrencyPrecisionConfig;
45
45
  * accrued interest), prefer `INDONESIA_CURRENCY_CONFIG` instead.
46
46
  */
47
47
  export declare const INDONESIA_RUPIAH_WHOLE_CONFIG: CurrencyPrecisionConfig;
48
+ /** Jumlah digit NPWP: 2+3+3+1+3+3. */
49
+ export declare const NPWP_DIGITS = 15;
50
+ /**
51
+ * NPWP yang diketik → berformat `XX.XXX.XXX.X-XXX.XXX`.
52
+ *
53
+ * Pemisah DISISIPKAN otomatis, tidak diketik operator. Alasannya bukan
54
+ * kenyamanan: kalau titik dan strip harus diketik sendiri, setiap salah tempat
55
+ * menghasilkan nomor yang TERLIHAT benar tapi berbeda — dan NPWP adalah
56
+ * identitas pajak yang dicocokkan dengan sistem lain persis karakter demi
57
+ * karakter.
58
+ *
59
+ * Non-angka dibuang, jadi menempel (paste) dari sumber mana pun tetap
60
+ * menghasilkan bentuk yang sama.
61
+ */
62
+ export declare function normalizeNpwpInput(raw: string): string;
63
+ /** Digit murni NPWP — dipakai backend sebagai tax_id_plain. */
64
+ export declare function npwpDigits(value: string): string;
65
+ /** Sah bila tepat 15 digit. Bentuk pemisah tidak diperiksa: ia dihasilkan
66
+ * normalizeNpwpInput, bukan diketik. */
67
+ export declare function isValidNpwpInput(value: string): boolean;
@@ -24,6 +24,14 @@ export declare const Rules: {
24
24
  * `now` bisa disuntik agar bisa diuji tanpa bergantung jam mesin.
25
25
  */
26
26
  readonly dateNotFuture: (msg?: string, now?: () => Date) => FieldValidatorFn;
27
+ /**
28
+ * NPWP — 15 digit, ditampilkan `XX.XXX.XXX.X-XXX.XXX`.
29
+ *
30
+ * Yang diperiksa jumlah DIGIT-nya, bukan bentuk pemisahnya: pemisah
31
+ * dihasilkan normalizeNpwpInput dan tidak pernah diketik operator, jadi
32
+ * memeriksanya berarti menguji kode sendiri alih-alih masukan pengguna.
33
+ */
34
+ readonly npwp: (msg?: string) => FieldValidatorFn;
27
35
  readonly regex: (pattern: RegExp, msg: string) => FieldValidatorFn;
28
36
  };
29
37
  export declare function buildValidator<TData extends Record<string, unknown>>(crossField: (form: TData) => Partial<Record<keyof TData, string>>): (form: TData) => Partial<Record<keyof TData, string>>;
@@ -67,6 +67,8 @@ export type FormFieldNumericConfig<TData extends Record<string, unknown>> = {
67
67
  minDigits: number;
68
68
  maxDigits: number;
69
69
  allowedPrefixes?: string[];
70
+ } | {
71
+ kind: "npwp";
70
72
  };
71
73
  /**
72
74
  * Toggle field state mapping for non-boolean stored values.
@@ -181,6 +183,11 @@ export type FormSection<TData extends Record<string, unknown>> = {
181
183
  };
182
184
  export type FormPageDef<TData extends Record<string, unknown>> = {
183
185
  label: string;
186
+ /**
187
+ * Baris kedua di stepper — menjelaskan ISI langkah, bukan mengulang
188
+ * labelnya. Opsional; tanpa ini stepper hanya menampilkan label.
189
+ */
190
+ sublabel?: string;
184
191
  sections: FormSection<TData>[];
185
192
  /** Optional: save draft on Next. Returns {ok,error?} (no 202 required). */
186
193
  stepSaveApiPath?: ApiPath;
package/dist/i18n.d.ts CHANGED
@@ -24,6 +24,8 @@ export type BosSharedLabels = {
24
24
  cancel: string;
25
25
  prev: string;
26
26
  next: string;
27
+ /** Judul stepper vertikal wizard. */
28
+ wizardStepsTitle: string;
27
29
  subtitleCreate: string;
28
30
  subtitleEdit: string;
29
31
  subtitleView: string;
@@ -27391,7 +27391,7 @@ function ba(M, L) {
27391
27391
  return String(pr(M, L));
27392
27392
  }
27393
27393
  async function Io(M, L, r) {
27394
- const { default: x } = await import("./jspdf.es.min-iZWDIC-k.js").then((o) => o.j), { default: w } = await import("./jspdf.plugin.autotable-DFaknRns.js"), c = new x({ orientation: "landscape" });
27394
+ const { default: x } = await import("./jspdf.es.min-DzRwzM6G.js").then((o) => o.j), { default: w } = await import("./jspdf.plugin.autotable-DFaknRns.js"), c = new x({ orientation: "landscape" });
27395
27395
  c.setFontSize(14), c.text(r, 14, 15), c.setFontSize(10), w(c, {
27396
27396
  head: [L.map((o) => o.title)],
27397
27397
  body: M.map((o) => L.map((a) => ba(o[a.field], a.displayFormat))),
@@ -28646,6 +28646,7 @@ function tl() {
28646
28646
  cancel: M("bos7.cancel", "Batal"),
28647
28647
  prev: M("bos7.prev", "Kembali"),
28648
28648
  next: M("bos7.next", "Lanjut"),
28649
+ wizardStepsTitle: M("bos7.wizardStepsTitle", "Langkah Pengisian"),
28649
28650
  subtitleCreate: M("bos7.subtitleCreate", "Isi form untuk menambah data baru."),
28650
28651
  subtitleEdit: M("bos7.subtitleEdit", "Edit data yang dipilih."),
28651
28652
  subtitleView: M("bos7.subtitleView", "Detail data."),
@@ -1,5 +1,5 @@
1
- import { c as Ke, g as Do } from "./index-DV8lJjii.js";
2
- import { _ as Xa } from "./jspdf.es.min-iZWDIC-k.js";
1
+ import { c as Ke, g as Do } from "./index-BI2MxWot.js";
2
+ import { _ as Xa } from "./jspdf.es.min-DzRwzM6G.js";
3
3
  var vt = function(a) {
4
4
  return a && a.Math === Math && a;
5
5
  }, _ = (
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as s, a as o, b as t, D as r, N as i, O as n, d as l, P as c, e as d, f as u, h as S, U as h, i as A, j as T, k, l as C, m as _, n as O, o as U, p as g, q as N, r as P, s as m, t as E, u as F, v as p, w as f, x as I, y as R, z as x, B as v, C as y, E as L, F as b, G as B, H as D, I as M, J as H, K as V, L as W, M as q, Q as K, R as w, S as z, T as G, V as Q, W as X, X as Y, Y as j, Z as J, _ as Z, $, a0 as aa, a1 as ea, a2 as sa, a3 as oa, a4 as ta, a5 as ra, a6 as ia, a7 as na, a8 as la, a9 as ca, aa as da, ab as ua, ac as Sa, ad as ha, ae as Aa, af as Ta, ag as ka, ah as Ca, ai as _a } from "./index-DV8lJjii.js";
1
+ import { A as s, a as o, b as t, D as r, N as i, O as n, d as l, P as c, e as d, f as u, h as S, U as h, i as A, j as T, k, l as C, m as _, n as O, o as U, p as g, q as N, r as P, s as m, t as E, u as F, v as p, w as f, x as I, y as R, z as x, B as v, C as y, E as L, F as b, G as B, H as D, I as M, J as H, K as V, L as W, M as q, Q as K, R as w, S as z, T as G, V as Q, W as X, X as Y, Y as j, Z as J, _ as Z, $, a0 as aa, a1 as ea, a2 as sa, a3 as oa, a4 as ta, a5 as ra, a6 as ia, a7 as na, a8 as la, a9 as ca, aa as da, ab as ua, ac as Sa, ad as ha, ae as Aa, af as Ta, ag as ka, ah as Ca, ai as _a } from "./index-BI2MxWot.js";
2
2
  export {
3
3
  s as AUTH7_SCOPES,
4
4
  o as AppShellLayout,
@@ -9216,7 +9216,7 @@ function(n) {
9216
9216
  var d = h.getContext("2d");
9217
9217
  d.fillStyle = "#fff", d.fillRect(0, 0, h.width, h.height);
9218
9218
  var m = { ignoreMouse: !0, ignoreAnimation: !0, ignoreDimensions: !0 }, _ = this;
9219
- return (Kt.canvg ? Promise.resolve(Kt.canvg) : import("./index.es-DqL2wDqB.js")).catch(function(k) {
9219
+ return (Kt.canvg ? Promise.resolve(Kt.canvg) : import("./index.es-D-PR3JkH.js")).catch(function(k) {
9220
9220
  return Promise.reject(new Error("Could not load canvg: " + k));
9221
9221
  }).then(function(k) {
9222
9222
  return k.default ? k.default : k;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Stepper VERTIKAL di sisi kanan — bentuk baku wizard (keputusan PM 2026-08-16).
3
+ *
4
+ * Menggantikan ProgressIndicator horizontal Carbon. Alasannya bukan selera:
5
+ * wizard di sini punya langkah yang TERKUNCI, dan gembok perlu tempat untuk
6
+ * terbaca. Pada stepper horizontal, lima langkah berikut sublabel dan ikonnya
7
+ * berdesakan sampai keduanya hilang — yang tersisa hanya lingkaran bernomor,
8
+ * dan operator tidak bisa tahu langkah mana yang belum boleh dibuka sampai ia
9
+ * menekannya.
10
+ *
11
+ * Tiga keadaan, dan gembok DIBEDAKAN dari sekadar "belum aktif":
12
+ *
13
+ * selesai → sudah dilewati
14
+ * aktif → sedang diisi
15
+ * terkunci → belum boleh dibuka (gembok)
16
+ *
17
+ * Langkah yang belum aktif TAPI boleh dibuka tidak diberi gembok. Menyamakan
18
+ * keduanya membuat operator mengira seluruh sisa form terkunci padahal hanya
19
+ * langkah pertama yang menahan.
20
+ */
21
+ export type WizardStepState = "selesai" | "aktif" | "terkunci" | "menunggu";
22
+ export type WizardStepView = {
23
+ label: string;
24
+ sublabel?: string;
25
+ state: WizardStepState;
26
+ };
27
+ export declare function WizardStepper({ steps, title, onSelect, }: {
28
+ steps: WizardStepView[];
29
+ title: string;
30
+ /** Dipanggil saat langkah diklik. Langkah TERKUNCI tidak pernah memanggilnya. */
31
+ onSelect?: (index: number) => void;
32
+ }): import("react/jsx-runtime").JSX.Element;
@@ -22,6 +22,16 @@ export type UseCrudFormReturn<TData extends Record<string, unknown>> = {
22
22
  canGoPrev: boolean;
23
23
  goNext: () => Promise<void>;
24
24
  goPrev: () => void;
25
+ /**
26
+ * Lompat ke halaman tertentu lewat stepper.
27
+ *
28
+ * MUNDUR selalu boleh — data yang sudah diisi tidak hilang, dan menahan
29
+ * operator melihat kembali apa yang ia tulis tidak melindungi apa pun.
30
+ * MAJU diteruskan ke goNext supaya melewati gerbang YANG SAMA dengan tombol
31
+ * Berikutnya; kalau tidak, stepper menjadi pintu belakang yang melewati
32
+ * verifikasi.
33
+ */
34
+ goToPage: (target: number) => void;
25
35
  handleSave: () => Promise<void>;
26
36
  };
27
37
  export declare function useCrudForm<TData extends Record<string, unknown>>({ schema, mode, params, onSuccess, }: UseCrudFormOptions<TData>): UseCrudFormReturn<TData>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isi-ui7/bos7-shared",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Shared auth7 and layout primitives for bos7 applications.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,6 +24,7 @@ import type { CrudCustomActionSchema, CrudDeleteSchema, CrudListSchema } from ".
24
24
  import type { CrudForm, FormMode } from "./form-types";
25
25
  import type { Ui7FormDensity } from "./style-contract";
26
26
  import { SchemaFormRenderer } from "./form-renderer";
27
+ import { WizardStepper, type WizardStepState } from "./wizard-stepper";
27
28
  import { PageBody, PageFooter, PageHeader, PageLayout } from "./page-layout";
28
29
  import { submitWorkflowDelete, submitWorkflowForm } from "./workflow/crud-client";
29
30
  import { useCrudForm } from "./workflow/use-crud-form";
@@ -441,6 +442,7 @@ export function CrudSchemaPage<TData extends Record<string, unknown>>({
441
442
  canGoPrev,
442
443
  goNext,
443
444
  goPrev,
445
+ goToPage,
444
446
  handleSave,
445
447
  } = useCrudForm({ schema, mode, params, onSuccess });
446
448
 
@@ -572,27 +574,61 @@ export function CrudSchemaPage<TData extends Record<string, unknown>>({
572
574
  return (
573
575
  <PageBody>
574
576
  {errBanner}
575
- <ProgressIndicator currentIndex={page} style={{ marginBottom: "1.5rem" }}>
576
- {layout.pages.map((pg, i) => (
577
- <ProgressStep key={i} label={pg.label} />
578
- ))}
579
- </ProgressIndicator>
580
- {currentPage ? (
581
- <SchemaFormRenderer
582
- mode={mode}
583
- value={form}
584
- sections={currentPage.sections}
585
- errors={fieldErrors}
586
- disabled={saving}
587
- onChange={setForm}
588
- density={density}
589
- width={width}
577
+ {/* Isi di kiri, stepper vertikal di kanan — bentuk baku wizard
578
+ (keputusan PM 2026-08-16). `alignItems: flex-start` supaya stepper
579
+ tidak ikut meregang setinggi form; ia daftar, bukan kolom. */}
580
+ <div style={{ display: "flex", gap: "2rem", alignItems: "flex-start" }}>
581
+ <div style={{ flex: 1, minWidth: 0 }}>
582
+ {currentPage ? (
583
+ <SchemaFormRenderer
584
+ mode={mode}
585
+ value={form}
586
+ sections={currentPage.sections}
587
+ errors={fieldErrors}
588
+ disabled={saving}
589
+ onChange={setForm}
590
+ density={density}
591
+ // Pada wizard, form MENGISI kolom kirinya. Default global
592
+ // "two-thirds" membatasi form di dalam kolom yang SUDAH
593
+ // dipersempit stepper — hasilnya sepertiga ruang kosong
594
+ // menganga di antara keduanya. Penulis skema tetap bisa
595
+ // menyempitkannya dgn menyetel schema.width secara eksplisit.
596
+ width={width ?? "full"}
597
+ />
598
+ ) : null}
599
+ </div>
600
+ <WizardStepper
601
+ title={labels.wizardStepsTitle}
602
+ steps={layout.pages.map((pg, i) => ({
603
+ label: pg.label,
604
+ sublabel: pg.sublabel,
605
+ state: wizardStepState(i),
606
+ }))}
607
+ onSelect={goToPage}
590
608
  />
591
- ) : null}
609
+ </div>
592
610
  </PageBody>
593
611
  );
594
612
  };
595
613
 
614
+ /**
615
+ * Keadaan satu langkah di stepper.
616
+ *
617
+ * TERKUNCI hanya untuk langkah SESUDAH langkah aktif yang gerbangnya menahan.
618
+ * Langkah yang belum aktif tapi boleh dibuka diberi keadaan "menunggu", bukan
619
+ * gembok — menyamakan keduanya membuat operator mengira seluruh sisa form
620
+ * terkunci padahal hanya langkah pertama yang menahan.
621
+ */
622
+ const wizardStepState = (i: number): WizardStepState => {
623
+ if (i < page) return "selesai";
624
+ if (i === page) return "aktif";
625
+ const l = schema.layout;
626
+ if (l.type === "wizard" && l.canLeavePage && !l.canLeavePage(page, form).ok) {
627
+ return "terkunci";
628
+ }
629
+ return "menunggu";
630
+ };
631
+
596
632
  const renderFooter = () => {
597
633
  if (isView) {
598
634
  return (
@@ -187,3 +187,55 @@ export const INDONESIA_RUPIAH_WHOLE_CONFIG: CurrencyPrecisionConfig = {
187
187
  precisionByCurrency: { IDR: 0, USD: 2 },
188
188
  defaultPrecision: 0,
189
189
  };
190
+
191
+ // ─── NPWP ────────────────────────────────────────────────────────────────────
192
+
193
+ /** Jumlah digit NPWP: 2+3+3+1+3+3. */
194
+ export const NPWP_DIGITS = 15;
195
+
196
+ /**
197
+ * NPWP yang diketik → berformat `XX.XXX.XXX.X-XXX.XXX`.
198
+ *
199
+ * Pemisah DISISIPKAN otomatis, tidak diketik operator. Alasannya bukan
200
+ * kenyamanan: kalau titik dan strip harus diketik sendiri, setiap salah tempat
201
+ * menghasilkan nomor yang TERLIHAT benar tapi berbeda — dan NPWP adalah
202
+ * identitas pajak yang dicocokkan dengan sistem lain persis karakter demi
203
+ * karakter.
204
+ *
205
+ * Non-angka dibuang, jadi menempel (paste) dari sumber mana pun tetap
206
+ * menghasilkan bentuk yang sama.
207
+ */
208
+ export function normalizeNpwpInput(raw: string): string {
209
+ const d = raw.replace(/\D/g, "").slice(0, NPWP_DIGITS);
210
+ if (!d) return "";
211
+ const bagian = [
212
+ d.slice(0, 2),
213
+ d.slice(2, 5),
214
+ d.slice(5, 8),
215
+ d.slice(8, 9),
216
+ d.slice(9, 12),
217
+ d.slice(12, 15),
218
+ ].filter((x) => x !== "");
219
+
220
+ // Dirakit bertahap supaya bentuknya benar SAAT DIKETIK, bukan hanya setelah
221
+ // 15 digit lengkap. Menunggu lengkap membuat pemisah melompat masuk di akhir
222
+ // dan kursor berpindah — operator kehilangan tempatnya.
223
+ let out = bagian[0] ?? "";
224
+ if (bagian[1]) out += "." + bagian[1];
225
+ if (bagian[2]) out += "." + bagian[2];
226
+ if (bagian[3]) out += "." + bagian[3];
227
+ if (bagian[4]) out += "-" + bagian[4];
228
+ if (bagian[5]) out += "." + bagian[5];
229
+ return out;
230
+ }
231
+
232
+ /** Digit murni NPWP — dipakai backend sebagai tax_id_plain. */
233
+ export function npwpDigits(value: string): string {
234
+ return value.replace(/\D/g, "");
235
+ }
236
+
237
+ /** Sah bila tepat 15 digit. Bentuk pemisah tidak diperiksa: ia dihasilkan
238
+ * normalizeNpwpInput, bukan diketik. */
239
+ export function isValidNpwpInput(value: string): boolean {
240
+ return npwpDigits(value).length === NPWP_DIGITS;
241
+ }
@@ -22,6 +22,7 @@ import {
22
22
  normalizePhoneInput,
23
23
  parseDecimalStrict,
24
24
  sanitizeDecimalInput,
25
+ normalizeNpwpInput,
25
26
  } from "./form-numeric";
26
27
  import {
27
28
  getUi7FormContractClassName,
@@ -944,6 +945,32 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
944
945
  ];
945
946
  }
946
947
 
948
+ // ── NPWP (teks dgn pemisah otomatis) ─────────────────────────
949
+ if (field.numeric?.kind === "npwp") {
950
+ return [
951
+ <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
952
+ <TextInput
953
+ id={key}
954
+ labelText={mkLabel(field.label, effectiveRequired)}
955
+ helperText={field.helperText}
956
+ size={carbonSize}
957
+ value={asString(rawValue)}
958
+ placeholder={field.placeholder ?? "XX.XXX.XXX.X-XXX.XXX"}
959
+ onChange={(e) =>
960
+ setValue(
961
+ field.key,
962
+ normalizeNpwpInput(e.target.value) as TData[keyof TData],
963
+ )
964
+ }
965
+ disabled={disabled}
966
+ readOnly={effectiveReadonly}
967
+ invalid={Boolean(invalidText)}
968
+ invalidText={invalidText}
969
+ />
970
+ </div>,
971
+ ];
972
+ }
973
+
947
974
  // ── Phone (text with numeric phone normalization) ────────────
948
975
  if (field.numeric?.kind === "phone") {
949
976
  return [
package/src/form-rules.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { isValidPhoneInput } from "./form-numeric";
1
+ import { isValidNpwpInput, isValidPhoneInput } from "./form-numeric";
2
2
  import type { PhoneRule } from "./form-numeric";
3
3
 
4
4
  export type FieldValidatorFn<TData = Record<string, unknown>> = (
@@ -83,6 +83,19 @@ export const Rules = {
83
83
  };
84
84
  },
85
85
 
86
+ /**
87
+ * NPWP — 15 digit, ditampilkan `XX.XXX.XXX.X-XXX.XXX`.
88
+ *
89
+ * Yang diperiksa jumlah DIGIT-nya, bukan bentuk pemisahnya: pemisah
90
+ * dihasilkan normalizeNpwpInput dan tidak pernah diketik operator, jadi
91
+ * memeriksanya berarti menguji kode sendiri alih-alih masukan pengguna.
92
+ */
93
+ npwp(msg?: string): FieldValidatorFn {
94
+ const m = msg ?? "NPWP harus 15 digit";
95
+ return (v) =>
96
+ v && typeof v === "string" && !isValidNpwpInput(v) ? m : undefined;
97
+ },
98
+
86
99
  regex(pattern: RegExp, msg: string): FieldValidatorFn {
87
100
  return (v) =>
88
101
  v && typeof v === "string" && !pattern.test(v) ? msg : undefined;
package/src/form-types.ts CHANGED
@@ -70,7 +70,8 @@ export type FormFieldNumericConfig<TData extends Record<string, unknown>> =
70
70
  | { kind: "currency"; currencyField: keyof TData; precisionByCurrency: Record<string, number>; defaultPrecision?: number }
71
71
  | { kind: "percent"; maxFractionDigits: number }
72
72
  | { kind: "integer" }
73
- | { kind: "phone"; minDigits: number; maxDigits: number; allowedPrefixes?: string[] };
73
+ | { kind: "phone"; minDigits: number; maxDigits: number; allowedPrefixes?: string[] }
74
+ | { kind: "npwp" };
74
75
 
75
76
  /**
76
77
  * Toggle field state mapping for non-boolean stored values.
@@ -196,6 +197,11 @@ export type FormSection<TData extends Record<string, unknown>> = {
196
197
 
197
198
  export type FormPageDef<TData extends Record<string, unknown>> = {
198
199
  label: string;
200
+ /**
201
+ * Baris kedua di stepper — menjelaskan ISI langkah, bukan mengulang
202
+ * labelnya. Opsional; tanpa ini stepper hanya menampilkan label.
203
+ */
204
+ sublabel?: string;
199
205
  sections: FormSection<TData>[];
200
206
  /** Optional: save draft on Next. Returns {ok,error?} (no 202 required). */
201
207
  stepSaveApiPath?: ApiPath;
package/src/i18n.tsx CHANGED
@@ -33,6 +33,8 @@ export type BosSharedLabels = {
33
33
  cancel: string;
34
34
  prev: string;
35
35
  next: string;
36
+ /** Judul stepper vertikal wizard. */
37
+ wizardStepsTitle: string;
36
38
 
37
39
  subtitleCreate: string;
38
40
  subtitleEdit: string;
@@ -81,6 +83,7 @@ export function useBosSharedI18n(): BosSharedLabels {
81
83
  cancel: t("bos7.cancel", "Batal"),
82
84
  prev: t("bos7.prev", "Kembali"),
83
85
  next: t("bos7.next", "Lanjut"),
86
+ wizardStepsTitle: t("bos7.wizardStepsTitle", "Langkah Pengisian"),
84
87
 
85
88
  subtitleCreate: t("bos7.subtitleCreate", "Isi form untuk menambah data baru."),
86
89
  subtitleEdit: t("bos7.subtitleEdit", "Edit data yang dipilih."),
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { normalizeNpwpInput, npwpDigits, isValidNpwpInput, NPWP_DIGITS } from "./form-numeric";
3
+ import { Rules } from "./form-rules";
4
+
5
+ // NPWP adalah identitas pajak yang dicocokkan dengan sistem lain persis
6
+ // karakter demi karakter. Kalau pemisah harus diketik sendiri, setiap salah
7
+ // tempat menghasilkan nomor yang TERLIHAT benar tapi berbeda — dan
8
+ // perbedaannya tidak akan terlihat sampai pencocokan di sistem lain gagal.
9
+
10
+ describe("normalizeNpwpInput", () => {
11
+ it("menyisipkan pemisah pada 15 digit", () => {
12
+ expect(normalizeNpwpInput("123456789012345")).toBe("12.345.678.9-012.345");
13
+ });
14
+
15
+ it("membentuk BERTAHAP saat diketik, bukan hanya setelah lengkap", () => {
16
+ // Menunggu 15 digit membuat pemisah melompat masuk di akhir dan kursor
17
+ // berpindah — operator kehilangan tempatnya di tengah pengetikan.
18
+ expect(normalizeNpwpInput("1")).toBe("1");
19
+ expect(normalizeNpwpInput("12")).toBe("12");
20
+ expect(normalizeNpwpInput("123")).toBe("12.3");
21
+ expect(normalizeNpwpInput("12345")).toBe("12.345");
22
+ expect(normalizeNpwpInput("123456")).toBe("12.345.6");
23
+ expect(normalizeNpwpInput("123456789")).toBe("12.345.678.9");
24
+ expect(normalizeNpwpInput("123456789012")).toBe("12.345.678.9-012");
25
+ });
26
+
27
+ it("membuang non-angka — menempel dari sumber mana pun tetap seragam", () => {
28
+ expect(normalizeNpwpInput("12.345.678.9-012.345")).toBe("12.345.678.9-012.345");
29
+ expect(normalizeNpwpInput("12 345 678 9 012 345")).toBe("12.345.678.9-012.345");
30
+ expect(normalizeNpwpInput("abc12def345678 9012345")).toBe("12.345.678.9-012.345");
31
+ });
32
+
33
+ it("memotong kelebihan digit — tidak menerima nomor lebih panjang", () => {
34
+ expect(normalizeNpwpInput("1234567890123456789")).toBe("12.345.678.9-012.345");
35
+ expect(npwpDigits(normalizeNpwpInput("1234567890123456789")).length).toBe(NPWP_DIGITS);
36
+ });
37
+
38
+ it("kosong tetap kosong", () => {
39
+ expect(normalizeNpwpInput("")).toBe("");
40
+ expect(normalizeNpwpInput("abc")).toBe("");
41
+ });
42
+ });
43
+
44
+ describe("npwpDigits — sumber tax_id_plain", () => {
45
+ it("mengembalikan digit murni", () => {
46
+ // Backend memakai ini untuk mengisi tax_id_plain. Kalau ia ikut membawa
47
+ // pemisah, kolom "plain" berhenti menjadi plain dan pencocokan numerik
48
+ // di sisi lain gagal tanpa alasan yang terlihat.
49
+ expect(npwpDigits("12.345.678.9-012.345")).toBe("123456789012345");
50
+ });
51
+ });
52
+
53
+ describe("Rules.npwp", () => {
54
+ const r = Rules.npwp();
55
+
56
+ it("menerima yang 15 digit", () => {
57
+ expect(r("12.345.678.9-012.345", {})).toBeUndefined();
58
+ expect(r("123456789012345", {})).toBeUndefined();
59
+ });
60
+
61
+ it("MENOLAK yang kurang dari 15 digit", () => {
62
+ expect(r("12.345.678", {})).toBeTruthy();
63
+ expect(r("1234567890123", {})).toBeTruthy();
64
+ });
65
+
66
+ it("kosong lolos — kewajiban diurus `required`", () => {
67
+ expect(r("", {})).toBeUndefined();
68
+ });
69
+
70
+ it("isValidNpwpInput sepakat dengan aturannya", () => {
71
+ // Dua jalur (validator & helper) tidak boleh berbeda pendapat: satu dipakai
72
+ // form, satu bisa dipakai kode lain, dan perbedaan di antaranya berarti
73
+ // nomor yang lolos di satu tempat ditolak di tempat lain.
74
+ for (const v of ["12.345.678.9-012.345", "123", "", "12345678901234"]) {
75
+ const lolosAturan = r(v, {}) === undefined;
76
+ const lolosHelper = v === "" ? true : isValidNpwpInput(v);
77
+ expect(lolosAturan, `tidak sepakat untuk ${JSON.stringify(v)}`).toBe(lolosHelper);
78
+ }
79
+ });
80
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import { WizardStepper } from "./wizard-stepper";
5
+ import { useCrudForm } from "./workflow/use-crud-form";
6
+ import type { CrudForm } from "./form-types";
7
+
8
+ type D = Record<string, unknown>;
9
+
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Stepper: gembok harus DIBEDAKAN dari sekadar "belum aktif"
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ describe("WizardStepper", () => {
14
+ it("langkah terkunci TIDAK bisa diklik, yang menunggu bisa", () => {
15
+ // Menyamakan "terkunci" dengan "belum aktif" membuat operator mengira
16
+ // seluruh sisa form terkunci padahal hanya langkah pertama yang menahan.
17
+ const onSelect = vi.fn();
18
+ render(
19
+ <WizardStepper
20
+ title="Langkah"
21
+ onSelect={onSelect}
22
+ steps={[
23
+ { label: "Satu", state: "selesai" },
24
+ { label: "Dua", state: "aktif" },
25
+ { label: "Tiga", state: "menunggu" },
26
+ { label: "Empat", state: "terkunci" },
27
+ ]}
28
+ />,
29
+ );
30
+
31
+ screen.getByText("Empat").closest("button")!.click();
32
+ expect(onSelect, "langkah terkunci tidak boleh memanggil onSelect").not.toHaveBeenCalled();
33
+
34
+ screen.getByText("Tiga").closest("button")!.click();
35
+ expect(onSelect).toHaveBeenCalledWith(2);
36
+ });
37
+
38
+ it("menampilkan sublabel bila ada, dan tidak memaksanya", () => {
39
+ render(
40
+ <WizardStepper
41
+ title="Langkah"
42
+ steps={[
43
+ { label: "Data Nasabah", sublabel: "Identitas & informasi dasar", state: "aktif" },
44
+ { label: "Tanpa Sublabel", state: "menunggu" },
45
+ ]}
46
+ />,
47
+ );
48
+ expect(screen.getByText("Identitas & informasi dasar")).toBeTruthy();
49
+ expect(screen.getByText("Tanpa Sublabel")).toBeTruthy();
50
+ });
51
+
52
+ it("judul dipakai sebagai label navigasi — stepper bisa ditemukan pembaca layar", () => {
53
+ render(<WizardStepper title="Langkah Pengisian" steps={[{ label: "A", state: "aktif" }]} />);
54
+ expect(screen.getByRole("navigation", { name: "Langkah Pengisian" })).toBeTruthy();
55
+ });
56
+ });
57
+
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+ // goToPage: stepper tidak boleh jadi pintu belakang
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ function schema(gate?: (from: number, d: D) => { ok: boolean; message?: string }): CrudForm<D> {
62
+ return {
63
+ title: { create: "c", edit: "e", view: "v" },
64
+ emptyData: {},
65
+ layout: {
66
+ type: "wizard",
67
+ canLeavePage: gate,
68
+ pages: [
69
+ { label: "Satu", sections: [] },
70
+ { label: "Dua", sections: [] },
71
+ { label: "Tiga", sections: [] },
72
+ ],
73
+ },
74
+ } as CrudForm<D>;
75
+ }
76
+
77
+ describe("goToPage", () => {
78
+ it("MAJU lewat stepper tetap melewati gerbang — bukan pintu belakang", async () => {
79
+ // Kalau stepper bisa melompati gerbang, seluruh verifikasi Langkah 1 bisa
80
+ // dilewati hanya dengan mengklik langkah 3 — dan tidak ada yang berbunyi.
81
+ const { result } = renderHook(() =>
82
+ useCrudForm<D>({ schema: schema(() => ({ ok: false, message: "ditahan" })), mode: "create" }),
83
+ );
84
+
85
+ await act(async () => {
86
+ result.current.goToPage(2);
87
+ });
88
+
89
+ expect(result.current.page, "harus tetap di halaman 0").toBe(0);
90
+ expect(result.current.error).toBe("ditahan");
91
+ });
92
+
93
+ it("MUNDUR selalu boleh, meski gerbangnya menahan", async () => {
94
+ // Menahan operator melihat kembali apa yang ia tulis tidak melindungi
95
+ // apa pun — datanya sudah ada di form.
96
+ const { result } = renderHook(() =>
97
+ useCrudForm<D>({ schema: schema(), mode: "create" }),
98
+ );
99
+ await act(async () => {
100
+ await result.current.goNext();
101
+ });
102
+ await act(async () => {
103
+ await result.current.goNext();
104
+ });
105
+ expect(result.current.page).toBe(2);
106
+
107
+ await act(async () => {
108
+ result.current.goToPage(0);
109
+ });
110
+ expect(result.current.page).toBe(0);
111
+ });
112
+
113
+ it("indeks di luar jangkauan diabaikan, bukan melempar", async () => {
114
+ const { result } = renderHook(() => useCrudForm<D>({ schema: schema(), mode: "create" }));
115
+ await act(async () => {
116
+ result.current.goToPage(-1);
117
+ result.current.goToPage(99);
118
+ });
119
+ expect(result.current.page).toBe(0);
120
+ });
121
+ });
@@ -0,0 +1,189 @@
1
+ "use client";
2
+ import type { ReactNode } from "react";
3
+
4
+ /**
5
+ * Stepper VERTIKAL di sisi kanan — bentuk baku wizard (keputusan PM 2026-08-16).
6
+ *
7
+ * Menggantikan ProgressIndicator horizontal Carbon. Alasannya bukan selera:
8
+ * wizard di sini punya langkah yang TERKUNCI, dan gembok perlu tempat untuk
9
+ * terbaca. Pada stepper horizontal, lima langkah berikut sublabel dan ikonnya
10
+ * berdesakan sampai keduanya hilang — yang tersisa hanya lingkaran bernomor,
11
+ * dan operator tidak bisa tahu langkah mana yang belum boleh dibuka sampai ia
12
+ * menekannya.
13
+ *
14
+ * Tiga keadaan, dan gembok DIBEDAKAN dari sekadar "belum aktif":
15
+ *
16
+ * selesai → sudah dilewati
17
+ * aktif → sedang diisi
18
+ * terkunci → belum boleh dibuka (gembok)
19
+ *
20
+ * Langkah yang belum aktif TAPI boleh dibuka tidak diberi gembok. Menyamakan
21
+ * keduanya membuat operator mengira seluruh sisa form terkunci padahal hanya
22
+ * langkah pertama yang menahan.
23
+ */
24
+
25
+ export type WizardStepState = "selesai" | "aktif" | "terkunci" | "menunggu";
26
+
27
+ export type WizardStepView = {
28
+ label: string;
29
+ sublabel?: string;
30
+ state: WizardStepState;
31
+ };
32
+
33
+ function Ikon({ state, nomor }: { state: WizardStepState; nomor: number }): ReactNode {
34
+ const dasar = {
35
+ width: "1.75rem",
36
+ height: "1.75rem",
37
+ borderRadius: "50%",
38
+ display: "flex",
39
+ alignItems: "center",
40
+ justifyContent: "center",
41
+ fontSize: "0.8125rem",
42
+ flexShrink: 0,
43
+ } as const;
44
+
45
+ if (state === "aktif") {
46
+ return (
47
+ <span style={{ ...dasar, background: "var(--cds-interactive, #0f62fe)", color: "#fff" }}>
48
+ {nomor}
49
+ </span>
50
+ );
51
+ }
52
+ if (state === "selesai") {
53
+ return (
54
+ <span
55
+ style={{ ...dasar, background: "var(--cds-support-success, #24a148)", color: "#fff" }}
56
+ aria-label="selesai"
57
+ >
58
+
59
+ </span>
60
+ );
61
+ }
62
+ if (state === "terkunci") {
63
+ return (
64
+ <span
65
+ style={{
66
+ ...dasar,
67
+ border: "1px solid var(--cds-border-subtle, #c6c6c6)",
68
+ color: "var(--cds-text-disabled, #a8a8a8)",
69
+ }}
70
+ aria-label="terkunci"
71
+ >
72
+ 🔒
73
+ </span>
74
+ );
75
+ }
76
+ return (
77
+ <span
78
+ style={{
79
+ ...dasar,
80
+ border: "1px solid var(--cds-border-subtle, #c6c6c6)",
81
+ color: "var(--cds-text-secondary, #525252)",
82
+ }}
83
+ >
84
+ {nomor}
85
+ </span>
86
+ );
87
+ }
88
+
89
+ export function WizardStepper({
90
+ steps,
91
+ title,
92
+ onSelect,
93
+ }: {
94
+ steps: WizardStepView[];
95
+ title: string;
96
+ /** Dipanggil saat langkah diklik. Langkah TERKUNCI tidak pernah memanggilnya. */
97
+ onSelect?: (index: number) => void;
98
+ }) {
99
+ return (
100
+ <nav
101
+ aria-label={title}
102
+ style={{
103
+ width: "13.5rem",
104
+ flexShrink: 0,
105
+ display: "flex",
106
+ flexDirection: "column",
107
+ gap: "0.25rem",
108
+ }}
109
+ >
110
+ <div
111
+ style={{
112
+ fontSize: "0.6875rem",
113
+ letterSpacing: "0.08em",
114
+ textTransform: "uppercase",
115
+ color: "var(--cds-text-secondary, #525252)",
116
+ marginBottom: "0.5rem",
117
+ }}
118
+ >
119
+ {title}
120
+ </div>
121
+
122
+ {steps.map((s, i) => {
123
+ const bisaDiklik = s.state !== "terkunci" && Boolean(onSelect);
124
+ return (
125
+ <div key={i} style={{ display: "flex", gap: "0.75rem" }}>
126
+ {/* Garis penghubung: menandai bahwa langkah-langkah ini SATU urutan,
127
+ bukan daftar menu yang bisa dimasuki dari mana saja. */}
128
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
129
+ <Ikon state={s.state} nomor={i + 1} />
130
+ {i < steps.length - 1 ? (
131
+ <div
132
+ style={{
133
+ width: 1,
134
+ flex: 1,
135
+ minHeight: "1.25rem",
136
+ background: "var(--cds-border-subtle, #e0e0e0)",
137
+ }}
138
+ />
139
+ ) : null}
140
+ </div>
141
+
142
+ <button
143
+ type="button"
144
+ disabled={!bisaDiklik}
145
+ onClick={bisaDiklik ? () => onSelect?.(i) : undefined}
146
+ style={{
147
+ background: "none",
148
+ border: "none",
149
+ padding: "0.125rem 0 1rem",
150
+ textAlign: "left",
151
+ cursor: bisaDiklik ? "pointer" : "default",
152
+ font: "inherit",
153
+ }}
154
+ >
155
+ <div
156
+ style={{
157
+ fontSize: "0.875rem",
158
+ fontWeight: s.state === "aktif" ? 600 : 400,
159
+ color:
160
+ s.state === "aktif"
161
+ ? "var(--cds-link-primary, #0f62fe)"
162
+ : s.state === "terkunci"
163
+ ? "var(--cds-text-disabled, #a8a8a8)"
164
+ : "var(--cds-text-primary, #161616)",
165
+ }}
166
+ >
167
+ {s.label}
168
+ </div>
169
+ {s.sublabel ? (
170
+ <div
171
+ style={{
172
+ fontSize: "0.75rem",
173
+ color:
174
+ s.state === "terkunci"
175
+ ? "var(--cds-text-disabled, #a8a8a8)"
176
+ : "var(--cds-text-secondary, #525252)",
177
+ marginTop: "0.125rem",
178
+ }}
179
+ >
180
+ {s.sublabel}
181
+ </div>
182
+ ) : null}
183
+ </button>
184
+ </div>
185
+ );
186
+ })}
187
+ </nav>
188
+ );
189
+ }
@@ -35,6 +35,16 @@ export type UseCrudFormReturn<TData extends Record<string, unknown>> = {
35
35
  canGoPrev: boolean;
36
36
  goNext: () => Promise<void>;
37
37
  goPrev: () => void;
38
+ /**
39
+ * Lompat ke halaman tertentu lewat stepper.
40
+ *
41
+ * MUNDUR selalu boleh — data yang sudah diisi tidak hilang, dan menahan
42
+ * operator melihat kembali apa yang ia tulis tidak melindungi apa pun.
43
+ * MAJU diteruskan ke goNext supaya melewati gerbang YANG SAMA dengan tombol
44
+ * Berikutnya; kalau tidak, stepper menjadi pintu belakang yang melewati
45
+ * verifikasi.
46
+ */
47
+ goToPage: (target: number) => void;
38
48
  handleSave: () => Promise<void>;
39
49
  };
40
50
 
@@ -180,6 +190,17 @@ export function useCrudForm<TData extends Record<string, unknown>>({
180
190
  }
181
191
  };
182
192
 
193
+ const goToPage = (target: number) => {
194
+ if (target === page || target < 0 || target >= pageCount) return;
195
+ if (target < page) {
196
+ setServerError(null);
197
+ setHasValidationError(false);
198
+ setPage(target);
199
+ return;
200
+ }
201
+ void goNext();
202
+ };
203
+
183
204
  return {
184
205
  form,
185
206
  setForm,
@@ -194,6 +215,7 @@ export function useCrudForm<TData extends Record<string, unknown>>({
194
215
  canGoPrev: page > 0,
195
216
  goNext,
196
217
  goPrev,
218
+ goToPage,
197
219
  handleSave,
198
220
  };
199
221
  }