@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.
- package/LICENSE +16 -0
- package/README.md +122 -2
- package/package.json +44 -4
- package/src/components/ai/AppAssistantDraft.vue +120 -0
- package/src/components/ai/AppAssistantWidget.vue +245 -0
- package/src/components/ai/AppCitedAnswer.vue +86 -0
- package/src/components/approvals/AppApprovalInbox.vue +265 -0
- package/src/components/approvals/AppApprovalSubjectModal.vue +197 -0
- package/src/components/chat/AppChatBubble.vue +324 -0
- package/src/components/chat/AppChatList.vue +249 -0
- package/src/components/chat/AppChatPanel.vue +315 -0
- package/src/components/chat/AppChatWidget.vue +126 -0
- package/src/components/communications/AppChannelBadge.vue +47 -0
- package/src/components/communications/AppConversationThread.vue +439 -0
- package/src/components/customFields/CustomFieldFilterPanel.vue +50 -0
- package/src/components/customFields/CustomFieldInput.vue +422 -0
- package/src/components/customFields/CustomFieldValue.vue +129 -0
- package/src/components/customFields/CustomFieldsSection.vue +65 -0
- package/src/components/customFields/fieldTypes.js +23 -0
- package/src/components/dashboard/AppCustomWidget.vue +138 -0
- package/src/components/dashboard/AppCustomWidgetForm.vue +173 -0
- package/src/components/dashboard/AppDashboardGrid.vue +200 -0
- package/src/components/dashboard/AppDashboardToolbar.vue +66 -0
- package/src/components/dashboard/AppSubAppsCatalog.vue +89 -0
- package/src/components/dataTools/AppImportExport.vue +724 -0
- package/src/components/dataTools/AppImportSchemaGuide.vue +130 -0
- package/src/components/hrMetrics/AppHrMetricsDashboard.vue +231 -0
- package/src/components/hrMetrics/AppHrMetricsEmployees.vue +87 -0
- package/src/components/hrMetrics/AppHrMetricsStat.vue +61 -0
- package/src/components/kpi/AppKpiAppraisalConfig.vue +284 -0
- package/src/components/kpi/AppKpiAppraisals.vue +733 -0
- package/src/components/kpi/AppKpiDefinitionModal.vue +317 -0
- package/src/components/kpi/AppKpiEntryModal.vue +122 -0
- package/src/components/kpi/AppKpiLeaderboard.vue +108 -0
- package/src/components/kpi/AppKpiMyCard.vue +134 -0
- package/src/components/kpi/AppKpiPeriodPicker.vue +115 -0
- package/src/components/kpi/AppKpiReportTable.vue +214 -0
- package/src/components/kpi/AppKpiScorecard.vue +242 -0
- package/src/components/kpi/AppKpiTargetsModal.vue +299 -0
- package/src/components/kpi/AppKpiTeam.vue +221 -0
- package/src/components/kpi/AppKpiWeightProfiles.vue +244 -0
- package/src/components/kpi/AppKpiWorkspace.vue +479 -0
- package/src/components/layout/AppGlobalSearch.vue +149 -0
- package/src/components/layout/AppLanguagePopup.vue +94 -0
- package/src/components/layout/AppLauncherPopup.vue +147 -0
- package/src/components/layout/AppLegalConsentModal.vue +484 -0
- package/src/components/layout/AppNavbar.vue +402 -0
- package/src/components/layout/AppNotificationContent.vue +173 -0
- package/src/components/layout/AppNotificationDrawer.vue +98 -0
- package/src/components/layout/AppNotificationPopup.vue +67 -0
- package/src/components/layout/AppPlatformBanners.vue +192 -0
- package/src/components/layout/AppSidebar.vue +226 -0
- package/src/components/layout/AppSidebarNav.vue +119 -0
- package/src/components/layout/AppUserMenu.vue +89 -0
- package/src/components/layout/AppUserMenuContent.vue +59 -0
- package/src/composables/useKpi.js +232 -0
- package/src/composables/useLgViewport.js +25 -0
- package/src/composables/useNavigation.js +177 -0
- package/src/layouts/AppLayout.vue +207 -0
- package/src/locales/en.json +1362 -0
- package/src/locales/id.json +1396 -0
- package/src/pages/auth/AccountBlocked.vue +55 -0
- package/src/pages/auth/IdentityRedirect.vue +37 -0
- package/src/pages/profile/Layout.vue +210 -0
- package/src/pages/profile/partials/AccountSection.vue +139 -0
- package/src/pages/profile/partials/ApprovalDelegationSection.vue +389 -0
- package/src/pages/profile/partials/DevicesCard.vue +222 -0
- package/src/pages/profile/partials/PreferencesSection.vue +87 -0
- package/src/pages/profile/partials/SecuritySection.vue +93 -0
- package/src/pages/profile/partials/SessionsCard.vue +418 -0
- package/src/pages/profile/partials/SessionsSection.vue +58 -0
- package/src/pages/profile/partials/SupportAccessSection.vue +173 -0
- package/src/pages/profile/partials/TrustedDevicesCard.vue +141 -0
- package/src/pages/profile/partials/TwoFactorSection.vue +304 -0
- 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>
|