@isi-ui7/bos7-shared 0.3.5 → 0.3.7
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.
- package/dist/form-numeric.d.ts +20 -0
- package/dist/form-renderer-utils.d.ts +25 -1
- package/dist/form-rules.d.ts +25 -0
- package/dist/form-types.d.ts +7 -0
- package/dist/i18n.d.ts +2 -0
- package/dist/{index-DV8lJjii.js → index-BI2MxWot.js} +2 -1
- package/dist/{index.es-DqL2wDqB.js → index.es-D-PR3JkH.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/{jspdf.es.min-iZWDIC-k.js → jspdf.es.min-DzRwzM6G.js} +1 -1
- package/dist/wizard-stepper.d.ts +32 -0
- package/dist/workflow/use-crud-form.d.ts +10 -0
- package/package.json +1 -1
- package/src/crud-components.tsx +47 -16
- package/src/date-and-hidden-validation.test.ts +140 -0
- package/src/form-numeric.ts +52 -0
- package/src/form-renderer-utils.ts +51 -3
- package/src/form-renderer.tsx +34 -6
- package/src/form-rules.ts +56 -1
- package/src/form-types.ts +7 -1
- package/src/i18n.tsx +3 -0
- package/src/npwp.test.ts +80 -0
- package/src/wizard-stepper.test.tsx +121 -0
- package/src/wizard-stepper.tsx +189 -0
- package/src/workflow/use-crud-form.ts +32 -1
package/dist/form-numeric.d.ts
CHANGED
|
@@ -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;
|
|
@@ -5,6 +5,30 @@ export declare function resolveApiPath(path: ApiPath, params: Record<string, str
|
|
|
5
5
|
type ValidationI18n = Pick<BosSharedLabels, "validRequired" | "validMinLength" | "validMaxLength" | "validMin" | "validMax" | "validPattern">;
|
|
6
6
|
export declare function runFieldValidation<TData extends Record<string, unknown>>(fields: FormField<TData>[], data: TData, mode: FormMode, i18n?: Partial<ValidationI18n>): Partial<Record<keyof TData, string>>;
|
|
7
7
|
export declare function collectAllFields<TData extends Record<string, unknown>>(layout: FormLayout<TData>): FormField<TData>[];
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Field yang BOLEH divalidasi pada satu halaman.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ Section yang `hidden` DILEWATI. Tanpa ini, field wajib di panel yang belum
|
|
12
|
+
* terbuka ikut divalidasi — dan operator melihat "wajib diisi" untuk field yang
|
|
13
|
+
* tidak ada di layarnya, tanpa cara memperbaikinya.
|
|
14
|
+
*
|
|
15
|
+
* `mode`/`data` opsional supaya pemanggil lama tetap jalan; tanpa keduanya,
|
|
16
|
+
* hanya `hidden: true` literal yang bisa dievaluasi — predikat fungsi
|
|
17
|
+
* diperlakukan sebagai TIDAK tersembunyi, arah aman untuk validasi.
|
|
18
|
+
*/
|
|
19
|
+
export declare function collectPageFields<TData extends Record<string, unknown>>(layout: FormLayout<TData>, pageIndex: number, mode?: FormMode, data?: TData): FormField<TData>[];
|
|
9
20
|
export declare function getLayoutPageCount<TData extends Record<string, unknown>>(layout: FormLayout<TData>): number;
|
|
21
|
+
/**
|
|
22
|
+
* Tanggal yang dipilih → "YYYY-MM-DD" menurut kalender LOKAL.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ Ada karena `date.toISOString().slice(0,10)` MENGGESER tanggal satu hari
|
|
25
|
+
* bagi siapa pun di timur UTC. `new Date(1990,0,1)` di WIB (UTC+7) menjadi
|
|
26
|
+
* "1989-12-31T17:00:00Z" — tanggal lahir yang diketik operator tersimpan
|
|
27
|
+
* mundur sehari, tanpa satu pun galat, di setiap field tanggal di setiap modul.
|
|
28
|
+
*
|
|
29
|
+
* Komponen lokal dipakai apa adanya: pemilih tanggal memilih HARI di kalender
|
|
30
|
+
* pengguna, bukan sebuah titik waktu. Mengubahnya ke UTC memperlakukannya
|
|
31
|
+
* sebagai titik waktu, dan di situlah pergeserannya lahir.
|
|
32
|
+
*/
|
|
33
|
+
export declare function toLocalISODate(date: Date | undefined | null): string;
|
|
10
34
|
export {};
|
package/dist/form-rules.d.ts
CHANGED
|
@@ -7,6 +7,31 @@ export declare const Rules: {
|
|
|
7
7
|
readonly nik: (msg?: string) => FieldValidatorFn;
|
|
8
8
|
readonly kodePos: (msg?: string) => FieldValidatorFn;
|
|
9
9
|
readonly phone: (rule: PhoneRule, msg?: string) => FieldValidatorFn;
|
|
10
|
+
/**
|
|
11
|
+
* Tanggal yang tersimpan harus berupa tanggal KALENDER yang sungguh ada.
|
|
12
|
+
*
|
|
13
|
+
* Nilai yang disimpan berformat ISO (YYYY-MM-DD) meski ditampilkan
|
|
14
|
+
* DD-MM-YYYY — aturan ini memeriksa nilai simpannya.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ Memeriksa hari-nya kembali, bukan hanya `new Date(v)` tidak NaN.
|
|
17
|
+
* `new Date("2026-02-31")` di sebagian mesin MENGGULUNG jadi 3 Maret alih-alih
|
|
18
|
+
* menolak, sehingga tanggal yang tidak ada lolos dan tersimpan sebagai
|
|
19
|
+
* tanggal LAIN — tanpa galat, dan tanpa cara mengetahuinya kemudian.
|
|
20
|
+
*/
|
|
21
|
+
readonly date: (msg?: string) => FieldValidatorFn;
|
|
22
|
+
/**
|
|
23
|
+
* Tanggal tidak boleh di masa depan — untuk tanggal lahir dan sejenisnya.
|
|
24
|
+
* `now` bisa disuntik agar bisa diuji tanpa bergantung jam mesin.
|
|
25
|
+
*/
|
|
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;
|
|
10
35
|
readonly regex: (pattern: RegExp, msg: string) => FieldValidatorFn;
|
|
11
36
|
};
|
|
12
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>>;
|
package/dist/form-types.d.ts
CHANGED
|
@@ -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
|
@@ -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-
|
|
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-
|
|
2
|
-
import { _ as Xa } from "./jspdf.es.min-
|
|
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-
|
|
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-
|
|
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
package/src/crud-components.tsx
CHANGED
|
@@ -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,56 @@ export function CrudSchemaPage<TData extends Record<string, unknown>>({
|
|
|
572
574
|
return (
|
|
573
575
|
<PageBody>
|
|
574
576
|
{errBanner}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
+
width={width}
|
|
592
|
+
/>
|
|
593
|
+
) : null}
|
|
594
|
+
</div>
|
|
595
|
+
<WizardStepper
|
|
596
|
+
title={labels.wizardStepsTitle}
|
|
597
|
+
steps={layout.pages.map((pg, i) => ({
|
|
598
|
+
label: pg.label,
|
|
599
|
+
sublabel: pg.sublabel,
|
|
600
|
+
state: wizardStepState(i),
|
|
601
|
+
}))}
|
|
602
|
+
onSelect={goToPage}
|
|
590
603
|
/>
|
|
591
|
-
|
|
604
|
+
</div>
|
|
592
605
|
</PageBody>
|
|
593
606
|
);
|
|
594
607
|
};
|
|
595
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Keadaan satu langkah di stepper.
|
|
611
|
+
*
|
|
612
|
+
* TERKUNCI hanya untuk langkah SESUDAH langkah aktif yang gerbangnya menahan.
|
|
613
|
+
* Langkah yang belum aktif tapi boleh dibuka diberi keadaan "menunggu", bukan
|
|
614
|
+
* gembok — menyamakan keduanya membuat operator mengira seluruh sisa form
|
|
615
|
+
* terkunci padahal hanya langkah pertama yang menahan.
|
|
616
|
+
*/
|
|
617
|
+
const wizardStepState = (i: number): WizardStepState => {
|
|
618
|
+
if (i < page) return "selesai";
|
|
619
|
+
if (i === page) return "aktif";
|
|
620
|
+
const l = schema.layout;
|
|
621
|
+
if (l.type === "wizard" && l.canLeavePage && !l.canLeavePage(page, form).ok) {
|
|
622
|
+
return "terkunci";
|
|
623
|
+
}
|
|
624
|
+
return "menunggu";
|
|
625
|
+
};
|
|
626
|
+
|
|
596
627
|
const renderFooter = () => {
|
|
597
628
|
if (isView) {
|
|
598
629
|
return (
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { toLocalISODate, collectPageFields, runFieldValidation } from "./form-renderer-utils";
|
|
3
|
+
import { Rules } from "./form-rules";
|
|
4
|
+
import type { FormLayout, FormSection } from "./form-types";
|
|
5
|
+
|
|
6
|
+
type D = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// toLocalISODate — pergeseran tanggal satu hari
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
describe("toLocalISODate", () => {
|
|
12
|
+
it("TIDAK menggeser tanggal bagi zona di timur UTC", () => {
|
|
13
|
+
// Ini bug yang sesungguhnya: `date.toISOString().slice(0,10)` mengubah
|
|
14
|
+
// tanggal ke UTC. Di WIB (UTC+7), 1 Januari pukul 00:00 lokal adalah
|
|
15
|
+
// 31 Desember pukul 17:00 UTC — dan tanggal lahir yang diketik operator
|
|
16
|
+
// tersimpan MUNDUR SEHARI, di setiap field tanggal di setiap modul.
|
|
17
|
+
const picked = new Date(1990, 0, 1); // 1 Jan 1990, waktu lokal
|
|
18
|
+
expect(toLocalISODate(picked)).toBe("1990-01-01");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("kontrol: cara LAMA memang menggeser — membuktikan tes ini mengukur sesuatu", () => {
|
|
22
|
+
const picked = new Date(1990, 0, 1);
|
|
23
|
+
const lama = picked.toISOString().slice(0, 10);
|
|
24
|
+
const offsetMenit = picked.getTimezoneOffset(); // negatif di timur UTC
|
|
25
|
+
if (offsetMenit < 0) {
|
|
26
|
+
expect(lama).not.toBe("1990-01-01");
|
|
27
|
+
} else {
|
|
28
|
+
// Di UTC atau barat UTC cara lama kebetulan benar — itulah sebabnya bug
|
|
29
|
+
// ini bisa lolos di mesin pengembang yang zonanya berbeda.
|
|
30
|
+
expect(lama).toBe("1990-01-01");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("tengah tahun & akhir bulan tetap utuh", () => {
|
|
35
|
+
expect(toLocalISODate(new Date(2026, 7, 15))).toBe("2026-08-15");
|
|
36
|
+
expect(toLocalISODate(new Date(2024, 1, 29))).toBe("2024-02-29"); // kabisat
|
|
37
|
+
expect(toLocalISODate(new Date(2026, 11, 31))).toBe("2026-12-31");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("nilai kosong / tak sah → string kosong, bukan lempar", () => {
|
|
41
|
+
expect(toLocalISODate(undefined)).toBe("");
|
|
42
|
+
expect(toLocalISODate(null)).toBe("");
|
|
43
|
+
expect(toLocalISODate(new Date("bukan tanggal"))).toBe("");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
// Rules.date — tanggal kalender yang sungguh ada
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
describe("Rules.date", () => {
|
|
51
|
+
const r = Rules.date();
|
|
52
|
+
|
|
53
|
+
it("menerima tanggal sah", () => {
|
|
54
|
+
expect(r("1990-01-01", {})).toBeUndefined();
|
|
55
|
+
expect(r("2024-02-29", {})).toBeUndefined(); // kabisat
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("MENOLAK tanggal yang tidak ada di kalender", () => {
|
|
59
|
+
// `new Date("2026-02-31")` di sebagian mesin MENGGULUNG jadi 3 Maret
|
|
60
|
+
// alih-alih menolak. Tanpa pemeriksaan balik, tanggal itu lolos dan
|
|
61
|
+
// tersimpan sebagai tanggal LAIN.
|
|
62
|
+
expect(r("2026-02-31", {})).toBeTruthy();
|
|
63
|
+
expect(r("2026-13-01", {})).toBeTruthy();
|
|
64
|
+
expect(r("2023-02-29", {})).toBeTruthy(); // 2023 bukan kabisat
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("menolak bentuk yang bukan ISO", () => {
|
|
68
|
+
expect(r("01-01-1990", {})).toBeTruthy();
|
|
69
|
+
expect(r("1990/01/01", {})).toBeTruthy();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("kosong lolos — kewajiban diurus `required`", () => {
|
|
73
|
+
expect(r("", {})).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("Rules.dateNotFuture", () => {
|
|
78
|
+
const now = () => new Date(2026, 7, 15);
|
|
79
|
+
const r = Rules.dateNotFuture(undefined, now);
|
|
80
|
+
|
|
81
|
+
it("hari ini masih boleh", () => {
|
|
82
|
+
expect(r("2026-08-15", {})).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
it("kemarin boleh", () => {
|
|
85
|
+
expect(r("2026-08-14", {})).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
it("besok DITOLAK", () => {
|
|
88
|
+
expect(r("2026-08-16", {})).toBeTruthy();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
// collectPageFields — section tersembunyi
|
|
94
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
95
|
+
const terbuka: FormSection<D> = {
|
|
96
|
+
title: "Terbuka",
|
|
97
|
+
fields: [{ key: "a", label: "A", type: "text", span: 4, validation: { required: true } }],
|
|
98
|
+
};
|
|
99
|
+
const tertutup: FormSection<D> = {
|
|
100
|
+
title: "Tertutup",
|
|
101
|
+
fields: [{ key: "b", label: "B", type: "text", span: 4, validation: { required: true } }],
|
|
102
|
+
hidden: (_m, d) => d.buka !== true,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const layout: FormLayout<D> = {
|
|
106
|
+
type: "wizard",
|
|
107
|
+
pages: [{ label: "Satu", sections: [terbuka, tertutup] }],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
describe("collectPageFields menghormati section.hidden", () => {
|
|
111
|
+
it("field di section TERSEMBUNYI tidak ikut divalidasi", () => {
|
|
112
|
+
// Inti keluhan: operator melihat "wajib diisi" untuk field yang tidak ada
|
|
113
|
+
// di layarnya, tanpa cara memperbaikinya.
|
|
114
|
+
const fields = collectPageFields(layout, 0, "create", { buka: false });
|
|
115
|
+
expect(fields.map((f) => String(f.key))).toEqual(["a"]);
|
|
116
|
+
|
|
117
|
+
const errs = runFieldValidation(fields, { buka: false }, "create");
|
|
118
|
+
expect(errs.b).toBeUndefined();
|
|
119
|
+
expect(errs.a).toBeTruthy(); // yang tampil tetap divalidasi
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("field ikut divalidasi begitu section-nya terbuka", () => {
|
|
123
|
+
const fields = collectPageFields(layout, 0, "create", { buka: true });
|
|
124
|
+
expect(fields.map((f) => String(f.key)).sort()).toEqual(["a", "b"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("tanpa mode/data, predikat fungsi dianggap TIDAK tersembunyi", () => {
|
|
128
|
+
// Arah aman: pemanggil lama yang belum meneruskan konteks tetap
|
|
129
|
+
// memvalidasi segalanya, bukan diam-diam melewatkan field wajib.
|
|
130
|
+
expect(collectPageFields(layout, 0).map((f) => String(f.key)).sort()).toEqual(["a", "b"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("hidden: true literal tetap dilewati tanpa konteks", () => {
|
|
134
|
+
const l: FormLayout<D> = {
|
|
135
|
+
type: "wizard",
|
|
136
|
+
pages: [{ label: "S", sections: [terbuka, { ...tertutup, hidden: true }] }],
|
|
137
|
+
};
|
|
138
|
+
expect(collectPageFields(l, 0).map((f) => String(f.key))).toEqual(["a"]);
|
|
139
|
+
});
|
|
140
|
+
});
|
package/src/form-numeric.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApiPath, FieldValidation, FormField, FormLayout, FormMode } from "./form-types";
|
|
1
|
+
import type { ApiPath, FieldValidation, FormField, FormLayout, FormMode, FormSection} from "./form-types";
|
|
2
2
|
import type { BosSharedLabels } from "./i18n";
|
|
3
3
|
|
|
4
4
|
// Non-React fallback — callers (React components) should always pass i18n from useBosSharedI18n().
|
|
@@ -124,12 +124,40 @@ export function collectAllFields<TData extends Record<string, unknown>>(
|
|
|
124
124
|
return sections.flatMap((s) => s.fields);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Field yang BOLEH divalidasi pada satu halaman.
|
|
129
|
+
*
|
|
130
|
+
* ⚠️ Section yang `hidden` DILEWATI. Tanpa ini, field wajib di panel yang belum
|
|
131
|
+
* terbuka ikut divalidasi — dan operator melihat "wajib diisi" untuk field yang
|
|
132
|
+
* tidak ada di layarnya, tanpa cara memperbaikinya.
|
|
133
|
+
*
|
|
134
|
+
* `mode`/`data` opsional supaya pemanggil lama tetap jalan; tanpa keduanya,
|
|
135
|
+
* hanya `hidden: true` literal yang bisa dievaluasi — predikat fungsi
|
|
136
|
+
* diperlakukan sebagai TIDAK tersembunyi, arah aman untuk validasi.
|
|
137
|
+
*/
|
|
127
138
|
export function collectPageFields<TData extends Record<string, unknown>>(
|
|
128
139
|
layout: FormLayout<TData>,
|
|
129
140
|
pageIndex: number,
|
|
141
|
+
mode?: FormMode,
|
|
142
|
+
data?: TData,
|
|
130
143
|
): FormField<TData>[] {
|
|
131
|
-
|
|
132
|
-
|
|
144
|
+
const visible = (sections: FormSection<TData>[]) =>
|
|
145
|
+
sections.filter((s) => !isSectionHidden(s, mode, data)).flatMap((s) => s.fields);
|
|
146
|
+
|
|
147
|
+
if (layout.type === "single-page") return visible(layout.sections);
|
|
148
|
+
return visible(layout.pages[pageIndex]?.sections ?? []);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isSectionHidden<TData extends Record<string, unknown>>(
|
|
152
|
+
section: FormSection<TData>,
|
|
153
|
+
mode?: FormMode,
|
|
154
|
+
data?: TData,
|
|
155
|
+
): boolean {
|
|
156
|
+
if (typeof section.hidden === "function") {
|
|
157
|
+
if (mode === undefined || data === undefined) return false;
|
|
158
|
+
return section.hidden(mode, data);
|
|
159
|
+
}
|
|
160
|
+
return section.hidden === true;
|
|
133
161
|
}
|
|
134
162
|
|
|
135
163
|
export function getLayoutPageCount<TData extends Record<string, unknown>>(
|
|
@@ -137,3 +165,23 @@ export function getLayoutPageCount<TData extends Record<string, unknown>>(
|
|
|
137
165
|
): number {
|
|
138
166
|
return layout.type === "single-page" ? 1 : layout.pages.length;
|
|
139
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Tanggal yang dipilih → "YYYY-MM-DD" menurut kalender LOKAL.
|
|
171
|
+
*
|
|
172
|
+
* ⚠️ Ada karena `date.toISOString().slice(0,10)` MENGGESER tanggal satu hari
|
|
173
|
+
* bagi siapa pun di timur UTC. `new Date(1990,0,1)` di WIB (UTC+7) menjadi
|
|
174
|
+
* "1989-12-31T17:00:00Z" — tanggal lahir yang diketik operator tersimpan
|
|
175
|
+
* mundur sehari, tanpa satu pun galat, di setiap field tanggal di setiap modul.
|
|
176
|
+
*
|
|
177
|
+
* Komponen lokal dipakai apa adanya: pemilih tanggal memilih HARI di kalender
|
|
178
|
+
* pengguna, bukan sebuah titik waktu. Mengubahnya ke UTC memperlakukannya
|
|
179
|
+
* sebagai titik waktu, dan di situlah pergeserannya lahir.
|
|
180
|
+
*/
|
|
181
|
+
export function toLocalISODate(date: Date | undefined | null): string {
|
|
182
|
+
if (!date || Number.isNaN(date.getTime())) return "";
|
|
183
|
+
const y = date.getFullYear();
|
|
184
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
185
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
186
|
+
return `${y}-${m}-${d}`;
|
|
187
|
+
}
|
package/src/form-renderer.tsx
CHANGED
|
@@ -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,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
import type { Ui7FormDensity, Ui7FormWidth } from "./style-contract";
|
|
33
34
|
import { useBosSharedI18n } from "./i18n";
|
|
34
35
|
import type { FormField, FormMode, FormSection } from "./form-types";
|
|
36
|
+
import { toLocalISODate } from "./form-renderer-utils";
|
|
35
37
|
|
|
36
38
|
export type SchemaFormRendererProps<TData extends Record<string, unknown>> = {
|
|
37
39
|
mode: FormMode;
|
|
@@ -747,20 +749,20 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
747
749
|
<div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
|
|
748
750
|
<DatePicker
|
|
749
751
|
datePickerType="single"
|
|
750
|
-
|
|
752
|
+
// Tampil DD-MM-YYYY (konvensi setempat); nilai yang
|
|
753
|
+
// DISIMPAN tetap ISO. Dua hal berbeda: yang satu untuk
|
|
754
|
+
// dibaca manusia, yang satu kontrak dgn backend.
|
|
755
|
+
dateFormat="d-m-Y"
|
|
751
756
|
value={asString(rawValue)}
|
|
752
757
|
onChange={([date]) =>
|
|
753
|
-
setValue(
|
|
754
|
-
field.key,
|
|
755
|
-
(date ? date.toISOString().slice(0, 10) : "") as TData[keyof TData],
|
|
756
|
-
)
|
|
758
|
+
setValue(field.key, toLocalISODate(date) as TData[keyof TData])
|
|
757
759
|
}
|
|
758
760
|
>
|
|
759
761
|
<DatePickerInput
|
|
760
762
|
id={key}
|
|
761
763
|
labelText={mkLabel(field.label, effectiveRequired)}
|
|
762
764
|
size={carbonSize}
|
|
763
|
-
placeholder={field.placeholder ?? "
|
|
765
|
+
placeholder={field.placeholder ?? "DD-MM-YYYY"}
|
|
764
766
|
disabled={disabled}
|
|
765
767
|
invalid={Boolean(invalidText)}
|
|
766
768
|
invalidText={invalidText}
|
|
@@ -943,6 +945,32 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
943
945
|
];
|
|
944
946
|
}
|
|
945
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
|
+
|
|
946
974
|
// ── Phone (text with numeric phone normalization) ────────────
|
|
947
975
|
if (field.numeric?.kind === "phone") {
|
|
948
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>> = (
|
|
@@ -41,6 +41,61 @@ export const Rules = {
|
|
|
41
41
|
v && typeof v === "string" && !isValidPhoneInput(v, rule) ? m : undefined;
|
|
42
42
|
},
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Tanggal yang tersimpan harus berupa tanggal KALENDER yang sungguh ada.
|
|
46
|
+
*
|
|
47
|
+
* Nilai yang disimpan berformat ISO (YYYY-MM-DD) meski ditampilkan
|
|
48
|
+
* DD-MM-YYYY — aturan ini memeriksa nilai simpannya.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ Memeriksa hari-nya kembali, bukan hanya `new Date(v)` tidak NaN.
|
|
51
|
+
* `new Date("2026-02-31")` di sebagian mesin MENGGULUNG jadi 3 Maret alih-alih
|
|
52
|
+
* menolak, sehingga tanggal yang tidak ada lolos dan tersimpan sebagai
|
|
53
|
+
* tanggal LAIN — tanpa galat, dan tanpa cara mengetahuinya kemudian.
|
|
54
|
+
*/
|
|
55
|
+
date(msg?: string): FieldValidatorFn {
|
|
56
|
+
const m = msg ?? "Tanggal tidak valid";
|
|
57
|
+
return (v) => {
|
|
58
|
+
if (!v || typeof v !== "string") return undefined;
|
|
59
|
+
const mm = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
|
|
60
|
+
if (!mm) return m;
|
|
61
|
+
const [y, mo, d] = [Number(mm[1]), Number(mm[2]), Number(mm[3])];
|
|
62
|
+
const dt = new Date(y, mo - 1, d);
|
|
63
|
+
const sama =
|
|
64
|
+
dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d;
|
|
65
|
+
return sama ? undefined : m;
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Tanggal tidak boleh di masa depan — untuk tanggal lahir dan sejenisnya.
|
|
71
|
+
* `now` bisa disuntik agar bisa diuji tanpa bergantung jam mesin.
|
|
72
|
+
*/
|
|
73
|
+
dateNotFuture(msg?: string, now?: () => Date): FieldValidatorFn {
|
|
74
|
+
const m = msg ?? "Tanggal tidak boleh melebihi hari ini";
|
|
75
|
+
return (v) => {
|
|
76
|
+
if (!v || typeof v !== "string") return undefined;
|
|
77
|
+
const mm = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
|
|
78
|
+
if (!mm) return undefined; // bentuk salah ditangani Rules.date
|
|
79
|
+
const today = (now ? now() : new Date());
|
|
80
|
+
const t = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
81
|
+
const dt = new Date(Number(mm[1]), Number(mm[2]) - 1, Number(mm[3]));
|
|
82
|
+
return dt.getTime() > t.getTime() ? m : undefined;
|
|
83
|
+
};
|
|
84
|
+
},
|
|
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
|
+
|
|
44
99
|
regex(pattern: RegExp, msg: string): FieldValidatorFn {
|
|
45
100
|
return (v) =>
|
|
46
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."),
|
package/src/npwp.test.ts
ADDED
|
@@ -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: "15rem",
|
|
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
|
|
|
@@ -85,7 +95,16 @@ export function useCrudForm<TData extends Record<string, unknown>>({
|
|
|
85
95
|
);
|
|
86
96
|
|
|
87
97
|
const validatePage = (pageIndex: number) => {
|
|
88
|
-
|
|
98
|
+
// mode & form DIWAJIBKAN di sini: tanpanya collectPageFields tidak bisa
|
|
99
|
+
// mengevaluasi predikat section.hidden, dan field di panel yang belum
|
|
100
|
+
// terbuka ikut divalidasi — operator melihat "wajib diisi" untuk field yang
|
|
101
|
+
// tidak ada di layarnya.
|
|
102
|
+
return runFieldValidation(
|
|
103
|
+
collectPageFields(schema.layout, pageIndex, mode, form),
|
|
104
|
+
form,
|
|
105
|
+
mode,
|
|
106
|
+
labels,
|
|
107
|
+
);
|
|
89
108
|
};
|
|
90
109
|
|
|
91
110
|
const validateAll = () => {
|
|
@@ -171,6 +190,17 @@ export function useCrudForm<TData extends Record<string, unknown>>({
|
|
|
171
190
|
}
|
|
172
191
|
};
|
|
173
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
|
+
|
|
174
204
|
return {
|
|
175
205
|
form,
|
|
176
206
|
setForm,
|
|
@@ -185,6 +215,7 @@ export function useCrudForm<TData extends Record<string, unknown>>({
|
|
|
185
215
|
canGoPrev: page > 0,
|
|
186
216
|
goNext,
|
|
187
217
|
goPrev,
|
|
218
|
+
goToPage,
|
|
188
219
|
handleSave,
|
|
189
220
|
};
|
|
190
221
|
}
|