@isi-ui7/bos7-shared 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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
  }
@@ -0,0 +1,22 @@
1
+ // Tambalan lingkungan jsdom — BUKAN tambalan produk.
2
+ //
3
+ // jsdom nol mengimplementasikan `Element.prototype.scrollIntoView`. Carbon
4
+ // `ComposedModal` memanggilnya saat memindahkan fokus, jadi setiap uji yang
5
+ // membuka modal melempar `TypeError` di luar rantai promise uji. Vitest
6
+ // menghitungnya sebagai *unhandled error*, bukan sebagai uji gagal — hasilnya
7
+ // `Tests 108 passed` berdampingan dengan `Errors 11` dan **exit code 1**.
8
+ //
9
+ // 🔑 Itu bentuk kegagalan yang paling mudah dilaporkan keliru: ringkasan yang
10
+ // dibaca manusia berbunyi hijau sepenuhnya, sementara gerbang CI yang membaca
11
+ // exit code menolak paket ini. Sesi yang menambahkan tambalan ini sempat
12
+ // melaporkan "105/105 lulus" atas suite yang MERAH, dan baru tahu setelah
13
+ // verifikasi adversarial memeriksa exit code-nya secara terpisah.
14
+ //
15
+ // Fungsinya no-op: yang diuji adalah apa yang terender, bukan apakah browser
16
+ // menggulir. Menambal di lapisan lingkungan menjaga kode produk tetap bersih
17
+ // dari pengetahuan tentang jsdom.
18
+ if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
19
+ Element.prototype.scrollIntoView = function scrollIntoView() {
20
+ /* no-op: jsdom nol punya viewport untuk digulir */
21
+ };
22
+ }