@initsolusidigital/worksuite-shell 0.0.0-stage → 0.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 (75) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +122 -2
  3. package/package.json +44 -4
  4. package/src/components/ai/AppAssistantDraft.vue +120 -0
  5. package/src/components/ai/AppAssistantWidget.vue +245 -0
  6. package/src/components/ai/AppCitedAnswer.vue +86 -0
  7. package/src/components/approvals/AppApprovalInbox.vue +265 -0
  8. package/src/components/approvals/AppApprovalSubjectModal.vue +197 -0
  9. package/src/components/chat/AppChatBubble.vue +324 -0
  10. package/src/components/chat/AppChatList.vue +249 -0
  11. package/src/components/chat/AppChatPanel.vue +315 -0
  12. package/src/components/chat/AppChatWidget.vue +126 -0
  13. package/src/components/communications/AppChannelBadge.vue +47 -0
  14. package/src/components/communications/AppConversationThread.vue +439 -0
  15. package/src/components/customFields/CustomFieldFilterPanel.vue +50 -0
  16. package/src/components/customFields/CustomFieldInput.vue +422 -0
  17. package/src/components/customFields/CustomFieldValue.vue +129 -0
  18. package/src/components/customFields/CustomFieldsSection.vue +65 -0
  19. package/src/components/customFields/fieldTypes.js +23 -0
  20. package/src/components/dashboard/AppCustomWidget.vue +138 -0
  21. package/src/components/dashboard/AppCustomWidgetForm.vue +173 -0
  22. package/src/components/dashboard/AppDashboardGrid.vue +200 -0
  23. package/src/components/dashboard/AppDashboardToolbar.vue +66 -0
  24. package/src/components/dashboard/AppSubAppsCatalog.vue +89 -0
  25. package/src/components/dataTools/AppImportExport.vue +724 -0
  26. package/src/components/dataTools/AppImportSchemaGuide.vue +130 -0
  27. package/src/components/hrMetrics/AppHrMetricsDashboard.vue +231 -0
  28. package/src/components/hrMetrics/AppHrMetricsEmployees.vue +87 -0
  29. package/src/components/hrMetrics/AppHrMetricsStat.vue +61 -0
  30. package/src/components/kpi/AppKpiAppraisalConfig.vue +284 -0
  31. package/src/components/kpi/AppKpiAppraisals.vue +733 -0
  32. package/src/components/kpi/AppKpiDefinitionModal.vue +317 -0
  33. package/src/components/kpi/AppKpiEntryModal.vue +122 -0
  34. package/src/components/kpi/AppKpiLeaderboard.vue +108 -0
  35. package/src/components/kpi/AppKpiMyCard.vue +134 -0
  36. package/src/components/kpi/AppKpiPeriodPicker.vue +115 -0
  37. package/src/components/kpi/AppKpiReportTable.vue +214 -0
  38. package/src/components/kpi/AppKpiScorecard.vue +242 -0
  39. package/src/components/kpi/AppKpiTargetsModal.vue +299 -0
  40. package/src/components/kpi/AppKpiTeam.vue +221 -0
  41. package/src/components/kpi/AppKpiWeightProfiles.vue +244 -0
  42. package/src/components/kpi/AppKpiWorkspace.vue +479 -0
  43. package/src/components/layout/AppGlobalSearch.vue +149 -0
  44. package/src/components/layout/AppLanguagePopup.vue +94 -0
  45. package/src/components/layout/AppLauncherPopup.vue +147 -0
  46. package/src/components/layout/AppLegalConsentModal.vue +484 -0
  47. package/src/components/layout/AppNavbar.vue +402 -0
  48. package/src/components/layout/AppNotificationContent.vue +173 -0
  49. package/src/components/layout/AppNotificationDrawer.vue +98 -0
  50. package/src/components/layout/AppNotificationPopup.vue +67 -0
  51. package/src/components/layout/AppPlatformBanners.vue +192 -0
  52. package/src/components/layout/AppSidebar.vue +226 -0
  53. package/src/components/layout/AppSidebarNav.vue +119 -0
  54. package/src/components/layout/AppUserMenu.vue +89 -0
  55. package/src/components/layout/AppUserMenuContent.vue +59 -0
  56. package/src/composables/useKpi.js +232 -0
  57. package/src/composables/useLgViewport.js +25 -0
  58. package/src/composables/useNavigation.js +177 -0
  59. package/src/layouts/AppLayout.vue +207 -0
  60. package/src/locales/en.json +1362 -0
  61. package/src/locales/id.json +1396 -0
  62. package/src/pages/auth/AccountBlocked.vue +55 -0
  63. package/src/pages/auth/IdentityRedirect.vue +37 -0
  64. package/src/pages/profile/Layout.vue +210 -0
  65. package/src/pages/profile/partials/AccountSection.vue +139 -0
  66. package/src/pages/profile/partials/ApprovalDelegationSection.vue +389 -0
  67. package/src/pages/profile/partials/DevicesCard.vue +222 -0
  68. package/src/pages/profile/partials/PreferencesSection.vue +87 -0
  69. package/src/pages/profile/partials/SecuritySection.vue +93 -0
  70. package/src/pages/profile/partials/SessionsCard.vue +418 -0
  71. package/src/pages/profile/partials/SessionsSection.vue +58 -0
  72. package/src/pages/profile/partials/SupportAccessSection.vue +173 -0
  73. package/src/pages/profile/partials/TrustedDevicesCard.vue +141 -0
  74. package/src/pages/profile/partials/TwoFactorSection.vue +304 -0
  75. package/src/shellSetup.js +46 -0
@@ -0,0 +1,484 @@
1
+ <script setup>
2
+ // Modal persetujuan naskah hukum. Kontrak: `legal-consent.md` §2–§4.
3
+ //
4
+ // Dipasang SEKALI di `App.vue`, bukan di `AppLayout`: layar tanpa `AppLayout` (editor penuh layar)
5
+ // juga menolak tulis selama naskah belum disetujui, dan tanpa modal di sana tombol Simpan terasa
6
+ // mati tanpa sebab.
7
+ //
8
+ // Yang memutuskan tampil-tidaknya hanya server: `legal.must_accept` dari `/auth/me`, atau 403
9
+ // `legal_acceptance_required` yang dicatat `api/legalGate.js`. Versi tak pernah dibandingkan di
10
+ // sini. Isi (versi + alamat) hanya dari `legal.documents`; ⚠️ modal ini TIDAK boleh memakai
11
+ // `utils/legalLinks.js`: persetujuan yang tercatat harus menunjuk versi yang dicatat backend, dan
12
+ // alamat cadangan bisa menautkan naskah yang berbeda dari yang dicatat.
13
+ //
14
+ // Bukan `AppModal`: komponen itu selalu punya tombol tutup dan menutup lewat Escape, sedangkan
15
+ // modal ini hanya selesai lewat Setuju, Tolak, atau Keluar.
16
+ import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
17
+ import { useI18n } from 'vue-i18n'
18
+ import { useRoute, useRouter } from 'vue-router'
19
+ import { ExternalLink, LogOut, RefreshCw, ScrollText } from '@lucide/vue'
20
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
21
+ import { legalApi } from '@initsolusidigital/worksuite-core/api/endpoints/legal.js'
22
+ import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
23
+ import { legalGateState as gate, setLegalRefresher, refreshLegal, clearLegalGate } from '@initsolusidigital/worksuite-core/api/legalGate.js'
24
+ import { maintenanceState } from '@initsolusidigital/worksuite-core/api/maintenance.js'
25
+ import { usePreferences } from '@initsolusidigital/worksuite-core/composables/usePreferences.js'
26
+ import { redirectToIdentityLogin } from '@initsolusidigital/worksuite-core/utils/loginRedirect.js'
27
+ import { parseDateOnly } from '@initsolusidigital/worksuite-core/utils/dateOnly.js'
28
+ import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
29
+ import AppSpinner from '@initsolusidigital/worksuite-ui/AppSpinner.vue'
30
+
31
+ const { t, locale } = useI18n()
32
+ const route = useRoute()
33
+ const router = useRouter()
34
+ const auth = useAuthStore()
35
+ const { applyLocale, save: savePreferences } = usePreferences()
36
+
37
+ const DOCS = ['terms', 'privacy']
38
+ const titleId = useId()
39
+ const bodyId = useId()
40
+
41
+ // ── Kapan modal tampil ────────────────────────────────────────────────────────────────────────
42
+ // Rute tanpa modal: halaman publik dan pintu pelanggan Client Zone (pelanggan portal bukan pihak
43
+ // naskah ini), serta rute ber-`meta.legalGateExempt`. Nilai `'systemOwner'` mengecualikan System
44
+ // Owner saja: endpoint setelan naskah lolos dari gerbang server supaya sakelarnya selalu bisa
45
+ // dimatikan, dan modal yang menutupi layar setelan itu membatalkan kelonggaran tersebut.
46
+ const exemptRoute = computed(() => route.matched.some(
47
+ (r) => r.meta.public || r.meta.portalGuest || r.meta.requiresPortalAuth || r.meta.legalGateExempt === true,
48
+ ))
49
+ const ownerExemptRoute = computed(() => auth.isSystemOwner
50
+ && route.matched.some((r) => r.meta.legalGateExempt === 'systemOwner'))
51
+
52
+ const needed = computed(() => auth.isAuthenticated && (auth.legalMustAccept || gate.required))
53
+ // Akun karyawan nonaktif hanya bisa keluar (halaman blokir); meminta persetujuannya tak berguna,
54
+ // dan Tolak dari sana mengirim notifikasi ke admin untuk akun yang memang sudah ditutup.
55
+ const suppressed = computed(() => auth.isBlocked || exemptRoute.value || (ownerExemptRoute.value && !gate.forceShow))
56
+ const visible = computed(() => needed.value && !suppressed.value)
57
+
58
+ // `flush: 'sync'`: `useToast` membaca dua bendera ini pada saat `catch` pemanggil berjalan, yaitu
59
+ // sebelum Vue sempat memperbarui tampilan.
60
+ watch(suppressed, (v) => { gate.suppressed = v }, { immediate: true, flush: 'sync' })
61
+ watch(visible, (v) => { gate.shown = v }, { immediate: true, flush: 'sync' })
62
+ watch(() => auth.isAuthenticated, (ok) => { if (!ok) clearLegalGate() })
63
+ // Permintaan membuka modal di rute yang dikecualikan berlaku untuk kunjungan itu saja: kembali ke
64
+ // rute tersebut harus kembali tanpa modal, sebab pengecualiannya ada supaya layarnya terjangkau.
65
+ watch(() => route.fullPath, () => { gate.forceShow = false })
66
+
67
+ // ── Isi ───────────────────────────────────────────────────────────────────────────────────────
68
+ const legal = computed(() => auth.legal)
69
+ const documents = computed(() => legal.value?.documents ?? {})
70
+ const ready = computed(() => DOCS.every((d) => documents.value[d]?.version))
71
+ // Daftar tertunda bisa kosong sesaat: 403 datang lebih dulu daripada `/auth/me` yang dimuat ulang.
72
+ // Selama itu kedua naskah ditampilkan; server tetap menolak versi yang bukan versi berlaku.
73
+ const pending = computed(() => {
74
+ const list = (legal.value?.pending ?? []).filter((d) => DOCS.includes(d))
75
+ return DOCS.filter((d) => (list.length ? list.includes(d) : true))
76
+ })
77
+ const onBehalf = computed(() => legal.value?.on_behalf_of_company === true)
78
+ const isUpdate = computed(() => DOCS.some((d) => legal.value?.accepted?.[d]))
79
+ const linkLang = computed(() => (String(locale.value).toLowerCase().startsWith('id') ? 'id' : 'en'))
80
+
81
+ function docUrl(doc) {
82
+ const url = documents.value[doc]?.urls?.[linkLang.value]
83
+ return typeof url === 'string' && url.startsWith('https://') ? url : null
84
+ }
85
+
86
+ function docDate(doc) {
87
+ const d = parseDateOnly(documents.value[doc]?.effective_date)
88
+ if (!d) return ''
89
+ return new Intl.DateTimeFormat(linkLang.value === 'id' ? 'id-ID' : 'en-US', { dateStyle: 'long' }).format(d)
90
+ }
91
+
92
+ const agreeLabel = computed(() => (pending.value.length === 1
93
+ ? t('legal.modal.agreeOne', { doc: t(`legal.doc.${pending.value[0]}`) })
94
+ : t('legal.modal.agreeBoth')))
95
+
96
+ // ── Keadaan form ──────────────────────────────────────────────────────────────────────────────
97
+ const step = ref('main') // main | decline
98
+ const agreed = ref(false)
99
+ const authority = ref(false)
100
+ const busy = ref('') // '' | accept | decline | logout
101
+ const error = ref('')
102
+ const notice = ref('')
103
+ // Bahasa yang dipilih DI DALAM modal. Tidak disimpan ke server selama naskah belum disetujui
104
+ // (simpan preferensi ikut ditolak gerbang); baru disimpan sesudah persetujuan tercatat.
105
+ let chosenLocale = null
106
+
107
+ const canAccept = computed(() => ready.value && agreed.value && (!onBehalf.value || authority.value) && !busy.value)
108
+
109
+ function resetForm() {
110
+ step.value = 'main'
111
+ agreed.value = false
112
+ authority.value = false
113
+ error.value = ''
114
+ notice.value = ''
115
+ }
116
+
117
+ function setLanguage(loc) {
118
+ chosenLocale = loc
119
+ applyLocale(loc)
120
+ }
121
+
122
+ function messageFor(e) {
123
+ if (e.isMaintenance) return t('maintenance.unsaved')
124
+ if (e.isNetwork) return t('common.networkError')
125
+ if (e.isRateLimited) return e.retryAfter ? t('common.rateLimited', { seconds: e.retryAfter }) : t('common.rateLimitedNoWait')
126
+ if (e.isImpersonationRestricted) return t('legal.modal.impersonation')
127
+ if (e.isServer && e.errorId) return t('common.serverErrorWithId', { id: e.errorId })
128
+ return t('common.serverError')
129
+ }
130
+
131
+ async function accept() {
132
+ if (!canAccept.value) return
133
+ busy.value = 'accept'
134
+ error.value = ''
135
+ notice.value = ''
136
+ try {
137
+ const { data } = await legalApi.accept({
138
+ terms_version: documents.value.terms.version,
139
+ privacy_version: documents.value.privacy.version,
140
+ ...(onBehalf.value ? { on_behalf_of_company: true } : {}),
141
+ locale: linkLang.value,
142
+ })
143
+ const next = data?.data?.legal
144
+ if (next) auth.setLegal(next)
145
+ else await auth.fetchLegal()
146
+ clearLegalGate()
147
+ if (chosenLocale && chosenLocale !== auth.user?.preferences?.locale) savePreferences({ locale: chosenLocale })
148
+ chosenLocale = null
149
+ } catch (err) {
150
+ const e = normalizeApiError(err)
151
+ if (e.status === 409 && e.reason === 'legal_version_outdated') {
152
+ // Versi naik sejak modal dibuka: naskah terbaru dipasang, centang diminta ulang. Blok lain
153
+ // (`pending`, `on_behalf_of_company`) menyusul dari `/auth/me`.
154
+ if (e.data.documents) auth.setLegal({ ...(legal.value ?? {}), documents: e.data.documents })
155
+ agreed.value = false
156
+ authority.value = false
157
+ notice.value = t('legal.modal.outdated')
158
+ refreshLegal()
159
+ } else if (e.reason === 'authority_confirmation_required') {
160
+ error.value = t('legal.modal.authorityRequired')
161
+ } else {
162
+ error.value = messageFor(e)
163
+ }
164
+ } finally {
165
+ busy.value = ''
166
+ }
167
+ }
168
+
169
+ // Sesi sudah berakhir di server; yang tersisa membersihkan state lalu pindah ke pintu login.
170
+ function leave() {
171
+ auth.clearUser()
172
+ clearLegalGate()
173
+ if (!redirectToIdentityLogin()) router.push({ name: 'login' }).catch(() => {})
174
+ }
175
+
176
+ async function decline() {
177
+ if (busy.value) return
178
+ busy.value = 'decline'
179
+ error.value = ''
180
+ try {
181
+ await legalApi.decline()
182
+ } catch (err) {
183
+ const e = normalizeApiError(err)
184
+ busy.value = ''
185
+ if (e.status === 401) return // interceptor sudah membawa pengguna ke pintu login
186
+ if (e.status === 409 && e.reason === 'legal_nothing_pending') {
187
+ // Sudah disetujui di tab lain: tak ada yang ditolak, modal menutup begitu keadaan dimuat ulang.
188
+ step.value = 'main'
189
+ await refreshLegal()
190
+ return
191
+ }
192
+ error.value = messageFor(e)
193
+ return
194
+ }
195
+ leave()
196
+ }
197
+
198
+ // Keluar TANPA mencatat penolakan: `POST /logout` biasa, yang memang lolos dari gerbang.
199
+ async function logout() {
200
+ if (busy.value) return
201
+ busy.value = 'logout'
202
+ try {
203
+ await auth.logout()
204
+ } catch { /* state lokal tetap dibersihkan `logout()` */ }
205
+ leave()
206
+ }
207
+
208
+ // ── Pemuat ulang `/auth/me` ───────────────────────────────────────────────────────────────────
209
+ // Hanya blok `legal` yang dimuat ulang (`fetchLegal`), bukan seluruh `/auth/me` ke store: mengganti
210
+ // `user` menjalankan ulang apa pun yang mengikutinya di balik modal (lihat `stores/auth.js`).
211
+ setLegalRefresher(async () => {
212
+ try {
213
+ await auth.fetchLegal()
214
+ } catch (err) {
215
+ if (err?.response?.status === 401) { leave(); return }
216
+ throw err
217
+ }
218
+ if (!auth.legalMustAccept) clearLegalGate()
219
+ })
220
+
221
+ function retryLoad() {
222
+ refreshLegal()
223
+ }
224
+
225
+ // Persetujuan di satu tab harus menutup modal di tab lain: selama modal tampil, keadaan dimuat
226
+ // ulang tiap tab ini kembali aktif.
227
+ function onVisibility() {
228
+ if (document.visibilityState === 'visible' && visible.value && !busy.value) refreshLegal()
229
+ }
230
+
231
+ // ── Kunci halaman, fokus, papan tik ───────────────────────────────────────────────────────────
232
+ const rootRef = ref(null)
233
+ const panelRef = ref(null)
234
+ const modalStack = (window.__wsModalStack ||= [])
235
+ const stackToken = Symbol('legal-consent')
236
+ let inerted = []
237
+ let observer = null
238
+ let returnFocusTo = null
239
+
240
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
241
+
242
+ // Halaman di balik modal dibuat `inert`: tetap terpasang (isian form utuh), tapi tak bisa difokus
243
+ // atau diklik. Elemen yang sudah `inert` oleh layar pemeliharaan dilewati, dan inangnya sendiri
244
+ // tak disentuh: layar pemeliharaan harus tetap bisa dipakai di atas modal ini.
245
+ function lockPage() {
246
+ for (const el of document.body.children) {
247
+ if (el === rootRef.value || el.hasAttribute('data-maintenance-host') || el.inert) continue
248
+ el.inert = true
249
+ inerted.push(el)
250
+ }
251
+ }
252
+
253
+ function unlockPage() {
254
+ for (const el of inerted) el.inert = false
255
+ inerted = []
256
+ }
257
+
258
+ function onKeydown(e) {
259
+ if (!visible.value) return
260
+ if (e.key === 'Escape') {
261
+ // Escape tak menutup modal ini, dan tak boleh sampai ke dialog di baliknya: menutup form yang
262
+ // sedang diisi pengguna membuang isiannya.
263
+ e.preventDefault()
264
+ e.stopPropagation()
265
+ if (step.value === 'decline' && !busy.value) step.value = 'main'
266
+ return
267
+ }
268
+ if (e.key !== 'Tab' || !panelRef.value) return
269
+ const els = [...panelRef.value.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null)
270
+ if (!els.length) return
271
+ const first = els[0]
272
+ const last = els[els.length - 1]
273
+ const inside = panelRef.value.contains(document.activeElement)
274
+ if (e.shiftKey && (document.activeElement === first || !inside)) {
275
+ e.preventDefault(); last.focus()
276
+ } else if (!e.shiftKey && (document.activeElement === last || !inside)) {
277
+ e.preventDefault(); first.focus()
278
+ }
279
+ }
280
+
281
+ function onShow() {
282
+ resetForm()
283
+ returnFocusTo = document.activeElement
284
+ // Dialog lain membaca tumpukan ini untuk memutuskan siapa yang menanggapi Escape; selama token
285
+ // modal ini di puncak, dialog di baliknya diam.
286
+ if (!modalStack.includes(stackToken)) modalStack.push(stackToken)
287
+ lockPage()
288
+ // Elemen yang ditambahkan ke `<body>` sesudah modal tampil (dialog yang baru di-teleport) ikut dikunci.
289
+ observer = new MutationObserver(lockPage)
290
+ observer.observe(document.body, { childList: true })
291
+ document.addEventListener('keydown', onKeydown, true)
292
+ document.addEventListener('visibilitychange', onVisibility)
293
+ panelRef.value?.focus()
294
+ // Naskah belum ada di store (403 mendahului `/auth/me`) dan tak ada pemuatan yang berjalan.
295
+ if (!ready.value && !gate.loading) refreshLegal()
296
+ }
297
+
298
+ function onHide() {
299
+ observer?.disconnect()
300
+ observer = null
301
+ document.removeEventListener('keydown', onKeydown, true)
302
+ document.removeEventListener('visibilitychange', onVisibility)
303
+ const i = modalStack.indexOf(stackToken)
304
+ if (i !== -1) modalStack.splice(i, 1)
305
+ unlockPage()
306
+ if (returnFocusTo?.isConnected) returnFocusTo.focus?.()
307
+ returnFocusTo = null
308
+ }
309
+
310
+ // `flush: 'post'`: `onShow` memakai elemen modal, yang baru ada sesudah tampilan diperbarui.
311
+ watch(visible, (v) => (v ? onShow() : onHide()), { flush: 'post' })
312
+ // Layar pemeliharaan membuka kembali elemen yang ia kunci; selama modal ini masih tampil, elemen
313
+ // itu dikunci lagi.
314
+ watch(() => maintenanceState.active, (active) => { if (!active && visible.value) lockPage() })
315
+
316
+ onMounted(() => { if (visible.value) onShow() })
317
+ onBeforeUnmount(() => {
318
+ onHide()
319
+ gate.shown = false
320
+ })
321
+ </script>
322
+
323
+ <template>
324
+ <Teleport to="body">
325
+ <div
326
+ v-if="visible"
327
+ ref="rootRef"
328
+ class="fixed inset-0 z-[900] flex items-center justify-center overflow-y-auto p-4"
329
+ data-legal-consent
330
+ >
331
+ <div class="fixed inset-0 bg-gray-900/60 backdrop-blur-sm" aria-hidden="true"></div>
332
+
333
+ <div
334
+ ref="panelRef"
335
+ role="dialog"
336
+ aria-modal="true"
337
+ tabindex="-1"
338
+ :aria-labelledby="titleId"
339
+ :aria-describedby="bodyId"
340
+ class="relative flex max-h-[calc(100dvh-2rem)] w-full max-w-lg flex-col rounded-2xl bg-white shadow-2xl focus:outline-none dark:bg-gray-800"
341
+ >
342
+ <!-- Kepala: judul + pemilih bahasa (hanya mengubah tampilan, tidak menyimpan preferensi) -->
343
+ <div class="flex shrink-0 items-start gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-700 sm:px-6">
344
+ <div class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300" aria-hidden="true">
345
+ <ScrollText class="h-5 w-5" />
346
+ </div>
347
+ <h2 :id="titleId" class="min-w-0 flex-1 pt-1.5 text-base font-semibold text-gray-900 dark:text-white [text-wrap:balance]">
348
+ {{ step === 'decline' ? t('legal.modal.declineTitle') : t('legal.modal.title') }}
349
+ </h2>
350
+ <div class="flex shrink-0 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-600" role="group" :aria-label="t('legal.modal.language')">
351
+ <button
352
+ v-for="loc in ['id', 'en']"
353
+ :key="loc"
354
+ type="button"
355
+ class="px-2.5 py-1.5 text-xs font-semibold uppercase transition-colors"
356
+ :class="linkLang === loc
357
+ ? 'bg-blue-600 text-white'
358
+ : 'bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'"
359
+ :aria-pressed="linkLang === loc"
360
+ :aria-label="t(`lang.${loc}`)"
361
+ :data-legal-lang="loc"
362
+ @click="setLanguage(loc)"
363
+ >{{ loc }}</button>
364
+ </div>
365
+ </div>
366
+
367
+ <div :id="bodyId" class="scrollbar-rounded flex-1 overflow-y-auto px-5 py-5 sm:px-6">
368
+ <!-- Konfirmasi penolakan -->
369
+ <div v-if="step === 'decline'" data-legal-decline-confirm>
370
+ <p class="text-sm leading-relaxed text-gray-700 dark:text-gray-300">
371
+ {{ onBehalf ? t('legal.modal.declineBodyCompany', { company: auth.companyName }) : t('legal.modal.declineBody') }}
372
+ </p>
373
+ </div>
374
+
375
+ <!-- Naskah belum ada di store: tunggu atau coba lagi. Tanpa alamat cadangan. -->
376
+ <div v-else-if="!ready" class="py-6 text-center" data-legal-pending-load>
377
+ <template v-if="gate.loading">
378
+ <AppSpinner class="mx-auto" />
379
+ <p class="mt-3 text-sm text-gray-500 dark:text-gray-400">{{ t('legal.modal.loading') }}</p>
380
+ </template>
381
+ <template v-else>
382
+ <p class="text-sm text-gray-700 dark:text-gray-300">{{ t('legal.modal.loadFailed') }}</p>
383
+ <AppButton class="mt-4" variant="secondary" data-legal-retry @click="retryLoad">
384
+ <RefreshCw class="h-4 w-4" aria-hidden="true" />{{ t('common.retry') }}
385
+ </AppButton>
386
+ </template>
387
+ </div>
388
+
389
+ <div v-else class="space-y-4">
390
+ <p class="text-sm leading-relaxed text-gray-700 dark:text-gray-300">
391
+ {{ isUpdate ? t('legal.modal.introUpdated') : t('legal.modal.introFirst') }}
392
+ </p>
393
+
394
+ <ul class="divide-y divide-gray-100 rounded-xl border border-gray-200 dark:divide-gray-700 dark:border-gray-700">
395
+ <li v-for="doc in pending" :key="doc" class="px-4 py-3" :data-legal-doc="doc">
396
+ <a
397
+ v-if="docUrl(doc)"
398
+ :href="docUrl(doc)"
399
+ target="_blank"
400
+ rel="noopener noreferrer"
401
+ class="inline-flex items-center gap-1.5 text-sm font-semibold text-blue-700 hover:underline dark:text-blue-300"
402
+ :aria-label="t('legal.modal.openDoc', { doc: t(`legal.doc.${doc}`) })"
403
+ >
404
+ {{ t(`legal.doc.${doc}`) }}
405
+ <ExternalLink class="h-3.5 w-3.5" aria-hidden="true" />
406
+ </a>
407
+ <span v-else class="text-sm font-semibold text-gray-900 dark:text-white">{{ t(`legal.doc.${doc}`) }}</span>
408
+ <p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
409
+ {{ t('legal.modal.docMeta', { version: documents[doc].version, date: docDate(doc) }) }}
410
+ </p>
411
+ </li>
412
+ </ul>
413
+
414
+ <p v-if="notice" role="status" class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-900/20 dark:text-amber-200" data-legal-notice>
415
+ {{ notice }}
416
+ </p>
417
+
418
+ <label class="flex cursor-pointer items-start gap-3">
419
+ <input
420
+ v-model="agreed"
421
+ type="checkbox"
422
+ class="mt-0.5 h-4 w-4 shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
423
+ data-legal-agree
424
+ />
425
+ <span class="text-sm text-gray-800 dark:text-gray-200">{{ agreeLabel }}</span>
426
+ </label>
427
+
428
+ <!-- Bagian A Ketentuan Layanan belum disetujui perusahaan, dan akun ini admin utamanya:
429
+ satu aksi mencatat persetujuan perusahaan + persetujuan akun. -->
430
+ <div v-if="onBehalf" class="space-y-3 rounded-xl bg-gray-50 px-4 py-3 dark:bg-gray-900/40">
431
+ <p class="text-xs leading-relaxed text-gray-600 dark:text-gray-400">
432
+ {{ t('legal.modal.authorityNote', { company: auth.companyName }) }}
433
+ </p>
434
+ <label class="flex cursor-pointer items-start gap-3">
435
+ <input
436
+ v-model="authority"
437
+ type="checkbox"
438
+ class="mt-0.5 h-4 w-4 shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
439
+ data-legal-authority
440
+ />
441
+ <span class="text-sm text-gray-800 dark:text-gray-200">{{ t('legal.modal.authority', { company: auth.companyName }) }}</span>
442
+ </label>
443
+ </div>
444
+ </div>
445
+
446
+ <p v-if="error" role="alert" class="mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-300" data-legal-error>
447
+ {{ error }}
448
+ </p>
449
+ </div>
450
+
451
+ <div class="shrink-0 border-t border-gray-200 px-5 py-4 dark:border-gray-700 sm:px-6">
452
+ <div v-if="step === 'decline'" class="flex items-center justify-end gap-2">
453
+ <AppButton variant="secondary" :disabled="!!busy" @click="step = 'main'">{{ t('legal.modal.declineBack') }}</AppButton>
454
+ <AppButton variant="danger" :loading="busy === 'decline'" data-legal-decline-confirm-button @click="decline">
455
+ {{ t('legal.modal.declineConfirm') }}
456
+ </AppButton>
457
+ </div>
458
+ <div v-else class="flex items-center justify-end gap-2">
459
+ <AppButton variant="secondary" :disabled="!!busy" data-legal-decline @click="step = 'decline'; error = ''">
460
+ {{ t('legal.modal.decline') }}
461
+ </AppButton>
462
+ <AppButton variant="primary" :disabled="!canAccept" :loading="busy === 'accept'" data-legal-accept @click="accept">
463
+ {{ t('legal.modal.accept') }}
464
+ </AppButton>
465
+ </div>
466
+
467
+ <!-- Keluar tanpa memutuskan: bukan penolakan, tak ada yang diberi tahu. -->
468
+ <div v-if="step === 'main'" class="mt-3 border-t border-gray-100 pt-3 text-center dark:border-gray-700">
469
+ <button
470
+ type="button"
471
+ class="inline-flex items-center gap-1.5 rounded text-sm font-medium text-gray-600 hover:text-gray-900 hover:underline disabled:opacity-50 dark:text-gray-300 dark:hover:text-white"
472
+ :disabled="!!busy"
473
+ data-legal-logout
474
+ @click="logout"
475
+ >
476
+ <LogOut class="h-4 w-4" aria-hidden="true" />{{ t('legal.modal.logout') }}
477
+ </button>
478
+ <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('legal.modal.logoutHint') }}</p>
479
+ </div>
480
+ </div>
481
+ </div>
482
+ </div>
483
+ </Teleport>
484
+ </template>