@isi-ui7/bos7-shared 0.3.7 → 0.3.9
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/detail-modal-field.d.ts +47 -0
- package/dist/form-types.d.ts +89 -1
- package/dist/i18n.d.ts +9 -0
- package/dist/{index-BI2MxWot.js → index-DqWPV29B.js} +10 -2
- package/dist/{index.es-D-PR3JkH.js → index.es-9cvhYbjn.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/{jspdf.es.min-DzRwzM6G.js → jspdf.es.min-CCBzPHw4.js} +1 -1
- package/package.json +1 -1
- package/src/crud-components.tsx +6 -1
- package/src/detail-modal-field.test.tsx +530 -0
- package/src/detail-modal-field.tsx +342 -0
- package/src/form-renderer.tsx +83 -0
- package/src/form-types.ts +94 -1
- package/src/i18n.tsx +19 -0
- package/src/wizard-stepper.tsx +1 -1
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
ComposedModal,
|
|
7
|
+
ModalBody,
|
|
8
|
+
ModalFooter,
|
|
9
|
+
ModalHeader,
|
|
10
|
+
Table,
|
|
11
|
+
TableBody,
|
|
12
|
+
TableCell,
|
|
13
|
+
TableContainer,
|
|
14
|
+
TableHead,
|
|
15
|
+
TableHeader,
|
|
16
|
+
TableRow,
|
|
17
|
+
} from "@carbon/react";
|
|
18
|
+
import { Add, Edit, TrashCan } from "@carbon/icons-react";
|
|
19
|
+
import type { DetailModalConfig, FormMode, FormSection } from "./form-types";
|
|
20
|
+
import { runFieldValidation } from "./form-renderer-utils";
|
|
21
|
+
import type { BosSharedLabels } from "./i18n";
|
|
22
|
+
import { UI7_FORM_VISUAL_CLASSNAMES } from "./style-contract";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Tabel ringkas + modal form per baris — badan tipe field `detail-modal`.
|
|
26
|
+
*
|
|
27
|
+
* Semua gaya di sini INLINE (atau memakai kelas `ui7-form-*` yang sudah ikut
|
|
28
|
+
* `corporate-themes/dist/globals.css`), mengikuti `wizard-stepper.tsx`.
|
|
29
|
+
* Sengaja tidak ada berkas `.css` bersebelahan: `packages/bos7-shared` tidak
|
|
30
|
+
* punya `sideEffects`, tidak punya titik impor CSS, dan `form-contract.css`
|
|
31
|
+
* membuktikan jalur itu berakhir sebagai berkas yang tidak pernah dimuat siapa
|
|
32
|
+
* pun — komponennya tetap jalan, hanya tampilannya rusak, tanpa satu pun galat.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export type DetailModalFieldProps = {
|
|
36
|
+
/** Dipakai untuk id/aria yang unik antar field dalam satu form. */
|
|
37
|
+
fieldKey: string;
|
|
38
|
+
label: ReactNode;
|
|
39
|
+
required?: boolean;
|
|
40
|
+
helperText?: ReactNode;
|
|
41
|
+
config: DetailModalConfig;
|
|
42
|
+
rows: Record<string, unknown>[];
|
|
43
|
+
mode: FormMode;
|
|
44
|
+
/** View mode atau field readonly: tabel tetap tampil, aksinya hilang. */
|
|
45
|
+
readOnly: boolean;
|
|
46
|
+
disabled: boolean;
|
|
47
|
+
labels: BosSharedLabels;
|
|
48
|
+
/** Pesan galat tingkat field dari validator form induk. */
|
|
49
|
+
invalidText?: string;
|
|
50
|
+
onChange: (rows: Record<string, unknown>[]) => void;
|
|
51
|
+
/**
|
|
52
|
+
* Perender form untuk isi modal.
|
|
53
|
+
*
|
|
54
|
+
* Disuntikkan dari `form-renderer.tsx` alih-alih di-import langsung supaya
|
|
55
|
+
* tidak ada lingkaran impor antara kedua modul — `form-renderer` sudah
|
|
56
|
+
* meng-import berkas ini.
|
|
57
|
+
*/
|
|
58
|
+
renderForm: (props: {
|
|
59
|
+
value: Record<string, unknown>;
|
|
60
|
+
sections: FormSection<Record<string, unknown>>[];
|
|
61
|
+
mode: FormMode;
|
|
62
|
+
errors: Partial<Record<string, string>>;
|
|
63
|
+
disabled: boolean;
|
|
64
|
+
onChange: (patch: Partial<Record<string, unknown>>) => void;
|
|
65
|
+
}) => ReactNode;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function DetailModalField({
|
|
69
|
+
fieldKey,
|
|
70
|
+
label,
|
|
71
|
+
required = false,
|
|
72
|
+
helperText,
|
|
73
|
+
config,
|
|
74
|
+
rows,
|
|
75
|
+
mode,
|
|
76
|
+
readOnly,
|
|
77
|
+
disabled,
|
|
78
|
+
labels,
|
|
79
|
+
invalidText,
|
|
80
|
+
onChange,
|
|
81
|
+
renderForm,
|
|
82
|
+
}: DetailModalFieldProps) {
|
|
83
|
+
// `draft === null` berarti modal tertutup. State draft dipisah dari `rows`
|
|
84
|
+
// supaya Batal benar-benar membatalkan: baris asli tidak pernah tersentuh
|
|
85
|
+
// sampai Simpan lolos validasi.
|
|
86
|
+
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
|
|
87
|
+
// `null` = baris baru; angka = indeks baris yang sedang diubah.
|
|
88
|
+
const [draftIndex, setDraftIndex] = useState<number | null>(null);
|
|
89
|
+
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
|
|
90
|
+
|
|
91
|
+
const allowEdit = config.allowEdit ?? true;
|
|
92
|
+
const allowDelete = config.allowDelete ?? true;
|
|
93
|
+
const showActions = !readOnly && (allowEdit || allowDelete);
|
|
94
|
+
const atMax = config.maxRows !== undefined && rows.length >= config.maxRows;
|
|
95
|
+
const isOpen = draft !== null;
|
|
96
|
+
|
|
97
|
+
const openAdd = () => {
|
|
98
|
+
setDraft(config.newRowFactory());
|
|
99
|
+
setDraftIndex(null);
|
|
100
|
+
setErrors({});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const openEdit = (index: number) => {
|
|
104
|
+
// Salinan dangkal: menyunting objek baris langsung akan mengubah data form
|
|
105
|
+
// induk sebelum Simpan, sehingga Batal tidak lagi bisa mengembalikannya.
|
|
106
|
+
setDraft({ ...rows[index] });
|
|
107
|
+
setDraftIndex(index);
|
|
108
|
+
setErrors({});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const closeModal = () => {
|
|
112
|
+
setDraft(null);
|
|
113
|
+
setDraftIndex(null);
|
|
114
|
+
setErrors({});
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const commit = () => {
|
|
118
|
+
if (draft === null) return;
|
|
119
|
+
|
|
120
|
+
// Validasi per-field memakai mesin yang SAMA dengan form biasa, dengan
|
|
121
|
+
// barisnya sebagai `TData`. Aturan `required` / `pattern` / `Rules.*` yang
|
|
122
|
+
// ditulis di section baris berlaku apa adanya, tanpa disalin ulang di sini.
|
|
123
|
+
const fields = config.sections.flatMap((s) => s.fields);
|
|
124
|
+
const fieldErrors = runFieldValidation(fields, draft, mode, labels);
|
|
125
|
+
|
|
126
|
+
// Baris baru divalidasi memakai posisi tempat ia AKAN mendarat, bukan -1,
|
|
127
|
+
// supaya aturan yang bergantung urutan (mis. nomor urut ahli waris) melihat
|
|
128
|
+
// indeks yang sama dengan yang akan dilihatnya setelah tersimpan.
|
|
129
|
+
const index = draftIndex ?? rows.length;
|
|
130
|
+
const rowErrors = config.validateRow?.(draft, index) ?? {};
|
|
131
|
+
|
|
132
|
+
const merged: Partial<Record<string, string>> = { ...fieldErrors, ...rowErrors };
|
|
133
|
+
if (Object.keys(merged).length > 0) {
|
|
134
|
+
setErrors(merged);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
onChange(
|
|
139
|
+
draftIndex === null
|
|
140
|
+
? [...rows, draft]
|
|
141
|
+
: rows.map((row, i) => (i === draftIndex ? draft : row)),
|
|
142
|
+
);
|
|
143
|
+
closeModal();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const removeAt = (index: number) => {
|
|
147
|
+
onChange(rows.filter((_, i) => i !== index));
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const renderCell = (
|
|
151
|
+
column: DetailModalConfig["columns"][number],
|
|
152
|
+
row: Record<string, unknown>,
|
|
153
|
+
): ReactNode => {
|
|
154
|
+
const raw = row[column.field];
|
|
155
|
+
if (column.format) return column.format(raw, row);
|
|
156
|
+
if (raw === null || raw === undefined || raw === "") return labels.emptyValue;
|
|
157
|
+
return String(raw);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const columnCount = config.columns.length + 1 + (showActions ? 1 : 0);
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div style={{ display: "grid", gap: "0.5rem" }}>
|
|
164
|
+
{/* Kepala panel: label kiri, tombol Tambah kanan — bentuk yang dipakai
|
|
165
|
+
contoh UI untuk Ahli Waris / Relasi / Beneficial Owner. */}
|
|
166
|
+
<div
|
|
167
|
+
style={{
|
|
168
|
+
display: "flex",
|
|
169
|
+
justifyContent: "space-between",
|
|
170
|
+
alignItems: "center",
|
|
171
|
+
gap: "1rem",
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
<div className={UI7_FORM_VISUAL_CLASSNAMES.label}>
|
|
175
|
+
<span>{label}</span>
|
|
176
|
+
{required ? (
|
|
177
|
+
<span className={UI7_FORM_VISUAL_CLASSNAMES.labelRequired}> *</span>
|
|
178
|
+
) : null}
|
|
179
|
+
</div>
|
|
180
|
+
{!readOnly ? (
|
|
181
|
+
<Button
|
|
182
|
+
size="sm"
|
|
183
|
+
renderIcon={Add}
|
|
184
|
+
onClick={openAdd}
|
|
185
|
+
disabled={disabled || atMax}
|
|
186
|
+
>
|
|
187
|
+
{config.addLabel ?? labels.detailAdd}
|
|
188
|
+
</Button>
|
|
189
|
+
) : null}
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<TableContainer>
|
|
193
|
+
<Table size="sm" useZebraStyles={false}>
|
|
194
|
+
<TableHead>
|
|
195
|
+
<TableRow>
|
|
196
|
+
<TableHeader style={{ width: "3rem" }}>{labels.detailNo}</TableHeader>
|
|
197
|
+
{config.columns.map((column) => (
|
|
198
|
+
<TableHeader
|
|
199
|
+
key={column.field}
|
|
200
|
+
style={column.width ? { width: `${column.width}px` } : undefined}
|
|
201
|
+
>
|
|
202
|
+
{column.header}
|
|
203
|
+
</TableHeader>
|
|
204
|
+
))}
|
|
205
|
+
{showActions ? (
|
|
206
|
+
<TableHeader style={{ width: "5.5rem" }}>
|
|
207
|
+
{labels.detailActions}
|
|
208
|
+
</TableHeader>
|
|
209
|
+
) : null}
|
|
210
|
+
</TableRow>
|
|
211
|
+
</TableHead>
|
|
212
|
+
<TableBody>
|
|
213
|
+
{rows.length === 0 ? (
|
|
214
|
+
<TableRow>
|
|
215
|
+
<TableCell
|
|
216
|
+
colSpan={columnCount}
|
|
217
|
+
style={{
|
|
218
|
+
textAlign: "center",
|
|
219
|
+
fontStyle: "italic",
|
|
220
|
+
color: "var(--cds-text-secondary, #525252)",
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
{config.emptyText ?? labels.detailEmpty}
|
|
224
|
+
</TableCell>
|
|
225
|
+
</TableRow>
|
|
226
|
+
) : (
|
|
227
|
+
rows.map((row, index) => (
|
|
228
|
+
<TableRow key={config.rowKey?.(row, index) ?? String(index)}>
|
|
229
|
+
<TableCell>{index + 1}</TableCell>
|
|
230
|
+
{config.columns.map((column) => (
|
|
231
|
+
<TableCell
|
|
232
|
+
key={column.field}
|
|
233
|
+
style={
|
|
234
|
+
column.align === "end" ? { textAlign: "right" } : undefined
|
|
235
|
+
}
|
|
236
|
+
>
|
|
237
|
+
{renderCell(column, row)}
|
|
238
|
+
</TableCell>
|
|
239
|
+
))}
|
|
240
|
+
{showActions ? (
|
|
241
|
+
<TableCell>
|
|
242
|
+
<div style={{ display: "flex", gap: "0.25rem" }}>
|
|
243
|
+
{allowEdit ? (
|
|
244
|
+
<Button
|
|
245
|
+
kind="ghost"
|
|
246
|
+
size="sm"
|
|
247
|
+
hasIconOnly
|
|
248
|
+
renderIcon={Edit}
|
|
249
|
+
iconDescription={labels.detailEdit}
|
|
250
|
+
aria-label={`${labels.detailEdit} ${index + 1}`}
|
|
251
|
+
disabled={disabled}
|
|
252
|
+
onClick={() => openEdit(index)}
|
|
253
|
+
/>
|
|
254
|
+
) : null}
|
|
255
|
+
{allowDelete ? (
|
|
256
|
+
<Button
|
|
257
|
+
kind="ghost"
|
|
258
|
+
size="sm"
|
|
259
|
+
hasIconOnly
|
|
260
|
+
renderIcon={TrashCan}
|
|
261
|
+
iconDescription={labels.detailDelete}
|
|
262
|
+
aria-label={`${labels.detailDelete} ${index + 1}`}
|
|
263
|
+
disabled={disabled}
|
|
264
|
+
onClick={() => removeAt(index)}
|
|
265
|
+
/>
|
|
266
|
+
) : null}
|
|
267
|
+
</div>
|
|
268
|
+
</TableCell>
|
|
269
|
+
) : null}
|
|
270
|
+
</TableRow>
|
|
271
|
+
))
|
|
272
|
+
)}
|
|
273
|
+
</TableBody>
|
|
274
|
+
</Table>
|
|
275
|
+
</TableContainer>
|
|
276
|
+
|
|
277
|
+
{invalidText ? (
|
|
278
|
+
<div
|
|
279
|
+
role="alert"
|
|
280
|
+
className={UI7_FORM_VISUAL_CLASSNAMES.helper}
|
|
281
|
+
style={{ color: "var(--cds-text-error, #da1e28)" }}
|
|
282
|
+
>
|
|
283
|
+
{invalidText}
|
|
284
|
+
</div>
|
|
285
|
+
) : null}
|
|
286
|
+
|
|
287
|
+
{helperText ? (
|
|
288
|
+
<div className={UI7_FORM_VISUAL_CLASSNAMES.helper}>{helperText}</div>
|
|
289
|
+
) : null}
|
|
290
|
+
|
|
291
|
+
{/* Modal dipasang HANYA saat terbuka supaya setiap kali dibuka formnya
|
|
292
|
+
benar-benar baru — tidak ada sisa nilai dari baris yang dibuka
|
|
293
|
+
sebelumnya. */}
|
|
294
|
+
{isOpen ? (
|
|
295
|
+
<ComposedModal
|
|
296
|
+
open
|
|
297
|
+
onClose={closeModal}
|
|
298
|
+
size={config.modalSize ?? "lg"}
|
|
299
|
+
aria-label={String(
|
|
300
|
+
draftIndex === null
|
|
301
|
+
? config.addModalTitle ?? labels.detailAddTitle
|
|
302
|
+
: config.editModalTitle ?? labels.detailEditTitle,
|
|
303
|
+
)}
|
|
304
|
+
>
|
|
305
|
+
<ModalHeader
|
|
306
|
+
title={
|
|
307
|
+
draftIndex === null
|
|
308
|
+
? config.addModalTitle ?? labels.detailAddTitle
|
|
309
|
+
: config.editModalTitle ?? labels.detailEditTitle
|
|
310
|
+
}
|
|
311
|
+
/>
|
|
312
|
+
<ModalBody hasScrollingContent>
|
|
313
|
+
{renderForm({
|
|
314
|
+
value: draft,
|
|
315
|
+
sections: config.sections,
|
|
316
|
+
// Baris selalu disunting dalam mode tulis. Mode `view` induk tidak
|
|
317
|
+
// pernah sampai ke sini — tombol Ubah sudah hilang saat readOnly.
|
|
318
|
+
mode: draftIndex === null ? "create" : "edit",
|
|
319
|
+
errors,
|
|
320
|
+
disabled,
|
|
321
|
+
onChange: (patch) =>
|
|
322
|
+
setDraft((prev) => (prev === null ? prev : { ...prev, ...patch })),
|
|
323
|
+
})}
|
|
324
|
+
</ModalBody>
|
|
325
|
+
<ModalFooter>
|
|
326
|
+
<Button kind="secondary" onClick={closeModal} disabled={disabled}>
|
|
327
|
+
{labels.cancel}
|
|
328
|
+
</Button>
|
|
329
|
+
<Button
|
|
330
|
+
kind="primary"
|
|
331
|
+
onClick={commit}
|
|
332
|
+
disabled={disabled}
|
|
333
|
+
data-testid={`detail-modal-save-${fieldKey}`}
|
|
334
|
+
>
|
|
335
|
+
{labels.submitCreate}
|
|
336
|
+
</Button>
|
|
337
|
+
</ModalFooter>
|
|
338
|
+
</ComposedModal>
|
|
339
|
+
) : null}
|
|
340
|
+
</div>
|
|
341
|
+
);
|
|
342
|
+
}
|
package/src/form-renderer.tsx
CHANGED
|
@@ -34,6 +34,7 @@ import type { Ui7FormDensity, Ui7FormWidth } from "./style-contract";
|
|
|
34
34
|
import { useBosSharedI18n } from "./i18n";
|
|
35
35
|
import type { FormField, FormMode, FormSection } from "./form-types";
|
|
36
36
|
import { toLocalISODate } from "./form-renderer-utils";
|
|
37
|
+
import { DetailModalField } from "./detail-modal-field";
|
|
37
38
|
|
|
38
39
|
export type SchemaFormRendererProps<TData extends Record<string, unknown>> = {
|
|
39
40
|
mode: FormMode;
|
|
@@ -663,6 +664,88 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
663
664
|
];
|
|
664
665
|
}
|
|
665
666
|
|
|
667
|
+
// ── Detail-modal (tabel ringkas + modal form per baris) ─────
|
|
668
|
+
// Sama seperti detail-rows: dirender SEBELUM cabang readonly
|
|
669
|
+
// supaya view / edit / create memakai satu komponen yang sama —
|
|
670
|
+
// yang readonly hanya kehilangan tombol Tambah dan kolom Aksi.
|
|
671
|
+
// Default span 12 karena tabel tidak pernah pas di setengah
|
|
672
|
+
// kolom.
|
|
673
|
+
if (field.type === "detail-modal") {
|
|
674
|
+
const detailSpan = clampSpan(field.span, 12);
|
|
675
|
+
const detailWrapperStyle = {
|
|
676
|
+
gridColumn: field.breakBefore
|
|
677
|
+
? `1 / span ${detailSpan}`
|
|
678
|
+
: `span ${detailSpan} / span ${detailSpan}`,
|
|
679
|
+
minWidth: 0,
|
|
680
|
+
} as const;
|
|
681
|
+
|
|
682
|
+
// Config yang hilang TIDAK boleh membuat field lenyap diam-diam.
|
|
683
|
+
// `detail-rows` mengembalikan [] di sini, dan akibatnya field
|
|
684
|
+
// yang salah tulis hilang dari layar tanpa satu pun galat —
|
|
685
|
+
// persis kelas kegagalan yang paling mahal di modul ini.
|
|
686
|
+
const detail = field.detailModal;
|
|
687
|
+
if (!detail) {
|
|
688
|
+
return [
|
|
689
|
+
<div
|
|
690
|
+
key={key}
|
|
691
|
+
className={UI7_FORM_VISUAL_CLASSNAMES.column}
|
|
692
|
+
style={detailWrapperStyle}
|
|
693
|
+
>
|
|
694
|
+
<div role="alert" style={{ color: "var(--cds-text-error, #da1e28)" }}>
|
|
695
|
+
{`Field "${key}" bertipe detail-modal tetapi tidak punya detailModal.`}
|
|
696
|
+
</div>
|
|
697
|
+
</div>,
|
|
698
|
+
];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const rows = Array.isArray(rawValue)
|
|
702
|
+
? (rawValue as Record<string, unknown>[])
|
|
703
|
+
: [];
|
|
704
|
+
|
|
705
|
+
return [
|
|
706
|
+
<div
|
|
707
|
+
key={key}
|
|
708
|
+
className={UI7_FORM_VISUAL_CLASSNAMES.column}
|
|
709
|
+
style={detailWrapperStyle}
|
|
710
|
+
>
|
|
711
|
+
<DetailModalField
|
|
712
|
+
fieldKey={key}
|
|
713
|
+
label={field.label}
|
|
714
|
+
required={effectiveRequired}
|
|
715
|
+
helperText={field.helperText}
|
|
716
|
+
config={detail}
|
|
717
|
+
rows={rows}
|
|
718
|
+
mode={mode}
|
|
719
|
+
readOnly={effectiveReadonly}
|
|
720
|
+
disabled={disabled}
|
|
721
|
+
labels={labels}
|
|
722
|
+
invalidText={invalidText}
|
|
723
|
+
onChange={(newRows) =>
|
|
724
|
+
setValue(
|
|
725
|
+
field.key,
|
|
726
|
+
(newRows as unknown) as TData[keyof TData],
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
renderForm={(p) => (
|
|
730
|
+
<SchemaFormRenderer
|
|
731
|
+
mode={p.mode}
|
|
732
|
+
value={p.value}
|
|
733
|
+
sections={p.sections}
|
|
734
|
+
errors={p.errors}
|
|
735
|
+
disabled={p.disabled}
|
|
736
|
+
onChange={p.onChange}
|
|
737
|
+
density={density}
|
|
738
|
+
// Form baris mengisi lebar modal. Tanpa ini ia
|
|
739
|
+
// mewarisi batas two-thirds milik form induk dan
|
|
740
|
+
// menyisakan sepertiga modal kosong.
|
|
741
|
+
width="full"
|
|
742
|
+
/>
|
|
743
|
+
)}
|
|
744
|
+
/>
|
|
745
|
+
</div>,
|
|
746
|
+
];
|
|
747
|
+
}
|
|
748
|
+
|
|
666
749
|
// ── View / readonly ─────────────────────────────────────────
|
|
667
750
|
if (effectiveReadonly) {
|
|
668
751
|
return [
|
package/src/form-types.ts
CHANGED
|
@@ -33,7 +33,7 @@ export type FieldValidation<TData extends Record<string, unknown>> = {
|
|
|
33
33
|
export type FormFieldType =
|
|
34
34
|
| "text" | "textarea" | "number" | "date" | "datetime"
|
|
35
35
|
| "select" | "lookup" | "checkbox" | "toggle"
|
|
36
|
-
| "detail-rows";
|
|
36
|
+
| "detail-rows" | "detail-modal";
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Select option. `value` may be a number for numeric-coded columns (e.g.
|
|
@@ -114,6 +114,98 @@ export type DetailRowsConfig = {
|
|
|
114
114
|
lockExistingRows?: boolean;
|
|
115
115
|
};
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Satu kolom pada tabel RINGKAS `detail-modal`.
|
|
119
|
+
*
|
|
120
|
+
* Sengaja BUKAN `EditableColumnDef`: kolom di sini hanya menampilkan, tidak
|
|
121
|
+
* pernah menyunting — penyuntingan seluruhnya terjadi di dalam modal. Memakai
|
|
122
|
+
* `EditableColumnDef` akan menuntut `columnType` untuk setiap kolom padahal
|
|
123
|
+
* tipe itu hanya menentukan editor sel yang tidak akan pernah dipakai.
|
|
124
|
+
*/
|
|
125
|
+
export type DetailModalColumn = {
|
|
126
|
+
/** Nama properti pada baris. */
|
|
127
|
+
field: string;
|
|
128
|
+
header: ReactNode;
|
|
129
|
+
/** Lebar kolom dalam piksel. Tanpa ini kolom ikut lebar isi. */
|
|
130
|
+
width?: number;
|
|
131
|
+
/** `end` untuk angka. Default `start`. */
|
|
132
|
+
align?: "start" | "end";
|
|
133
|
+
/**
|
|
134
|
+
* Ubah nilai mentah jadi tampilan. Tanpa ini nilai kosong tampil sebagai
|
|
135
|
+
* em-dash (`labels.emptyValue`) dan sisanya lewat `String(value)`.
|
|
136
|
+
*
|
|
137
|
+
* ⚠️ Kolom ringkas menampilkan nilai SIMPAN, bukan label. Untuk field
|
|
138
|
+
* `select`, `String(value)` menghasilkan kodenya ("M"), bukan "Laki-laki" —
|
|
139
|
+
* pakai `format` bila yang harus terbaca operator adalah labelnya.
|
|
140
|
+
*/
|
|
141
|
+
format?: (value: unknown, row: Record<string, unknown>) => ReactNode;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Detail-modal config — tabel RINGKAS + tombol Tambah yang membuka modal
|
|
146
|
+
* berisi form penuh untuk SATU baris.
|
|
147
|
+
*
|
|
148
|
+
* Bedanya dengan `detail-rows`: `detail-rows` menyunting DI DALAM sel, jadi
|
|
149
|
+
* setiap field baris harus muat sebagai satu kolom. Begitu satu baris punya
|
|
150
|
+
* belasan field (Beneficial Owner: ~20 field + dua blok alamat), barisnya jadi
|
|
151
|
+
* selebar beberapa layar dan tidak ada tempat untuk alur per-baris seperti cek
|
|
152
|
+
* identitas. `detail-modal` memindahkan seluruh form baris ke modal, dan
|
|
153
|
+
* menyisakan hanya beberapa kolom penanda di tabel.
|
|
154
|
+
*
|
|
155
|
+
* Nilai field di `key` harus berupa array objek biasa; renderer membaca dan
|
|
156
|
+
* menulis array itu secara utuh lewat `onChange({ [key]: newRows })` — sama
|
|
157
|
+
* seperti `detail-rows`.
|
|
158
|
+
*
|
|
159
|
+
* Isi modal dirender oleh `SchemaFormRenderer` yang sama dengan form induknya,
|
|
160
|
+
* jadi SEMUA tipe field yang sudah ada (lookup, date, numeric, toggle, bahkan
|
|
161
|
+
* `FormSection.render`) langsung bisa dipakai di dalam baris tanpa tambahan
|
|
162
|
+
* apa pun di sini.
|
|
163
|
+
*/
|
|
164
|
+
export type DetailModalConfig = {
|
|
165
|
+
/**
|
|
166
|
+
* Form untuk SATU baris. Section-nya sama persis dengan section form biasa,
|
|
167
|
+
* dan `TData`-nya adalah barisnya — bukan form induk.
|
|
168
|
+
*/
|
|
169
|
+
sections: FormSection<Record<string, unknown>>[];
|
|
170
|
+
/** Kolom yang tampil di tabel ringkas. Boleh sebagian kecil saja dari field baris. */
|
|
171
|
+
columns: DetailModalColumn[];
|
|
172
|
+
newRowFactory: () => Record<string, unknown>;
|
|
173
|
+
/**
|
|
174
|
+
* Aturan lintas-field untuk satu baris, dijalankan SETELAH validasi per-field
|
|
175
|
+
* milik `sections`. Kuncinya nama field; hasilnya menimpa error per-field.
|
|
176
|
+
*
|
|
177
|
+
* `index` adalah posisi baris — untuk baris baru, posisi tempat ia akan
|
|
178
|
+
* mendarat (`rows.length`).
|
|
179
|
+
*/
|
|
180
|
+
validateRow?: (
|
|
181
|
+
row: Record<string, unknown>,
|
|
182
|
+
index: number,
|
|
183
|
+
) => Record<string, string>;
|
|
184
|
+
/** Batas jumlah baris. Tombol Tambah nonaktif setelah tercapai. */
|
|
185
|
+
maxRows?: number;
|
|
186
|
+
/** Teks tombol tambah, mis. "Tambah Ahli Waris". Default: "Tambah". */
|
|
187
|
+
addLabel?: ReactNode;
|
|
188
|
+
/** Judul modal saat menambah. Default: "Tambah Data". */
|
|
189
|
+
addModalTitle?: ReactNode;
|
|
190
|
+
/** Judul modal saat mengubah. Default: "Ubah Data". */
|
|
191
|
+
editModalTitle?: ReactNode;
|
|
192
|
+
/** Teks saat tabel kosong. Default: "Belum ada data." */
|
|
193
|
+
emptyText?: ReactNode;
|
|
194
|
+
/** Lebar modal Carbon. Default `lg` — form baris biasanya padat. */
|
|
195
|
+
modalSize?: "xs" | "sm" | "md" | "lg";
|
|
196
|
+
/** Izinkan membuka baris yang sudah ada untuk diubah. Default `true`. */
|
|
197
|
+
allowEdit?: boolean;
|
|
198
|
+
/** Izinkan menghapus baris. Default `true`. */
|
|
199
|
+
allowDelete?: boolean;
|
|
200
|
+
/**
|
|
201
|
+
* Kunci React per baris. Default memakai indeks.
|
|
202
|
+
*
|
|
203
|
+
* Berguna bila baris punya identitas sendiri (mis. `person_id`) supaya state
|
|
204
|
+
* modal tidak nyangkut ke baris lain setelah penghapusan di tengah.
|
|
205
|
+
*/
|
|
206
|
+
rowKey?: (row: Record<string, unknown>, index: number) => string;
|
|
207
|
+
};
|
|
208
|
+
|
|
117
209
|
export type FormField<TData extends Record<string, unknown>> = {
|
|
118
210
|
key: keyof TData;
|
|
119
211
|
label: ReactNode;
|
|
@@ -146,6 +238,7 @@ export type FormField<TData extends Record<string, unknown>> = {
|
|
|
146
238
|
numeric?: FormFieldNumericConfig<TData>;
|
|
147
239
|
toggle?: FormFieldToggleConfig;
|
|
148
240
|
detailRows?: DetailRowsConfig;
|
|
241
|
+
detailModal?: DetailModalConfig;
|
|
149
242
|
format?: (value: TData[keyof TData], data: TData) => ReactNode;
|
|
150
243
|
validation?: FieldValidation<TData>;
|
|
151
244
|
};
|
package/src/i18n.tsx
CHANGED
|
@@ -44,6 +44,16 @@ export type BosSharedLabels = {
|
|
|
44
44
|
deleteTitle: string;
|
|
45
45
|
deleteDescription: string;
|
|
46
46
|
deleteConfirm: string;
|
|
47
|
+
|
|
48
|
+
/** Tipe field `detail-modal` — tabel ringkas + modal form per baris. */
|
|
49
|
+
detailAdd: string;
|
|
50
|
+
detailEdit: string;
|
|
51
|
+
detailDelete: string;
|
|
52
|
+
detailEmpty: string;
|
|
53
|
+
detailNo: string;
|
|
54
|
+
detailActions: string;
|
|
55
|
+
detailAddTitle: string;
|
|
56
|
+
detailEditTitle: string;
|
|
47
57
|
};
|
|
48
58
|
|
|
49
59
|
// ── Thin adapter — reads from @isi-ui7/i18n (wired in createShellProvider) ───
|
|
@@ -93,5 +103,14 @@ export function useBosSharedI18n(): BosSharedLabels {
|
|
|
93
103
|
deleteTitle: t("bos7.deleteTitle", "Konfirmasi Hapus"),
|
|
94
104
|
deleteDescription: t("bos7.deleteDescription", "Data berikut akan dihapus permanen."),
|
|
95
105
|
deleteConfirm: t("bos7.deleteConfirm", "Hapus"),
|
|
106
|
+
|
|
107
|
+
detailAdd: t("bos7.detailAdd", "Tambah"),
|
|
108
|
+
detailEdit: t("bos7.detailEdit", "Ubah"),
|
|
109
|
+
detailDelete: t("bos7.detailDelete", "Hapus"),
|
|
110
|
+
detailEmpty: t("bos7.detailEmpty", "Belum ada data."),
|
|
111
|
+
detailNo: t("bos7.detailNo", "No"),
|
|
112
|
+
detailActions: t("bos7.detailActions", "Aksi"),
|
|
113
|
+
detailAddTitle: t("bos7.detailAddTitle", "Tambah Data"),
|
|
114
|
+
detailEditTitle: t("bos7.detailEditTitle", "Ubah Data"),
|
|
96
115
|
};
|
|
97
116
|
}
|