unipo-ui 1.2.0 → 1.3.0

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.
Files changed (173) hide show
  1. package/cli/index.mjs +53 -4
  2. package/dist/admisi-YCOE3DMH.cjs +20 -0
  3. package/dist/admisi-YXCTBECZ.js +11 -0
  4. package/dist/akademik-PJGL6MSP.cjs +20 -0
  5. package/dist/akademik-TPDFEZGF.js +11 -0
  6. package/dist/akses-AUPQD6YN.cjs +20 -0
  7. package/dist/akses-V5XIMBWT.js +11 -0
  8. package/dist/arsip-2T7IEION.cjs +20 -0
  9. package/dist/arsip-LDRV7FYX.js +11 -0
  10. package/dist/cbt-4N6COVIN.js +11 -0
  11. package/dist/cbt-GMQSR7TP.cjs +20 -0
  12. package/dist/chunk-34OKV63Q.js +122 -0
  13. package/dist/chunk-34OKV63Q.js.map +1 -0
  14. package/dist/chunk-3O5ELKBH.cjs +18 -0
  15. package/dist/chunk-3O5ELKBH.cjs.map +1 -0
  16. package/dist/chunk-4BQOABRV.js +462 -0
  17. package/dist/chunk-4BQOABRV.js.map +1 -0
  18. package/dist/{chunk-DEF5CA7C.cjs → chunk-5VNCBW4R.cjs} +282 -1165
  19. package/dist/chunk-5VNCBW4R.cjs.map +1 -0
  20. package/dist/{chunk-SJ4DJOMZ.js → chunk-7AJ4LW5H.js} +163 -1019
  21. package/dist/chunk-7AJ4LW5H.js.map +1 -0
  22. package/dist/chunk-JASW45YF.cjs +510 -0
  23. package/dist/chunk-JASW45YF.cjs.map +1 -0
  24. package/dist/chunk-JSUHAYQ4.js +15 -0
  25. package/dist/chunk-JSUHAYQ4.js.map +1 -0
  26. package/dist/chunk-LN6HZMPI.js +1006 -0
  27. package/dist/chunk-LN6HZMPI.js.map +1 -0
  28. package/dist/chunk-LNJTDEQR.cjs +1937 -0
  29. package/dist/chunk-LNJTDEQR.cjs.map +1 -0
  30. package/dist/chunk-MKQEH7QS.cjs +1058 -0
  31. package/dist/chunk-MKQEH7QS.cjs.map +1 -0
  32. package/dist/chunk-TAKLMRU7.cjs +150 -0
  33. package/dist/chunk-TAKLMRU7.cjs.map +1 -0
  34. package/dist/chunk-TYCRRYCQ.js +1843 -0
  35. package/dist/chunk-TYCRRYCQ.js.map +1 -0
  36. package/dist/crm-C4XGSG6O.js +11 -0
  37. package/dist/crm-LTVCDNMK.cjs +20 -0
  38. package/dist/dasbor-OJLMSFTF.cjs +20 -0
  39. package/dist/dasbor-Y3UPFYFE.js +11 -0
  40. package/dist/edom-3GZUUB7H.cjs +20 -0
  41. package/dist/edom-K4Y2NV4K.js +11 -0
  42. package/dist/fakultas-DJRUVJBV.js +11 -0
  43. package/dist/fakultas-VVAQOPUX.cjs +20 -0
  44. package/dist/filter-D54KALbB.d.cts +436 -0
  45. package/dist/filter-D54KALbB.d.ts +436 -0
  46. package/dist/helpdesk-AWVC7BPE.cjs +20 -0
  47. package/dist/helpdesk-BAOXAU6G.js +11 -0
  48. package/dist/humas-7BMMJ3WF.js +11 -0
  49. package/dist/humas-H56JFUW6.cjs +20 -0
  50. package/dist/ikon.cjs +125 -0
  51. package/dist/ikon.cjs.map +1 -0
  52. package/dist/ikon.d.cts +140 -0
  53. package/dist/ikon.d.ts +140 -0
  54. package/dist/ikon.js +6 -0
  55. package/dist/ikon.js.map +1 -0
  56. package/dist/iku-7JLF2NIH.js +11 -0
  57. package/dist/iku-F47ZLMYJ.cjs +20 -0
  58. package/dist/index.cjs +544 -257
  59. package/dist/index.cjs.map +1 -1
  60. package/dist/index.d.cts +192 -2
  61. package/dist/index.d.ts +192 -2
  62. package/dist/index.js +126 -4
  63. package/dist/index.js.map +1 -1
  64. package/dist/induk-BGTGKRLL.js +11 -0
  65. package/dist/induk-DLYDMZAM.cjs +20 -0
  66. package/dist/integrasi-4VXNGA3V.cjs +20 -0
  67. package/dist/integrasi-IP3WWDO7.js +11 -0
  68. package/dist/kalender-DGQDGJCL.cjs +20 -0
  69. package/dist/kalender-FGGUBYKZ.js +11 -0
  70. package/dist/karier-H3HBFCQQ.js +11 -0
  71. package/dist/karier-QTU2P5YD.cjs +20 -0
  72. package/dist/katalog.cjs +1709 -0
  73. package/dist/katalog.cjs.map +1 -0
  74. package/dist/katalog.d.cts +645 -0
  75. package/dist/katalog.d.ts +645 -0
  76. package/dist/katalog.js +1626 -0
  77. package/dist/katalog.js.map +1 -0
  78. package/dist/kelulusan-BHFQM5N4.cjs +20 -0
  79. package/dist/kelulusan-ZR7CM6I5.js +11 -0
  80. package/dist/kemahasiswaan-EINWXAY7.cjs +20 -0
  81. package/dist/kemahasiswaan-NH2LEDH7.js +11 -0
  82. package/dist/kerjasama-FAOHZCML.js +11 -0
  83. package/dist/kerjasama-TXWJOU4Y.cjs +20 -0
  84. package/dist/keu-4TXF4FEP.cjs +20 -0
  85. package/dist/keu-SD7UOCDB.js +11 -0
  86. package/dist/kurikulum-SRO7TJYI.cjs +20 -0
  87. package/dist/kurikulum-XT7PZJOK.js +11 -0
  88. package/dist/layanan-RQ2AWCQ5.cjs +20 -0
  89. package/dist/layanan-Y6WUCSD2.js +11 -0
  90. package/dist/lms-5JYOSLBI.cjs +20 -0
  91. package/dist/lms-NTXMSHG2.js +11 -0
  92. package/dist/lppm-7DH7PRF3.cjs +20 -0
  93. package/dist/lppm-UXYGIOIF.js +11 -0
  94. package/dist/master-GKTTAKLF.cjs +20 -0
  95. package/dist/master-ZG4SPPDU.js +11 -0
  96. package/dist/mbkm-OGR72BTB.js +11 -0
  97. package/dist/mbkm-URKJHUIQ.cjs +20 -0
  98. package/dist/notifikasi-FMA26M4I.js +11 -0
  99. package/dist/notifikasi-QDCYIX7G.cjs +20 -0
  100. package/dist/pddikti-RJVYY2J4.cjs +20 -0
  101. package/dist/pddikti-VF2PXDKA.js +11 -0
  102. package/dist/{content-DaJPx5LX.d.cts → pemeliharaan-D5S-1lqW.d.ts} +38 -434
  103. package/dist/{content-DaJPx5LX.d.ts → pemeliharaan-Dr0HSxvZ.d.cts} +38 -434
  104. package/dist/perpustakaan-5RL33XG2.js +11 -0
  105. package/dist/perpustakaan-JXZW43MJ.cjs +20 -0
  106. package/dist/pilih-BEes_D3n.d.cts +205 -0
  107. package/dist/pilih-CEKqXMUS.d.ts +205 -0
  108. package/dist/pmb-EGTWW2ZH.cjs +20 -0
  109. package/dist/pmb-G7EAFJ3R.js +11 -0
  110. package/dist/portal-EI7TXN7H.cjs +20 -0
  111. package/dist/portal-MMUYX3RA.js +11 -0
  112. package/dist/portal-api-BJLO6AGI.js +11 -0
  113. package/dist/portal-api-Q6B3X6ZV.cjs +20 -0
  114. package/dist/profil-46FV46VH.cjs +20 -0
  115. package/dist/profil-4BIXIM6D.js +11 -0
  116. package/dist/referensi-HNYOJGNX.js +11 -0
  117. package/dist/referensi-VOT5BRCL.cjs +20 -0
  118. package/dist/registry.json +606 -4
  119. package/dist/repositori-2MJ43NXM.js +11 -0
  120. package/dist/repositori-KFXVJB7B.cjs +20 -0
  121. package/dist/rpl-2ZXHNSPF.js +11 -0
  122. package/dist/rpl-YF5MV2YA.cjs +20 -0
  123. package/dist/sarpras-CQ7QKVUN.js +11 -0
  124. package/dist/sarpras-XGPHHFCG.cjs +20 -0
  125. package/dist/scss/components/_animasi.scss +280 -0
  126. package/dist/scss/components/_ikon.scss +249 -0
  127. package/dist/scss/components/_katalog.scss +662 -0
  128. package/dist/scss/components/_platform.scss +364 -0
  129. package/dist/scss/index.scss +4 -0
  130. package/dist/sdm-N6LE66PP.cjs +20 -0
  131. package/dist/sdm-QKBXH3JF.js +11 -0
  132. package/dist/siakad.cjs +1389 -2915
  133. package/dist/siakad.cjs.map +1 -1
  134. package/dist/siakad.d.cts +31 -196
  135. package/dist/siakad.d.ts +31 -196
  136. package/dist/siakad.js +446 -2194
  137. package/dist/siakad.js.map +1 -1
  138. package/dist/spmi-J37NEYIU.js +11 -0
  139. package/dist/spmi-N6EU7KNC.cjs +20 -0
  140. package/dist/tracer-G6DDXVXC.cjs +20 -0
  141. package/dist/tracer-M3FTNWBG.js +11 -0
  142. package/dist/ui-unipo.css +1650 -0
  143. package/dist/ui-unipo.min.css +1 -1
  144. package/package.json +25 -3
  145. package/src/components/animasi.tsx +186 -0
  146. package/src/components/modal.tsx +113 -0
  147. package/src/components/overlays.tsx +6 -1
  148. package/src/components/pemeliharaan.tsx +152 -0
  149. package/src/components/platform.tsx +94 -0
  150. package/src/ikon/ilustrasi.tsx +283 -0
  151. package/src/ikon/index.ts +7 -0
  152. package/src/ikon/kotak.tsx +30 -0
  153. package/src/ikon/unipo.tsx +326 -0
  154. package/src/index.ts +8 -0
  155. package/src/katalog/aturan.ts +488 -0
  156. package/src/katalog/data/id.ts +749 -0
  157. package/src/katalog/data/indeks.json +1 -0
  158. package/src/katalog/data/muat.ts +47 -0
  159. package/src/katalog/ikon.tsx +11 -0
  160. package/src/katalog/index.ts +13 -0
  161. package/src/katalog/isian.tsx +372 -0
  162. package/src/katalog/katalog.tsx +138 -0
  163. package/src/katalog/kelola.tsx +620 -0
  164. package/src/katalog/modul.tsx +336 -0
  165. package/src/katalog/tampil.tsx +147 -0
  166. package/src/katalog/tipe.ts +141 -0
  167. package/src/katalog/wizard.tsx +155 -0
  168. package/src/lib/platform.ts +91 -0
  169. package/src/siakad/index.ts +1 -0
  170. package/src/siakad/pemeliharaan.tsx +118 -0
  171. package/src/siakad/provider.tsx +2 -0
  172. package/dist/chunk-DEF5CA7C.cjs.map +0 -1
  173. package/dist/chunk-SJ4DJOMZ.js.map +0 -1
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Aturan yang sama dengan backend SIAKAD, dibaca dari katalog OpenAPI:
3
+ *
4
+ * - validasi isian = PusatData\Aturan::bersihkan (teks:N, kode:N, enum, bool, int:min:max, angka, tanggal, waktu,
5
+ * jam, email, url, telepon, json, ref) dan penanda `wajib`, dengan pesan galat yang sama;
6
+ * - alur status aksi = PusatData\Crud::aksi (aksi hanya sah bila status baris salah satu `dari`, lalu menjadi `ke`);
7
+ * - scope = Principal::requireScope (semua scope operasi harus dimiliki token, cocok persis).
8
+ *
9
+ * Validasi di browser mencegah kiriman yang pasti ditolak; server tetap memeriksa ulang (kunci tabel lama
10
+ * harus ada, nilai unik, hak pengelola), dan galat 422 dari server dipetakan ke isian yang sama.
11
+ */
12
+ import type { Baris, Operasi, Parameter, Skema } from './tipe';
13
+
14
+ // ================================================================ Label dan teks
15
+ const KHUSUS: Record<string, string> = {
16
+ nim: 'NIM',
17
+ nidn: 'NIDN',
18
+ nik: 'NIK',
19
+ nip: 'NIP',
20
+ nuptk: 'NUPTK',
21
+ npwp: 'NPWP',
22
+ url: 'URL',
23
+ id: 'ID',
24
+ uuid: 'UUID',
25
+ ipk: 'IPK',
26
+ ips: 'IPS',
27
+ sks: 'SKS',
28
+ email: 'Email',
29
+ kode_prodi: 'Program studi',
30
+ kode_fakultas: 'Fakultas',
31
+ tahun_id: 'Tahun akademik',
32
+ nik_karyawan: 'Karyawan',
33
+ username: 'Pengguna',
34
+ created_at: 'Dibuat',
35
+ updated_at: 'Diperbarui',
36
+ };
37
+
38
+ /** Label manusiawi dari nama kolom: `tanggal_mulai` → "Tanggal mulai", `mitra_id` → "Mitra". */
39
+ export function labelKolom(nama: string): string {
40
+ const k = KHUSUS[nama.toLowerCase()];
41
+ if (k) return k;
42
+ const t = nama
43
+ .replace(/_/g, ' ')
44
+ .replace(/\s+id$/i, '')
45
+ .replace(/^id\s+/i, '')
46
+ .replace(/\b(nim|nidn|nik|nip|ipk|ips|sks|url|uuid|cpl|cpmk|rps|ukt|kip|va|qr|pt|sk)\b/gi, (m) => m.toUpperCase())
47
+ .trim();
48
+ return t.charAt(0).toUpperCase() + t.slice(1);
49
+ }
50
+
51
+ /** Label nilai enum: `kip_kuliah` → "Kip kuliah", `pemilik_atau_pengelola` → "Pemilik atau pengelola". */
52
+ export function labelNilai(v: unknown): string {
53
+ if (v === null || v === undefined || v === '') return '–';
54
+ if (typeof v === 'boolean') return v ? 'Ya' : 'Tidak';
55
+ const s = String(v);
56
+ if (/^[a-z0-9]+(_[a-z0-9]+)*$/.test(s) && s.length > 1) {
57
+ const t = s.replace(/_/g, ' ');
58
+ return t.charAt(0).toUpperCase() + t.slice(1);
59
+ }
60
+ return s;
61
+ }
62
+
63
+ /** Judul aksi tanpa awalan sumber: "Program beasiswa: Buka pendaftaran" → "Buka pendaftaran". */
64
+ export function judulOperasi(o: Pick<Operasi, 'sum' | 'path'>): string {
65
+ const s = o.sum.includes(':') ? o.sum.split(':').slice(1).join(':').trim() : o.sum;
66
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : o.path;
67
+ }
68
+
69
+ /** Keterangan isian tanpa nama tabel/kolom teknis (m_mahasiswa.NIM, ID di kmhs_ormawa, "(unik)"). */
70
+ export function keteranganIsian(s: Skema): string | undefined {
71
+ let t = s.ds ?? '';
72
+ t = t
73
+ .replace(/\((hanya baca|hanya pengelola|unik)(, (hanya baca|hanya pengelola|unik))*\)/g, '')
74
+ .replace(/\bID (di )?[a-z]+_[a-z_]+\b/g, '')
75
+ .replace(/\b[a-z_]+\.[A-Za-z_]+\b/g, '')
76
+ .replace(/^[\s;:,()-]+|[\s;:,()-]+$/g, '')
77
+ .trim();
78
+ return t || undefined;
79
+ }
80
+
81
+ /** Penanda kolom dari keterangan backend: unik, hanya pengelola, hanya baca. */
82
+ export function penandaKolom(s: Skema): { unik: boolean; kelola: boolean; baca: boolean } {
83
+ const ds = s.ds ?? '';
84
+ return { unik: /\bunik\b/.test(ds), kelola: /hanya pengelola/.test(ds), baca: Boolean(s.ro) || /hanya baca/.test(ds) };
85
+ }
86
+
87
+ // ================================================================ Path
88
+ /** Isi `{param}` di path. */
89
+ export function isiPath(path: string, nilai: Record<string, unknown>): string {
90
+ return path.replace(/\{([^}]+)\}/g, (_, n: string) => encodeURIComponent(String(nilai[n] ?? '')));
91
+ }
92
+ export function paramPath(path: string): string[] {
93
+ return [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
94
+ }
95
+
96
+ // ================================================================ Jenis isian
97
+ export type JenisIsian =
98
+ | 'file'
99
+ | 'boolean'
100
+ | 'enum'
101
+ | 'bulat'
102
+ | 'angka'
103
+ | 'tanggal'
104
+ | 'waktu'
105
+ | 'jam'
106
+ | 'email'
107
+ | 'url'
108
+ | 'telepon'
109
+ | 'kode'
110
+ | 'teks-panjang'
111
+ | 'daftar'
112
+ | 'json'
113
+ | 'acuan'
114
+ | 'teks';
115
+
116
+ const PANJANG = /(^isi|konten|deskripsi|keterangan|catatan|uraian|ringkasan|abstrak|alamat|tujuan|alasan|pesan|komentar|saran|tanggapan|_html$|teks|body|narasi|temuan|rekomendasi|syarat|ketentuan|kronologi|tindak_lanjut|hasil)/i;
117
+ const TELEPON = /^(telepon|telp|hp|no_hp|nomor_hp|no_telp|whatsapp|wa|no_wa|kontak_hp|telepon_.+|hp_.+)$/i;
118
+ export const POLA_KODE = '^[A-Za-z0-9._\\-/]+$';
119
+ const POLA_JAM = '^\\d{2}:\\d{2}$';
120
+
121
+ /** Jenis isian untuk satu properti skema; menentukan kontrol dan aturan validasi. */
122
+ export function jenisIsian(nama: string, s: Skema): JenisIsian {
123
+ if (s.f === 'binary' || (s.t === 'array' && s.i?.f === 'binary')) return 'file';
124
+ if (s.rf && (!s.t || s.t === 'string' || s.t === 'integer')) return 'acuan';
125
+ if (s.t === 'boolean') return 'boolean';
126
+ if (s.e && s.e.length) return 'enum';
127
+ if (s.t === 'integer') return 'bulat';
128
+ if (s.t === 'number') return 'angka';
129
+ if (s.t === 'array') return s.i && s.i.t !== 'object' && s.i.t !== 'array' ? 'daftar' : 'json';
130
+ if (s.t === 'object' || (!s.t && /JSON/i.test(s.ds ?? ''))) return 'json';
131
+ if (s.f === 'date') return 'tanggal';
132
+ if (s.f === 'date-time') return 'waktu';
133
+ if (s.f === 'time' || s.p === POLA_JAM) return 'jam';
134
+ if (s.f === 'email') return 'email';
135
+ if (s.f === 'uri') return 'url';
136
+ if (s.p === POLA_KODE) return 'kode';
137
+ if (!s.p && TELEPON.test(nama)) return 'telepon';
138
+ if ((s.max && s.max > 300) || (!s.max && PANJANG.test(nama))) return 'teks-panjang';
139
+ return 'teks';
140
+ }
141
+
142
+ /** Properti yang bisa diisi (bukan hanya baca), urut seperti di skema. */
143
+ export function isianSkema(skema: Skema | undefined): Array<[string, Skema]> {
144
+ return Object.entries(skema?.pr ?? {}).filter(([, s]) => !penandaKolom(s).baca);
145
+ }
146
+
147
+ // ================================================================ Validasi (cermin PusatData\Aturan)
148
+ const kosong = (v: unknown) => v === undefined || v === null || (typeof v === 'string' && v.trim() === '') || (typeof FileList !== 'undefined' && v instanceof FileList && v.length === 0) || (Array.isArray(v) && v.length === 0);
149
+
150
+ function tanggalSah(v: string): boolean {
151
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
152
+ const [y, m, d] = v.split('-').map(Number);
153
+ const dt = new Date(Date.UTC(y, m - 1, d));
154
+ return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
155
+ }
156
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
157
+ const buangTag = (s: string) => s.replace(/<[^>]*>/g, '');
158
+
159
+ /**
160
+ * Periksa satu nilai seperti backend. Kembalikan pesan galat (huruf kecil, sama dengan respons 422) atau null.
161
+ * Nilai kosong selalu lolos di sini; wajib-isi diperiksa {@link validasiBody}.
162
+ */
163
+ export function validasiNilai(nama: string, s: Skema, v: unknown): string | null {
164
+ if (kosong(v)) return null;
165
+ const j = jenisIsian(nama, s);
166
+ const teks = typeof v === 'string' ? v.trim() : String(v);
167
+ switch (j) {
168
+ case 'teks':
169
+ case 'teks-panjang': {
170
+ const maks = s.max ?? (j === 'teks-panjang' ? 65000 : undefined);
171
+ if (maks && buangTag(teks).length > maks) return `maksimal ${maks} karakter`;
172
+ if (s.p && !new RegExp(s.p).test(teks)) return 'format tidak valid';
173
+ return null;
174
+ }
175
+ case 'kode':
176
+ if (!new RegExp(POLA_KODE).test(teks) || (s.max && teks.length > s.max)) return `hanya huruf, angka, titik, garis bawah, strip, garis miring; maksimal ${s.max ?? 50} karakter`;
177
+ return null;
178
+ case 'enum':
179
+ return s.e!.some((x) => String(x) === teks) ? null : `harus salah satu: ${s.e!.filter((x) => x !== null).join(', ')}`;
180
+ case 'boolean':
181
+ return [true, false, 1, 0, '1', '0', 'true', 'false'].includes(v as never) ? null : 'harus true atau false';
182
+ case 'bulat':
183
+ case 'acuan':
184
+ if (j === 'acuan' && s.t !== 'integer') return teks.length > 50 ? 'tidak valid' : null;
185
+ if (!/^-?\d{1,10}$/.test(teks)) return j === 'acuan' || /\bID di\b/.test(s.ds ?? '') ? 'harus ID angka' : 'harus bilangan bulat';
186
+ return rentang(Number(teks), s);
187
+ case 'angka':
188
+ if (teks === '' || Number.isNaN(Number(teks))) return 'harus angka';
189
+ return rentang(Number(teks), s);
190
+ case 'tanggal':
191
+ return tanggalSah(teks) ? null : 'format YYYY-MM-DD';
192
+ case 'waktu':
193
+ if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?/.test(teks)) return 'format YYYY-MM-DD HH:MM:SS';
194
+ return tanggalSah(teks.slice(0, 10)) ? null : 'waktu tidak valid';
195
+ case 'jam':
196
+ return /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/.test(teks) ? null : 'format HH:MM';
197
+ case 'email':
198
+ return EMAIL.test(teks) && teks.length <= 150 ? null : 'email tidak valid';
199
+ case 'url':
200
+ return /^https?:\/\/[^\s]+\.[^\s]+/i.test(teks) && teks.length <= 500 ? null : 'harus URL http/https';
201
+ case 'telepon':
202
+ return /^\+?[0-9 ()-]{5,25}$/.test(teks) ? null : 'nomor telepon tidak valid';
203
+ case 'json':
204
+ if (typeof v === 'string') {
205
+ try {
206
+ const x = JSON.parse(v);
207
+ if (x === null || typeof x !== 'object') return 'harus objek atau array';
208
+ } catch {
209
+ return 'harus JSON';
210
+ }
211
+ } else if (typeof v !== 'object') return 'harus objek atau array';
212
+ return JSON.stringify(v).length > 60000 ? 'terlalu besar' : null;
213
+ default:
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function rentang(n: number, s: Skema): string | null {
219
+ if (s.min !== undefined && s.mx !== undefined && (n < s.min || n > s.mx)) return `harus ${s.min} sampai ${s.mx}`;
220
+ if (s.min !== undefined && s.mx === undefined && n < s.min) return `minimal ${s.min}`;
221
+ if (s.mx !== undefined && s.min === undefined && n > s.mx) return `maksimal ${s.mx}`;
222
+ return null;
223
+ }
224
+
225
+ /** Huruf besar di awal dan titik di akhir, untuk menampilkan galat backend/frontend dengan gaya yang sama. */
226
+ export function rapikanGalat(pesan: string): string {
227
+ const t = pesan.trim();
228
+ if (!t) return t;
229
+ return t.charAt(0).toUpperCase() + t.slice(1) + (/[.!?]$/.test(t) ? '' : '.');
230
+ }
231
+
232
+ export type NilaiForm = Record<string, unknown>;
233
+ export type GalatForm = Record<string, string>;
234
+
235
+ /** Nilai JSON sebagai data; teks JSON dari server diurai dulu. */
236
+ export function uraiJson(v: unknown): unknown {
237
+ if (v === undefined || v === null || v === '') return undefined;
238
+ if (typeof v !== 'string') return v;
239
+ try {
240
+ return JSON.parse(v);
241
+ } catch {
242
+ return v;
243
+ }
244
+ }
245
+
246
+ /** Nilai awal formulir dari baris yang ada (ubah) atau nilai bawaan skema (tambah). */
247
+ export function nilaiAwal(skema: Skema | undefined, data?: Baris | null): NilaiForm {
248
+ const hasil: NilaiForm = {};
249
+ for (const [k, s] of isianSkema(skema)) {
250
+ const j = jenisIsian(k, s);
251
+ let v = data ? data[k] : s.d;
252
+ if (j === 'boolean') v = v === true || v === 1 || v === '1' || v === 'Y' || v === 'true';
253
+ else if (j === 'json') v = uraiJson(v);
254
+ else if (j === 'daftar') v = Array.isArray(v) ? v.map(String) : typeof v === 'string' && v ? v.split(',').map((x) => x.trim()) : [];
255
+ else if (j === 'waktu' && typeof v === 'string') v = v.replace(' ', 'T').slice(0, 16);
256
+ else if (j === 'tanggal' && typeof v === 'string') v = v.slice(0, 10);
257
+ else if (j === 'jam' && typeof v === 'string') v = v.slice(0, 5);
258
+ else if (j === 'file') v = undefined;
259
+ else v = v === null || v === undefined ? '' : String(v);
260
+ hasil[k] = v;
261
+ }
262
+ return hasil;
263
+ }
264
+
265
+ const sama = (a: unknown, b: unknown) => JSON.stringify(a ?? '') === JSON.stringify(b ?? '');
266
+
267
+ export interface OpsiBody {
268
+ /** Mode ubah (PUT/PATCH): hanya kolom yang berubah yang dikirim; wajib tidak boleh dikosongkan. */
269
+ ubah?: boolean;
270
+ /** Nilai awal (mode ubah) untuk menghitung perubahan. */
271
+ awal?: NilaiForm;
272
+ /** Kirim sebagai multipart (ada berkas). */
273
+ multipart?: boolean;
274
+ }
275
+
276
+ /**
277
+ * Periksa semua isian dan bentuk body permintaan, seperti PusatData\Crud::tambah/ubah:
278
+ * - tambah: kolom wajib harus terisi; isian kosong tidak dikirim;
279
+ * - ubah: hanya kolom yang berubah dikirim; mengosongkan kolom wajib ditolak ("wajib diisi"),
280
+ * kolom lain yang dikosongkan dikirim null.
281
+ */
282
+ export function validasiBody(skema: Skema | undefined, nilai: NilaiForm, opsi: OpsiBody = {}): { body: Record<string, unknown> | FormData; galat: GalatForm; berubah: string[] } {
283
+ const { ubah = false, awal, multipart = false } = opsi;
284
+ const galat: GalatForm = {};
285
+ const wajib = new Set(skema?.rq ?? []);
286
+ const out: Record<string, unknown> = {};
287
+ const berubah: string[] = [];
288
+ for (const [k, s] of isianSkema(skema)) {
289
+ const j = jenisIsian(k, s);
290
+ const v = nilai[k];
291
+ if (ubah && awal && sama(v, awal[k]) && j !== 'file') continue;
292
+ if (kosong(v) || (j === 'json' && v !== null && typeof v === 'object' && Object.keys(v as object).length === 0)) {
293
+ if (wajib.has(k) && (!ubah || (awal && !kosong(awal[k])))) galat[k] = 'wajib diisi';
294
+ else if (ubah && j !== 'file') {
295
+ out[k] = null;
296
+ berubah.push(k);
297
+ }
298
+ continue;
299
+ }
300
+ const g = validasiNilai(k, s, v);
301
+ if (g) {
302
+ galat[k] = g;
303
+ continue;
304
+ }
305
+ out[k] = keNilaiApi(j, s, v);
306
+ berubah.push(k);
307
+ }
308
+ if (!multipart) return { body: out, galat, berubah };
309
+ const fd = new FormData();
310
+ for (const [k, v] of Object.entries(out)) {
311
+ if (typeof FileList !== 'undefined' && v instanceof FileList) Array.from(v).forEach((f) => fd.append(skema?.pr?.[k]?.t === 'array' ? `${k}[]` : k, f));
312
+ else if (v instanceof Blob) fd.append(k, v);
313
+ else if (Array.isArray(v)) v.forEach((x) => fd.append(`${k}[]`, String(x)));
314
+ else if (typeof v === 'boolean') fd.append(k, v ? '1' : '0');
315
+ else if (v !== null && typeof v === 'object') fd.append(k, JSON.stringify(v));
316
+ else if (v !== null) fd.append(k, String(v));
317
+ }
318
+ return { body: fd, galat, berubah };
319
+ }
320
+
321
+ /** Nilai formulir → nilai yang dikirim ke API. */
322
+ export function keNilaiApi(j: JenisIsian, s: Skema, v: unknown): unknown {
323
+ switch (j) {
324
+ case 'bulat':
325
+ return Number(v);
326
+ case 'angka':
327
+ return Number(v);
328
+ case 'acuan':
329
+ return s.t === 'integer' ? Number(v) : String(v).trim();
330
+ case 'boolean':
331
+ return v === true || v === 1 || v === '1' || v === 'true';
332
+ case 'json':
333
+ return typeof v === 'string' ? JSON.parse(v) : v;
334
+ case 'daftar': {
335
+ const arr = Array.isArray(v) ? v : String(v).split(',');
336
+ return arr.map((x) => String(x).trim()).filter(Boolean).map((x) => (s.i?.t === 'integer' || s.i?.t === 'number' ? Number(x) : x));
337
+ }
338
+ case 'waktu': {
339
+ const t = String(v).replace('T', ' ');
340
+ return t.length === 16 ? `${t}:00` : t;
341
+ }
342
+ case 'jam':
343
+ return String(v).slice(0, 5);
344
+ case 'file':
345
+ return v;
346
+ default:
347
+ return typeof v === 'string' ? v.trim() : v;
348
+ }
349
+ }
350
+
351
+ /** Galat 422 dari server (`details`: { kolom: pesan }) → galat per isian. */
352
+ export function galatDariServer(e: unknown): GalatForm | null {
353
+ const err = e as { status?: number; details?: unknown; code?: string } | null;
354
+ if (!err || (err.status !== 422 && err.code !== 'validation_failed')) return null;
355
+ const d = err.details as Record<string, unknown> | undefined;
356
+ if (!d || typeof d !== 'object') return null;
357
+ const out: GalatForm = {};
358
+ for (const [k, v] of Object.entries(d)) out[k] = Array.isArray(v) ? v.join('; ') : String(v);
359
+ return Object.keys(out).length ? out : null;
360
+ }
361
+
362
+ // ================================================================ Parameter query
363
+ /** Periksa parameter (query/path) seperti body. */
364
+ export function validasiParameter(pa: Parameter[] | undefined, nilai: NilaiForm): GalatForm {
365
+ const galat: GalatForm = {};
366
+ for (const p of pa ?? []) {
367
+ const v = nilai[p.nm];
368
+ if (kosong(v)) {
369
+ if (p.rq) galat[p.nm] = 'wajib diisi';
370
+ continue;
371
+ }
372
+ const g = validasiNilai(p.nm, p.s, v);
373
+ if (g) galat[p.nm] = g;
374
+ }
375
+ return galat;
376
+ }
377
+
378
+ // ================================================================ Alur status (cermin PusatData\Crud::aksi)
379
+ /** Kolom status yang dipakai aksi: `status` bila ada, atau kolom enum yang memuat semua status asal. */
380
+ export function kolomStatus(skemaBaris: Skema | undefined, tr: Operasi['tr'], baris?: Baris | null): string {
381
+ const pr = skemaBaris?.pr ?? {};
382
+ if (!tr) return 'status';
383
+ const cocok = (k: string) => {
384
+ const e = pr[k]?.e?.map(String);
385
+ return Boolean(e && tr.dari.every((d) => e.includes(d)));
386
+ };
387
+ if (cocok('status')) return 'status';
388
+ const lain = Object.keys(pr).find(cocok);
389
+ if (lain) return lain;
390
+ if (baris) {
391
+ const kb = Object.keys(baris).find((k) => /status/.test(k) && tr.dari.includes(String(baris[k])));
392
+ if (kb) return kb;
393
+ }
394
+ return 'status';
395
+ }
396
+
397
+ export interface KeabsahanAksi {
398
+ boleh: boolean;
399
+ /** Alasan bila tidak boleh (teks sama dengan respons 409 backend). */
400
+ alasan?: string;
401
+ }
402
+
403
+ /**
404
+ * Apakah aksi boleh dijalankan untuk baris ini menurut alur status.
405
+ * Hak pelaku (pengelola/pemilik) tetap diperiksa server; di sini hanya status dan scope.
406
+ */
407
+ export function keabsahanAksi(op: Operasi, baris: Baris | null | undefined, skemaBaris?: Skema, scopes?: readonly string[] | null): KeabsahanAksi {
408
+ if (!punyaScope(op, scopes)) return { boleh: false, alasan: `Butuh izin ${op.sc!.filter((s) => !scopes!.includes(s)).join(', ')}.` };
409
+ if (!op.tr || !baris) return { boleh: true };
410
+ const k = kolomStatus(skemaBaris, op.tr, baris);
411
+ const sekarang = baris[k] == null ? '' : String(baris[k]);
412
+ if (op.tr.dari.includes(sekarang)) return { boleh: true };
413
+ return { boleh: false, alasan: `${judulOperasi(op)} hanya bisa dilakukan bila status ${op.tr.dari.join('/')} (sekarang: ${sekarang || '-'}).` };
414
+ }
415
+
416
+ /** Semua scope operasi harus dimiliki token (Principal::requireScope). `scopes` tidak diisi = tidak diperiksa. */
417
+ export function punyaScope(op: Pick<Operasi, 'sc' | 'pub'>, scopes?: readonly string[] | null): boolean {
418
+ if (!scopes || op.pub || !op.sc?.length) return true;
419
+ return op.sc.every((s) => scopes.includes(s));
420
+ }
421
+
422
+ /** Urutan status dari alur aksi: [{ dari, ke, aksi }], untuk diagram alur. */
423
+ export function alurStatus(ops: Operasi[]): Array<{ dari: string[]; ke?: string; op: Operasi }> {
424
+ return ops.filter((o) => o.tr).map((o) => ({ dari: o.tr!.dari, ke: o.tr!.ke, op: o }));
425
+ }
426
+
427
+ // ================================================================ Kolom tabel
428
+ const PRIORITAS = [
429
+ /^(kode|nim|nidn|nik|nip|username|no|nomor)(_|$)/i,
430
+ /^(nama|judul|title|subjek|perihal)(_|$)/i,
431
+ /(nama|judul)/i,
432
+ /^(jenis|tipe|kategori|level)(_|$)/i,
433
+ /(prodi|fakultas|unit|periode|tahun)/i,
434
+ /(tanggal|tgl|mulai|selesai|batas)/i,
435
+ /(status|aktif)/i,
436
+ ];
437
+ const ABAIKAN = /(^uuid$|password|secret|hash|token|_html$|^isi$|^isi_|^konten|^body$|^blok|deskripsi|keterangan|catatan|^meta|created_by|updated_by|diubah_oleh|dibuat_oleh|deleted|^syarat|^updated_at$)/i;
438
+
439
+ /** Pilih kolom tabel dari skema dan isi baris: kunci/nama dulu, lalu jenis, unit, tanggal, status. */
440
+ export function pilihKolom(skema: Skema | undefined, baris: Baris[] = [], maks = 6): { semua: string[]; awal: string[] } {
441
+ const dariSkema = skema?.pr ? Object.keys(skema.pr) : [];
442
+ const dariData = baris.length ? Object.keys(baris[0]) : [];
443
+ const semua = dariData.length ? [...new Set([...dariSkema.filter((k) => dariData.includes(k)), ...dariData])] : dariSkema;
444
+ const skor = (k: string) => {
445
+ if (ABAIKAN.test(k)) return 100;
446
+ const i = PRIORITAS.findIndex((r) => r.test(k));
447
+ const contoh = baris[0]?.[k];
448
+ const tipe = skema?.pr?.[k]?.t;
449
+ const berat = (contoh !== null && typeof contoh === 'object') || tipe === 'object' || tipe === 'array' ? 50 : 0;
450
+ return (i === -1 ? 20 : i) + berat;
451
+ };
452
+ const urut = semua.filter((k) => k !== 'id' || semua.length <= 2).sort((a, b) => skor(a) - skor(b));
453
+ let awal = urut.filter((k) => skor(k) < 50).slice(0, maks);
454
+ if (!awal.length) awal = semua.slice(0, maks);
455
+ awal = semua.filter((k) => awal.includes(k));
456
+ return { semua, awal };
457
+ }
458
+
459
+ /** Judul satu baris untuk panel detail dan konfirmasi. */
460
+ export function judulBaris(b: Baris | null | undefined, kunci?: string): string {
461
+ if (!b) return '';
462
+ for (const k of ['nama', 'judul', 'title', 'nama_lengkap', 'perihal', 'subjek', 'nama_prodi', 'nama_fakultas', 'nama_mk', 'nomor']) {
463
+ if (typeof b[k] === 'string' && b[k]) return b[k];
464
+ }
465
+ const kn = Object.keys(b).find((k) => /^(nama|judul)/.test(k) && typeof b[k] === 'string' && b[k]);
466
+ if (kn) return String(b[kn]);
467
+ return String(b.kode ?? b[kunci ?? 'id'] ?? b.id ?? '');
468
+ }
469
+
470
+ /** Nilai kunci baris untuk path item ({id}, {kode}, {nim}). */
471
+ export function nilaiKunci(b: Baris, kunci = 'id'): string | undefined {
472
+ const v = b[kunci] ?? b[kunci.toLowerCase()] ?? b[kunci.toUpperCase()] ?? b.id ?? b.uuid;
473
+ return v === undefined || v === null ? undefined : String(v);
474
+ }
475
+
476
+ type Nada = 'neutral' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info';
477
+ const NADA: Array<[RegExp, Nada]> = [
478
+ [/^(aktif|a|buka|disetujui|setuju|terverifikasi|valid|lulus|selesai|terbit|diterbitkan|lunas|publik|published|diterima|tersedia|hadir|h|berlaku|sah|ditetapkan|sukses|berhasil|dibayar|tervalidasi|approved|done|ok)$/i, 'success'],
479
+ [/^(draf|draft|baru|diajukan|menunggu|pending|review|diproses|proses|seleksi|berjalan|dijadwalkan|terjadwal|dipinjam|antre|antrean|cuti|c|izin|i|sakit|s|revisi|perbaikan|verifikasi)$/i, 'warning'],
480
+ [/^(ditolak|tolak|batal|dibatalkan|gagal|kedaluwarsa|expired|nonaktif|non-aktif|n|d|do|drop out|keluar|hilang|rusak|alpa|blokir|diblokir|dicabut|ditutup|tutup|terlambat|denda|failed|rejected)$/i, 'danger'],
481
+ [/^(arsip|diarsipkan|inaktif|nonaktif sementara|dihapus|musnah|l)$/i, 'neutral'],
482
+ ];
483
+ /** Warna lencana untuk nilai status yang umum dipakai API SIAKAD. */
484
+ export function nadaStatus(v: unknown): Nada {
485
+ const s = String(v ?? '').trim();
486
+ for (const [re, n] of NADA) if (re.test(s)) return n;
487
+ return 'info';
488
+ }