@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,177 @@
1
+ import { computed, unref } from 'vue'
2
+ import { useI18n } from 'vue-i18n'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import { getNavigation, getNavigationBadges } from '../shellSetup.js'
5
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
6
+ import { useModulePermission } from '@initsolusidigital/worksuite-core/composables/useModulePermission.js'
7
+
8
+ /**
9
+ * Boleh tampil? — `moduleSlug` (satu) atau `moduleSlugs` (beberapa, cukup SALAH SATU diizinkan).
10
+ *
11
+ * **Tanpa slug = selalu tampil.** Itu yang membuat filter ini aditif murni: `data/navigation.js`
12
+ * yang belum menandai slug apa pun tetap menampilkan semua itemnya, jadi app bisa memakai filter
13
+ * ini tanpa menyentuh data nav mereka lebih dulu.
14
+ *
15
+ * `moduleFlag` menyamakan bahasa dengan router guard yang menerimanya lewat
16
+ * `meta.moduleFlag`. Tanpa itu, halaman yang gerbangnya BUKAN `can_read` — mis. Langganan Saya
17
+ * (`companies,can_configure`) — tetap tampil di menu untuk user yang cuma bisa membaca, lalu
18
+ * dijawab 403 oleh API. Menu dan guard yang memakai ukuran berbeda untuk halaman yang sama
19
+ * adalah cara paling mudah melanggar aturan "yang tidak diizinkan DISEMBUNYIKAN".
20
+ * Absen = `can_read`, jadi tetap aditif murni.
21
+ *
22
+ * Tiap entri `moduleSlugs` boleh membawa flag-nya SENDIRI (`{ slug, flag }`) di samping bentuk
23
+ * string biasa — perlu untuk menu umbrella yang menampung tab ber-flag campur (daftar `can_read`
24
+ * + template/pengaturan `can_configure` atas slug yang berbeda). `moduleFlag` tetap jadi default
25
+ * bagi entri yang tak menyebut flag. Semantik & alasan lengkapnya di `canAny()`.
26
+ */
27
+ function isAllowed(canAny, item) {
28
+ const entries = item.moduleSlugs ?? (item.moduleSlug ? [item.moduleSlug] : [])
29
+ return canAny(entries, item.moduleFlag ?? 'can_read')
30
+ }
31
+
32
+ // Bentuk lama (array item datar) dibungkus jadi satu grup tanpa label, jadi `navigation.js` yang
33
+ // belum dikelompokkan tetap benar sesudah shell ini ditarik.
34
+ function normalizeGroups(data) {
35
+ return Array.isArray(data[0]?.items) ? data : [{ items: data }]
36
+ }
37
+
38
+ // `<router-link>` meresolusi tujuan saat RENDER: rute yang tak terdaftar atau kekurangan param
39
+ // melempar dan satu item rusak menggugurkan SELURUH sidebar. Item seperti itu tak punya tujuan
40
+ // yang bisa dijanjikan menu, jadi dibuang di sini.
41
+ function isResolvable(router, to) {
42
+ try {
43
+ router.resolve(to)
44
+ return true
45
+ } catch {
46
+ return false
47
+ }
48
+ }
49
+
50
+ function selfCompanyRoute(companyId) {
51
+ return { name: 'companies.profile', params: { companyId } }
52
+ }
53
+
54
+ function isOwnCompanyRoute(route, companyId) {
55
+ return !!companyId && !!route.name?.toString().startsWith('companies.')
56
+ && String(route.params.companyId) === String(companyId)
57
+ }
58
+
59
+ // Skor kecocokan rute aktif — dipakai memilih SATU pemenang di seluruh menu. Pencocokan awalan saja
60
+ // menyalakan dua item sekaligus begitu saudaranya berbagi awalan nama: `approvals` ikut menyala di
61
+ // halaman `approvals.flows`. Nama persis selalu menang, sisanya awalan terpanjang.
62
+ function matchScore(route, item, companyId) {
63
+ if (item.selfCompany) return isOwnCompanyRoute(route, companyId) ? Number.MAX_SAFE_INTEGER - 1 : -1
64
+ if (!item.name) return -1
65
+ const name = route.name?.toString() ?? ''
66
+ if (name === item.name) return Number.MAX_SAFE_INTEGER
67
+ const prefix = item.activePrefix ?? item.name
68
+ return name.startsWith(`${prefix}.`) ? prefix.length : -1
69
+ }
70
+
71
+ /**
72
+ * Menu sidebar: `{ groups, bottomGroups }`. Satu-satunya pemanggil seharusnya `AppSidebar`, sebab
73
+ * pabrik badge dijalankan per panggilan dan boleh memicu pengambilan data.
74
+ */
75
+ export function useNavigation() {
76
+ const { t } = useI18n()
77
+ const route = useRoute()
78
+ const router = useRouter()
79
+ const authStore = useAuthStore()
80
+ const { canAny } = useModulePermission()
81
+
82
+ const badges = Object.fromEntries(Object.entries(getNavigationBadges()).map(([key, factory]) => [key, factory()]))
83
+
84
+ // Peran dibaca di sini, bukan di komponen, supaya grup yang seluruh itemnya khusus System Owner
85
+ // ikut hilang bagi user tenant tanpa kode khusus di renderer.
86
+ //
87
+ // ⚠️ Filter izin nyaris mustahil ketahuan bila keliru: gejalanya hanya muncul pada role dengan izin
88
+ // TERBATAS, sedangkan pengembangan hampir selalu memakai super admin — dan super admin di-bypass
89
+ // `can()`. Jadi menunya terlihat benar sepanjang pengembangan.
90
+ function isVisible(item) {
91
+ if (item.systemOwnerOnly && !authStore.isSystemOwner) return false
92
+ if (item.userOnly && authStore.isSystemOwner) return false
93
+ // System Owner membuka perusahaannya lewat ikon di navbar (`useSelfCompanyItem`).
94
+ if (item.selfCompany && authStore.isSystemOwner) return false
95
+ return isAllowed(canAny, item)
96
+ }
97
+
98
+ function toOf(item) {
99
+ if (item.selfCompany) return selfCompanyRoute(authStore.user?.company_id)
100
+ return item.name ? { name: item.name } : null
101
+ }
102
+
103
+ function resolveItem(item) {
104
+ if (!isVisible(item)) return null
105
+ if (item.children) {
106
+ // Induk yang SEMUA anaknya tersaring habis ikut disembunyikan — akordeon kosong lebih
107
+ // membingungkan daripada tak ada menu sama sekali.
108
+ const children = item.children.map(resolveItem).filter(Boolean)
109
+ return children.length ? { ...item, label: t(item.labelKey), children } : null
110
+ }
111
+ const to = toOf(item)
112
+ if (!to || !isResolvable(router, to)) return null
113
+ return {
114
+ ...item,
115
+ label: t(item.labelKey),
116
+ to,
117
+ badge: item.badgeKey ? (unref(badges[item.badgeKey]) ?? 0) : 0,
118
+ }
119
+ }
120
+
121
+ const resolved = computed(() => {
122
+ const groups = normalizeGroups(getNavigation())
123
+ .map((group, index) => ({
124
+ key: group.labelKey ?? `group-${index}`,
125
+ label: group.labelKey ? t(group.labelKey) : '',
126
+ position: group.position === 'bottom' ? 'bottom' : 'top',
127
+ items: group.items.map(resolveItem).filter(Boolean),
128
+ }))
129
+ .filter(group => group.items.length)
130
+
131
+ const leaves = groups.flatMap(group => group.items.flatMap(item => item.children ?? [item]))
132
+ const companyId = authStore.user?.company_id
133
+ let winner = null
134
+ let best = -1
135
+ for (const leaf of leaves) {
136
+ const score = matchScore(route, leaf, companyId)
137
+ if (score > best) { best = score; winner = leaf }
138
+ }
139
+ const markActive = (item) => {
140
+ if (!item.children) return { ...item, active: item === winner }
141
+ const children = item.children.map(markActive)
142
+ return { ...item, children, active: false, childActive: children.some(child => child.active) }
143
+ }
144
+ return groups.map(group => ({ ...group, items: group.items.map(markActive) }))
145
+ })
146
+
147
+ return {
148
+ groups: computed(() => resolved.value.filter(group => group.position === 'top')),
149
+ bottomGroups: computed(() => resolved.value.filter(group => group.position === 'bottom')),
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Ikon Perusahaan Saya di navbar — hanya System Owner; user tenant memilikinya sebagai item sidebar.
155
+ * Terpisah dari `useNavigation` supaya navbar tak ikut menjalankan pabrik badge.
156
+ *
157
+ * ⚠️ `company_id` kosong (sedang logout) → `null`: `<router-link>` melempar "Missing required param"
158
+ * saat render, tepat ketika `user` dikosongkan.
159
+ */
160
+ export function useSelfCompanyItem() {
161
+ const { t } = useI18n()
162
+ const route = useRoute()
163
+ const authStore = useAuthStore()
164
+
165
+ return computed(() => {
166
+ const companyId = authStore.user?.company_id
167
+ if (!authStore.isSystemOwner || !companyId) return null
168
+ const item = normalizeGroups(getNavigation()).flatMap(group => group.items).find(entry => entry.selfCompany)
169
+ if (!item) return null
170
+ return {
171
+ label: t(item.labelKey),
172
+ icon: item.icon,
173
+ to: selfCompanyRoute(companyId),
174
+ active: isOwnCompanyRoute(route, companyId),
175
+ }
176
+ })
177
+ }
@@ -0,0 +1,207 @@
1
+ <script setup>
2
+ import { onMounted, onBeforeUnmount, ref, watch, computed } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { useAppStore } from '@initsolusidigital/worksuite-core/stores/app.js'
5
+ import { getBreadcrumbs } from '../shellSetup.js'
6
+ import { useLgViewport } from '../composables/useLgViewport.js'
7
+ import { useModulePermission } from '@initsolusidigital/worksuite-core/composables/useModulePermission.js'
8
+ import { useChat } from '@initsolusidigital/worksuite-core/composables/useChat.js'
9
+ import { useNotifications } from '@initsolusidigital/worksuite-core/composables/useNotifications.js'
10
+ import { updatePageBadge } from '@initsolusidigital/worksuite-core/utils/pageBadge.js'
11
+ import AppNavbar from '../components/layout/AppNavbar.vue'
12
+ import AppSidebar from '../components/layout/AppSidebar.vue'
13
+ import AppBreadcrumb from '@initsolusidigital/worksuite-ui/AppBreadcrumb.vue'
14
+ import AppNotificationPopup from '../components/layout/AppNotificationPopup.vue'
15
+ import AppLauncherPopup from '../components/layout/AppLauncherPopup.vue'
16
+ import AppNotificationDrawer from '../components/layout/AppNotificationDrawer.vue'
17
+ import AppUserMenu from '../components/layout/AppUserMenu.vue'
18
+ import AppLanguagePopup from '../components/layout/AppLanguagePopup.vue'
19
+ import AppChatWidget from '../components/chat/AppChatWidget.vue'
20
+ import AppAssistantWidget from '../components/ai/AppAssistantWidget.vue'
21
+ import AppGlobalSearch from '../components/layout/AppGlobalSearch.vue'
22
+ import AppToastContainer from '@initsolusidigital/worksuite-core/components/ui/AppToastContainer.vue'
23
+ import AppPlatformBanners from '../components/layout/AppPlatformBanners.vue'
24
+
25
+ // Lebar konten per-halaman. Chrome (navbar/footer) SELALU full-width; hanya area
26
+ // konten yang di-cap. `default` = 1280 (form/detail — baca enak); `wide` = 1536 (tabel
27
+ // padat); `fluid` = full-width (papan Kanban/dashboard yang butuh seluruh layar).
28
+ // Padding horizontal DIPUSATKAN di sini (dulu tiap shell pakai idiom `px-* xl:px-0` yang
29
+ // hanya cocok utk cap 1280) — halaman/breadcrumb tak lagi mengatur padding sisi sendiri.
30
+ const props = defineProps({
31
+ width: {
32
+ type: String,
33
+ default: 'default',
34
+ validator: v => ['default', 'wide', 'fluid'].includes(v),
35
+ },
36
+ })
37
+
38
+ const contentMaxW = computed(() => ({
39
+ default: 'max-w-7xl',
40
+ wide: 'max-w-screen-2xl',
41
+ fluid: 'max-w-none',
42
+ }[props.width] ?? 'max-w-7xl'))
43
+
44
+ const { t } = useI18n()
45
+ const appStore = useAppStore()
46
+ // Peta breadcrumb milik app, dipasok lewat configureShell().
47
+ const useBreadcrumbs = getBreadcrumbs()
48
+ const { breadcrumbs } = useBreadcrumbs()
49
+ // Di ≥ lg breadcrumb dirender AppNavbar sebagai pulau header; baris di atas konten tinggal
50
+ // dipakai bila halaman mengisi `header-actions`.
51
+ const isLg = useLgViewport()
52
+ const { can } = useModulePermission()
53
+ const { unreadTotal } = useChat()
54
+ const { unreadCount } = useNotifications()
55
+
56
+ // Versi diinjeksi dari package.json via Vite define (vite.config.js)
57
+ const appVersion = __APP_VERSION__
58
+
59
+ // Terapkan dark mode dari preferensi tersimpan saat layout pertama kali dimuat
60
+ onMounted(() => {
61
+ appStore.initDarkMode()
62
+ })
63
+
64
+ // Tumpukan tombol melayang (Asisten AI di atas Chat) naik setinggi bagian footer yang sedang
65
+ // terlihat, lewat variabel CSS `--fab-lift` yang dibaca kedua widget. Posisi naik permanen akan
66
+ // menutupi konten di atas footer di setiap halaman; naik hanya saat footer masuk layar menjaga
67
+ // keduanya — footer tak pernah tertutup, konten tak dikorbankan selama digulung.
68
+ // ResizeObserver ikut dipasang karena tinggi halaman bisa berubah tanpa gulungan (pindah rute,
69
+ // data termuat) dan menggeser footer masuk/keluar layar.
70
+ // Bantalan bawah konten setinggi tumpukan tombol: tanpa itu elemen terakhir halaman (lazimnya
71
+ // tombol Simpan/Lanjut di tepi kanan) tertimpa tombol melayang saat digulung sampai habis.
72
+ // Dua tombol = 24 + 56 + 12 + 48 = 140px dari footer; satu tombol = 80px.
73
+ const showChat = computed(() => can('chats'))
74
+ const showAssistant = computed(() => can('ai_assistant', 'can_create'))
75
+ const mainBottomPad = computed(() => (showChat.value && showAssistant.value ? 'pb-40' : showChat.value || showAssistant.value ? 'pb-24' : 'pb-10'))
76
+ // Ruang yang dicadangkan elemen menempel di dasar konten (`AppFormFooter`) supaya tombolnya tak
77
+ // berada di bawah tombol melayang: kolom tombol memakan 80 px dari tepi layar, padding `main` 16–32 px.
78
+ const fabColumnWidth = computed(() => (showChat.value || showAssistant.value ? '4rem' : '0px'))
79
+ const footerEl = ref(null)
80
+ let fabRaf = 0
81
+ let fabObserver = null
82
+ function updateFabLift() {
83
+ fabRaf = 0
84
+ const top = footerEl.value?.getBoundingClientRect().top
85
+ const lift = top == null ? 0 : Math.max(0, Math.round(window.innerHeight - top))
86
+ document.documentElement.style.setProperty('--fab-lift', `${lift}px`)
87
+ }
88
+ function scheduleFabLift() { if (!fabRaf) fabRaf = requestAnimationFrame(updateFabLift) }
89
+ onMounted(() => {
90
+ window.addEventListener('scroll', scheduleFabLift, { passive: true })
91
+ window.addEventListener('resize', scheduleFabLift)
92
+ fabObserver = new ResizeObserver(scheduleFabLift)
93
+ fabObserver.observe(document.body)
94
+ updateFabLift()
95
+ })
96
+ onBeforeUnmount(() => {
97
+ window.removeEventListener('scroll', scheduleFabLift)
98
+ window.removeEventListener('resize', scheduleFabLift)
99
+ fabObserver?.disconnect()
100
+ if (fabRaf) cancelAnimationFrame(fabRaf)
101
+ document.documentElement.style.removeProperty('--fab-lift')
102
+ })
103
+
104
+ // Badge tab title/favicon — total gabungan chat (widget) + notifikasi umum, lihat
105
+ // src/utils/pageBadge.js utk kenapa dijumlah apa adanya walau sebagian tumpang tindih sumber.
106
+ watch([unreadTotal, unreadCount], ([chat, notif]) => {
107
+ updatePageBadge(chat + notif)
108
+ }, { immediate: true })
109
+ </script>
110
+
111
+ <template>
112
+ <!-- Wrapper seluruh layout — background menyesuaikan dark mode -->
113
+ <div class="min-h-screen bg-gray-100 dark:bg-gray-900 flex flex-col">
114
+
115
+ <!-- Tautan lompat: elemen fokus pertama, supaya pengguna papan tik tak harus melewati seluruh menu tiap halaman -->
116
+ <a
117
+ href="#konten"
118
+ class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[80] focus:px-4 focus:py-2 focus:rounded-lg focus:bg-white focus:text-blue-600 focus:shadow-lg"
119
+ >{{ t('common.skipToContent') }}</a>
120
+
121
+ <!-- Navigasi utama (sidebar ≥ lg, laci di bawahnya) + header utilitas melayang -->
122
+ <AppSidebar />
123
+ <AppNavbar />
124
+
125
+ <!-- Notifikasi: popup untuk desktop, drawer untuk mobile -->
126
+ <!-- Breakpoint CSS menentukan mana yang aktif, keduanya pakai state yang sama -->
127
+ <div class="hidden lg:block"><AppNotificationPopup /></div>
128
+ <div class="lg:hidden"><AppNotificationDrawer /></div>
129
+
130
+ <!-- User menu: popup desktop (layar sempit: kaki laci AppSidebar) -->
131
+ <div class="hidden lg:block"><AppUserMenu /></div>
132
+
133
+ <!-- Language popup: tampil di semua breakpoint (tidak butuh drawer terpisah) -->
134
+ <AppLanguagePopup />
135
+
136
+ <!-- App Launcher popup — grid app ala Google, dari navbar -->
137
+ <AppLauncherPopup />
138
+
139
+ <!-- Widget chat melayang — hanya bila role punya modul chats can_read -->
140
+ <AppChatWidget v-if="showChat" />
141
+ <!-- Asisten AI: hanya bagi pemegang izin MENJALANKAN asisten; tool di dalamnya disaring izin modul masing-masing. -->
142
+ <AppAssistantWidget v-if="showAssistant" />
143
+ <AppGlobalSearch />
144
+
145
+ <!-- Toast notifikasi realtime — selalu mount, tidak digerbang modul chats
146
+ (notifikasi berlaku semua user, lihat useNotifications.js initNotificationRealtime) -->
147
+ <AppToastContainer />
148
+
149
+ <!-- Kolom konten: bergeser selebar sidebar. Kelasnya sinkron dari store sejak render pertama,
150
+ jadi transisi hanya berjalan saat sidebar diciutkan, bukan tiap halaman dimuat. -->
151
+ <div
152
+ class="flex-1 flex flex-col min-w-0 transition-[padding] duration-200"
153
+ :class="appStore.sidebarCollapsed ? 'lg:pl-20' : 'lg:pl-64'"
154
+ >
155
+ <!-- Area konten utama halaman -->
156
+ <!-- Padding atas = jarak dari header floating: 16 + tinggi header + 32 px (pt-28 untuk bilah
157
+ 64 px di layar sempit, lg:pt-24 untuk pulau 48 px). Nilainya WAJIB sama dengan tinggi
158
+ masker blur di AppNavbar. Padding horizontal
159
+ dipusatkan di sini (px-4→lg:px-8) + cap lebar per varian; halaman tidak lagi
160
+ mengatur padding sisi sendiri. -->
161
+ <main
162
+ id="konten"
163
+ tabindex="-1"
164
+ class="flex-1 w-full mx-auto pt-28 lg:pt-24 px-4 sm:px-6 lg:px-8 outline-none"
165
+ :class="[contentMaxW, mainBottomPad]"
166
+ :style="{ '--fab-col': fabColumnWidth }"
167
+ >
168
+ <!-- Banner keadaan platform — sesi impersonation · langganan past_due/suspended ·
169
+ pengumuman Init. Ditaruh DI ATAS breadcrumb, bukan di bawahnya: ketiganya berlaku ke
170
+ seluruh aplikasi, jadi menaruhnya sesudah judul halaman membuatnya terbaca sebagai
171
+ komentar atas halaman itu saja. Tak ada spacing tambahan saat kosong (komponen
172
+ merender nol elemen). -->
173
+ <AppPlatformBanners />
174
+
175
+ <!-- Baris breadcrumb + aksi header. Slot `header-actions` = tombol aksi yang
176
+ SEBARIS dengan breadcrumb (mis. tombol "Atur" dashboard). Di ≥ lg breadcrumb ada di
177
+ header (AppNavbar), jadi baris ini hanya muncul bila halaman punya aksi, rata kanan. -->
178
+ <div v-if="!isLg || $slots['header-actions']" class="flex items-center justify-between gap-3 mb-4">
179
+ <AppBreadcrumb v-if="!isLg" :breadcrumbs="breadcrumbs" class="!mb-0 min-w-0" />
180
+ <div class="shrink-0 ml-auto"><slot name="header-actions" /></div>
181
+ </div>
182
+ <!-- Wrapper slot: hapus padding-top dari div pertama setiap halaman -->
183
+ <!-- agar halaman tidak perlu mengatur jarak atas secara manual -->
184
+ <div class="[&>div:first-child]:pt-0">
185
+ <slot />
186
+ </div>
187
+ </main>
188
+
189
+ <!-- Footer aplikasi — chrome full-width (sejajar navbar), padding konsisten. Tak perlu jalur
190
+ kosong untuk tombol melayang: tumpukannya naik di atas footer begitu footer terlihat
191
+ (`--fab-lift`, lihat script). -->
192
+ <footer ref="footerEl" class="w-full border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
193
+ <div class="w-full px-4 sm:px-6 lg:px-8 py-4 flex flex-col sm:flex-row items-center justify-between gap-2">
194
+ <!-- Hak cipta -->
195
+ <p class="text-xs text-gray-400 dark:text-gray-600">
196
+ {{ t('common.copyright', { year: new Date().getFullYear() }) }}
197
+ </p>
198
+ <!-- Versi aplikasi — diinjeksi dari package.json via Vite define -->
199
+ <p class="text-xs text-gray-400 dark:text-gray-600">
200
+ v{{ appVersion }}
201
+ </p>
202
+ </div>
203
+ </footer>
204
+ </div>
205
+
206
+ </div>
207
+ </template>