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,155 @@
1
+ 'use client';
2
+ import * as React from 'react';
3
+ import { Wizard, WizardReview, type WizardStep } from '../components/wizard';
4
+ import { useToast } from '../components/overlays';
5
+ import { Skeleton } from '../components/actions';
6
+ import { GalatApi } from '../siakad/galat';
7
+ import { invalidateSiakad, pesanGalat, useSiakad } from '../siakad/provider';
8
+ import { isianSkema, jenisIsian, labelKolom, labelNilai, rapikanGalat, validasiBody, type JenisIsian } from './aturan';
9
+ import { FormulirSkema, useFormulirSkema } from './isian';
10
+ import { useSumber } from './katalog';
11
+ import type { Baris, Skema } from './tipe';
12
+
13
+ export interface LangkahSkema {
14
+ id: string;
15
+ judul: string;
16
+ keterangan?: string;
17
+ /** Kolom skema di langkah ini. */
18
+ kolom: string[];
19
+ }
20
+
21
+ const KELOMPOK: Array<{ id: string; judul: string; keterangan: string; cocok: (k: string, j: JenisIsian) => boolean }> = [
22
+ { id: 'identitas', judul: 'Identitas', keterangan: 'Kode, nama, dan jenis.', cocok: (k, j) => /^(kode|nama|judul|jenis|tipe|kategori|level|bidang|perihal|subjek|nomor|no_)/.test(k) || (j === 'enum' && /jenis|tipe|kategori/.test(k)) },
23
+ { id: 'pihak', judul: 'Pihak terkait', keterangan: 'Mahasiswa, dosen, unit, atau data lain yang dirujuk.', cocok: (_, j) => j === 'acuan' },
24
+ { id: 'jadwal', judul: 'Waktu', keterangan: 'Tanggal, jam, dan periode.', cocok: (k, j) => j === 'tanggal' || j === 'waktu' || j === 'jam' || /(mulai|selesai|batas|periode|semester)/.test(k) },
25
+ { id: 'nilai', judul: 'Angka & ketentuan', keterangan: 'Nominal, kuota, dan batas.', cocok: (_, j) => j === 'angka' || j === 'bulat' || j === 'boolean' },
26
+ { id: 'uraian', judul: 'Uraian', keterangan: 'Keterangan, syarat, dan lampiran.', cocok: (_, j) => j === 'teks-panjang' || j === 'json' || j === 'file' || j === 'daftar' },
27
+ ];
28
+
29
+ /** Bagi isian skema menjadi langkah (identitas, pihak terkait, waktu, angka, uraian, lainnya). */
30
+ export function langkahDariSkema(skema: Skema | undefined, maksPerLangkah = 6): LangkahSkema[] {
31
+ const sisa = isianSkema(skema).map(([k, s]) => [k, jenisIsian(k, s)] as const);
32
+ const out: LangkahSkema[] = [];
33
+ const pakai = new Set<string>();
34
+ for (const g of KELOMPOK) {
35
+ const kolom = sisa.filter(([k, j]) => !pakai.has(k) && g.cocok(k, j)).map(([k]) => k);
36
+ kolom.forEach((k) => pakai.add(k));
37
+ for (let i = 0; i < kolom.length; i += maksPerLangkah) out.push({ id: `${g.id}-${i}`, judul: g.judul, keterangan: g.keterangan, kolom: kolom.slice(i, i + maksPerLangkah) });
38
+ }
39
+ const lain = sisa.filter(([k]) => !pakai.has(k)).map(([k]) => k);
40
+ if (lain.length) {
41
+ // Isian lain digabung ke langkah pertama bila sedikit.
42
+ if (out.length && lain.length <= 2) out[0].kolom.push(...lain);
43
+ else out.push({ id: 'lain', judul: 'Lainnya', kolom: lain });
44
+ }
45
+ return out.length ? out : [{ id: 'semua', judul: 'Data', kolom: [] }];
46
+ }
47
+
48
+ export interface WizardSumberProps {
49
+ /** Path sumber data, mis. '/v1/sarpras/peminjaman'. */
50
+ sumber: string;
51
+ induk?: Record<string, unknown>;
52
+ /** Langkah sendiri; bawaan dibagi otomatis dari skema. */
53
+ langkah?: LangkahSkema[];
54
+ /** Nilai awal. */
55
+ nilai?: Record<string, unknown>;
56
+ title?: React.ReactNode;
57
+ description?: React.ReactNode;
58
+ aside?: React.ReactNode;
59
+ finishLabel?: string;
60
+ onSelesai?: (b: Baris) => void;
61
+ }
62
+
63
+ /**
64
+ * Formulir tambah bertahap dari skema endpoint POST: tiap langkah divalidasi dengan aturan backend sebelum lanjut,
65
+ * langkah terakhir ringkasan, lalu dikirim sekali. Galat 422 dari server membuka langkah isian yang salah.
66
+ */
67
+ export function WizardSumber({ sumber: id, induk, langkah, nilai, title, description, aside, finishLabel, onSelesai }: WizardSumberProps) {
68
+ const st = useSumber(id);
69
+ const siakad = useSiakad();
70
+ const toast = useToast();
71
+ const op = st.op(st.sumber?.create);
72
+ const skema = op?.b?.s;
73
+ const form = useFormulirSkema(skema, { nilai });
74
+ const [step, setStep] = React.useState(0);
75
+ const daftar = React.useMemo(() => (langkah ?? langkahDariSkema(skema)).map((l) => ({ ...l, kolom: l.kolom.filter((k) => !(induk && k in induk)) })).filter((l) => l.kolom.length), [langkah, skema, induk]);
76
+ if (st.loading) return <Skeleton height={320} />;
77
+ if (st.error) return <GalatApi error={st.error} />;
78
+ if (!op || !skema) return <GalatApi error={new Error('Sumber ini tidak bisa ditambah lewat API.')} />;
79
+ const wajib = new Set(skema.rq ?? []);
80
+ const periksaLangkah = (kolom: string[]) => {
81
+ const sub: Skema = { ...skema, pr: Object.fromEntries(kolom.map((k) => [k, skema.pr![k]])), rq: (skema.rq ?? []).filter((k) => kolom.includes(k)) };
82
+ const r = validasiBody(sub, form.nilai);
83
+ form.setGalat((g) => ({ ...g, ...Object.fromEntries(kolom.map((k) => [k, r.galat[k] ?? ''])) }));
84
+ const k = Object.keys(r.galat)[0];
85
+ return k ? `${labelKolom(k)}: ${rapikanGalat(r.galat[k])}` : null;
86
+ };
87
+ const steps: WizardStep[] = [
88
+ ...daftar.map((l) => ({
89
+ id: l.id,
90
+ title: l.judul,
91
+ description: l.keterangan,
92
+ error: l.kolom.some((k) => form.galat[k]),
93
+ optional: !l.kolom.some((k) => wajib.has(k)),
94
+ content: <FormulirSkema skema={skema} form={form} kolom={l.kolom} />,
95
+ onNext: () => periksaLangkah(l.kolom),
96
+ })),
97
+ {
98
+ id: 'tinjau',
99
+ title: 'Tinjau',
100
+ description: 'Periksa lalu kirim.',
101
+ content: (
102
+ <WizardReview
103
+ sections={daftar.map((l, i) => ({
104
+ title: l.judul,
105
+ onEdit: () => setStep(i),
106
+ items: l.kolom.map((k) => ({ label: labelKolom(k), value: tampilNilai(form.nilai[k]) })),
107
+ }))}
108
+ />
109
+ ),
110
+ },
111
+ ];
112
+ return (
113
+ <Wizard
114
+ steps={steps}
115
+ step={step}
116
+ onStepChange={setStep}
117
+ title={title ?? `Tambah ${st.sumber?.title.toLowerCase()}`}
118
+ description={description}
119
+ aside={aside}
120
+ finishLabel={finishLabel ?? 'Kirim'}
121
+ onFinish={async () => {
122
+ const body = form.kirim();
123
+ if (!body) {
124
+ const k = Object.keys(form.galat).find((x) => form.galat[x]);
125
+ const i = daftar.findIndex((l) => k && l.kolom.includes(k));
126
+ if (i >= 0) setStep(i);
127
+ return 'Masih ada isian yang perlu diperbaiki.';
128
+ }
129
+ try {
130
+ const r = await siakad.request({ method: op.m, path: op.path, query: { ...induk } as never, body, multipart: typeof FormData !== 'undefined' && body instanceof FormData });
131
+ toast.add({ title: `${st.sumber?.title} terkirim`, type: 'success' });
132
+ invalidateSiakad('katalog:');
133
+ onSelesai?.((r.data as Baris) ?? {});
134
+ return null;
135
+ } catch (e) {
136
+ if (form.galatServer(e)) {
137
+ const g = (e as { details?: Record<string, unknown> }).details ?? {};
138
+ const i = daftar.findIndex((l) => l.kolom.some((k) => k in g));
139
+ if (i >= 0) setStep(i);
140
+ return 'Server menolak beberapa isian. Periksa langkah yang ditandai.';
141
+ }
142
+ return pesanGalat(e);
143
+ }
144
+ }}
145
+ />
146
+ );
147
+ }
148
+
149
+ function tampilNilai(v: unknown): string {
150
+ if (v === undefined || v === null || v === '') return '–';
151
+ if (Array.isArray(v)) return v.join(', ') || '–';
152
+ if (typeof FileList !== 'undefined' && v instanceof FileList) return Array.from(v).map((f) => f.name).join(', ');
153
+ if (typeof v === 'object') return JSON.stringify(v);
154
+ return labelNilai(v);
155
+ }
@@ -0,0 +1,91 @@
1
+ import * as React from 'react';
2
+
3
+ /** Target aplikasi: web (peramban), desktop (Electron/Tauri), atau mobile (Capacitor/WebView/PWA). */
4
+ export type Platform = 'web' | 'desktop' | 'mobile';
5
+ /** Sistem operasi untuk gaya khas: iOS (Cupertino) atau Android (Material). */
6
+ export type SistemOperasi = 'ios' | 'android' | 'macos' | 'windows' | 'linux' | 'lain';
7
+
8
+ export interface InfoPlatform {
9
+ platform: Platform;
10
+ os: SistemOperasi;
11
+ /** Layar sentuh (target sentuh lebih besar, tanpa efek sorot). */
12
+ sentuh: boolean;
13
+ mobile: boolean;
14
+ desktop: boolean;
15
+ ios: boolean;
16
+ android: boolean;
17
+ }
18
+
19
+ export interface SetelPlatformOpsi {
20
+ /** 'auto' mendeteksi Capacitor/Cordova (mobile), Electron/Tauri (desktop), atau peramban (web). */
21
+ platform?: Platform | 'auto';
22
+ /** Untuk mobile: 'ios' atau 'android'; 'auto' dari user agent. */
23
+ os?: SistemOperasi | 'auto';
24
+ /** Elemen yang diberi atribut data-u-platform/data-u-os (bawaan <html>). */
25
+ target?: HTMLElement | null;
26
+ }
27
+
28
+ type W = Window & { Capacitor?: { getPlatform?: () => string; isNativePlatform?: () => boolean }; cordova?: unknown; __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown; electron?: unknown };
29
+
30
+ /** Deteksi platform dari lingkungan berjalan. */
31
+ export function deteksiPlatform(): { platform: Platform; os: SistemOperasi } {
32
+ if (typeof window === 'undefined') return { platform: 'web', os: 'lain' };
33
+ const w = window as W;
34
+ const ua = navigator.userAgent;
35
+ const iOS = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
36
+ const os: SistemOperasi = iOS ? 'ios' : /Android/.test(ua) ? 'android' : /Mac/.test(ua) ? 'macos' : /Win/.test(ua) ? 'windows' : /Linux/.test(ua) ? 'linux' : 'lain';
37
+ const cap = w.Capacitor?.getPlatform?.();
38
+ if (cap === 'ios' || cap === 'android') return { platform: 'mobile', os: cap };
39
+ if (w.cordova) return { platform: 'mobile', os };
40
+ if (w.__TAURI__ || w.__TAURI_INTERNALS__ || /Electron/.test(ua)) return { platform: 'desktop', os };
41
+ return { platform: 'web', os };
42
+ }
43
+
44
+ function info(platform: Platform, os: SistemOperasi): InfoPlatform {
45
+ const sentuh = typeof window !== 'undefined' ? window.matchMedia?.('(pointer: coarse)').matches || platform === 'mobile' : platform === 'mobile';
46
+ return { platform, os, sentuh, mobile: platform === 'mobile', desktop: platform === 'desktop', ios: os === 'ios', android: os === 'android' };
47
+ }
48
+
49
+ let sekarang: InfoPlatform = info('web', 'lain');
50
+ const pendengar = new Set<() => void>();
51
+
52
+ /**
53
+ * Atur target platform untuk seluruh komponen UNIPO. Panggil sekali di berkas awal aplikasi
54
+ * (CLI `npx unipo-ui init --platform mobile --os ios` menuliskannya otomatis):
55
+ * setelPlatform({ platform: 'mobile', os: 'android' });
56
+ * Komponen menyesuaikan: dialog dan panel jadi lembar bawah di mobile, target sentuh 44 px (iOS) / 48 px (Android),
57
+ * gaya tombol, saklar, dan navigasi mengikuti iOS atau Android, tabel lebih rapat di desktop, area aman layar berponi.
58
+ */
59
+ export function setelPlatform({ platform = 'auto', os = 'auto', target }: SetelPlatformOpsi = {}): InfoPlatform {
60
+ const d = deteksiPlatform();
61
+ const p = platform === 'auto' ? d.platform : platform;
62
+ let o = os === 'auto' ? d.os : os;
63
+ if (p === 'mobile' && o !== 'ios' && o !== 'android') o = 'android';
64
+ sekarang = info(p, o);
65
+ const el = target ?? (typeof document !== 'undefined' ? document.documentElement : null);
66
+ if (el) {
67
+ el.dataset.uPlatform = p;
68
+ el.dataset.uOs = o;
69
+ if (sekarang.sentuh) el.dataset.uSentuh = '';
70
+ else delete el.dataset.uSentuh;
71
+ }
72
+ pendengar.forEach((l) => l());
73
+ return sekarang;
74
+ }
75
+
76
+ /** Platform aktif (berubah bila setelPlatform dipanggil lagi). */
77
+ export function usePlatform(): InfoPlatform {
78
+ return React.useSyncExternalStore(
79
+ (l) => {
80
+ pendengar.add(l);
81
+ return () => void pendengar.delete(l);
82
+ },
83
+ () => sekarang,
84
+ () => sekarang,
85
+ );
86
+ }
87
+
88
+ /** Platform aktif di luar React. */
89
+ export function platformSekarang(): InfoPlatform {
90
+ return sekarang;
91
+ }
@@ -20,4 +20,5 @@ export * from './navigasi';
20
20
  export * from './aplikasi';
21
21
  export * from './log';
22
22
  export * from './pilih';
23
+ export * from './pemeliharaan';
23
24
  export * from './berkas';
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+ import * as React from 'react';
3
+ import { BannerPemeliharaan, ModePemeliharaan, type ModePemeliharaanProps } from '../components/pemeliharaan';
4
+ import { invalidateSiakad } from './provider';
5
+
6
+ export interface InfoPemeliharaan {
7
+ /** Pesan dari server (body galat 503). */
8
+ message?: string;
9
+ /** Perkiraan selesai, dari header Retry-After atau body `until`/`selesai`. */
10
+ until?: Date | null;
11
+ }
12
+
13
+ let sekarang: InfoPemeliharaan | null = null;
14
+ const pendengar = new Set<() => void>();
15
+
16
+ /** Tandai aplikasi sedang pemeliharaan (null = selesai). Dipanggil otomatis saat API membalas 503. */
17
+ export function laporPemeliharaan(info: InfoPemeliharaan | null) {
18
+ sekarang = info;
19
+ pendengar.forEach((l) => l());
20
+ }
21
+
22
+ /** Status pemeliharaan yang terdeteksi dari API. */
23
+ export function usePemeliharaan(): InfoPemeliharaan | null {
24
+ return React.useSyncExternalStore(
25
+ (l) => {
26
+ pendengar.add(l);
27
+ return () => void pendengar.delete(l);
28
+ },
29
+ () => sekarang,
30
+ () => null,
31
+ );
32
+ }
33
+
34
+ function selesaiDari(retryAfter: string | null, body: Record<string, unknown> | null): Date | null {
35
+ const b = (body?.error as Record<string, unknown> | undefined) ?? body ?? {};
36
+ const t = (b.until ?? b.selesai ?? (b.details as Record<string, unknown> | undefined)?.until) as string | undefined;
37
+ if (t) {
38
+ const d = new Date(String(t).replace(' ', 'T'));
39
+ if (!Number.isNaN(d.getTime())) return d;
40
+ }
41
+ if (retryAfter) {
42
+ const n = Number(retryAfter);
43
+ if (!Number.isNaN(n)) return new Date(Date.now() + n * 1000);
44
+ const d = new Date(retryAfter);
45
+ if (!Number.isNaN(d.getTime())) return d;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Bungkus `fetch` untuk SDK agar balasan 503 dari API menyalakan mode pemeliharaan di seluruh aplikasi:
52
+ * new Siakad({ baseUrl, accessToken, fetch: fetchPemeliharaan() })
53
+ */
54
+ export function fetchPemeliharaan(dasar: typeof fetch = (...a) => globalThis.fetch(...a)): typeof fetch {
55
+ return async (input, init) => {
56
+ const r = await dasar(input, init);
57
+ if (r.status === 503) {
58
+ let body: Record<string, unknown> | null = null;
59
+ try {
60
+ body = await r.clone().json();
61
+ } catch {
62
+ /* bukan JSON */
63
+ }
64
+ const pesan = ((body?.error as Record<string, unknown> | undefined)?.message ?? body?.message) as string | undefined;
65
+ laporPemeliharaan({ message: pesan, until: selesaiDari(r.headers.get('retry-after'), body) });
66
+ } else if (sekarang && r.ok) {
67
+ laporPemeliharaan(null);
68
+ }
69
+ return r;
70
+ };
71
+ }
72
+
73
+ export interface GerbangPemeliharaanProps extends Omit<ModePemeliharaanProps, 'until' | 'onRetry'> {
74
+ children: React.ReactNode;
75
+ /** Paksa mode pemeliharaan (mis. dari pengaturan aplikasi). */
76
+ active?: boolean;
77
+ /** Jadwal pemeliharaan: spanduk muncul sebelumnya, halaman pemeliharaan saat berjalan. */
78
+ schedule?: { start: Date | string; end?: Date | string | null; message?: React.ReactNode };
79
+ /** Periksa apakah layanan sudah pulih (bawaan: muat ulang data). */
80
+ onRetry?: () => void | Promise<void>;
81
+ }
82
+
83
+ /**
84
+ * Gerbang aplikasi: tampilkan halaman pemeliharaan saat `active`, saat jadwal berjalan, atau saat API membalas 503
85
+ * (pasang {@link fetchPemeliharaan} di SDK). Spanduk muncul sebelum jadwal.
86
+ */
87
+ export function GerbangPemeliharaan({ children, active, schedule, onRetry, message, ...rest }: GerbangPemeliharaanProps) {
88
+ const dariApi = usePemeliharaan();
89
+ const [kini, setKini] = React.useState(() => Date.now());
90
+ React.useEffect(() => {
91
+ if (!schedule) return;
92
+ const t = setInterval(() => setKini(Date.now()), 30_000);
93
+ return () => clearInterval(t);
94
+ }, [schedule]);
95
+ const mulai = schedule ? new Date(String(schedule.start).replace(' ', 'T')).getTime() : 0;
96
+ const akhir = schedule?.end ? new Date(String(schedule.end).replace(' ', 'T')).getTime() : Infinity;
97
+ const terjadwal = Boolean(schedule && kini >= mulai && kini < akhir);
98
+ if (active || terjadwal || dariApi) {
99
+ return (
100
+ <ModePemeliharaan
101
+ {...rest}
102
+ message={dariApi?.message ?? (terjadwal ? schedule?.message : undefined) ?? message}
103
+ until={dariApi?.until ?? (schedule?.end ? schedule.end : null)}
104
+ onRetry={async () => {
105
+ laporPemeliharaan(null);
106
+ invalidateSiakad('');
107
+ await onRetry?.();
108
+ }}
109
+ />
110
+ );
111
+ }
112
+ return (
113
+ <>
114
+ {schedule && <BannerPemeliharaan start={schedule.start} end={schedule.end} message={schedule.message} />}
115
+ {children}
116
+ </>
117
+ );
118
+ }
@@ -3,6 +3,7 @@ import * as React from 'react';
3
3
  import type { ApiResult, Meta, Siakad } from 'unipo-siakad-sdk';
4
4
  import { Toaster, TooltipProvider } from '../components/overlays';
5
5
  import { requestIdDari } from '../components/galat';
6
+ import { laporPemeliharaan } from './pemeliharaan';
6
7
 
7
8
  const SiakadContext = React.createContext<Siakad | null>(null);
8
9
 
@@ -129,6 +130,7 @@ export function useSiakadQuery<T>(key: ReadonlyArray<unknown>, fn: (siakad: Siak
129
130
  if (!ctrl.signal.aborted) setState({ result, loading: false, error: null });
130
131
  })
131
132
  .catch((e: Error) => {
133
+ if ((e as { status?: number })?.status === 503) laporPemeliharaan({ message: e.message, until: null });
132
134
  if (!ctrl.signal.aborted && e?.name !== 'AbortError') setState((s) => ({ ...s, loading: false, error: e }));
133
135
  });
134
136
  return () => ctrl.abort();