@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,98 @@
1
+ <script setup>
2
+ import { nextTick, ref, watch } from 'vue'
3
+ import { useAppStore } from '@initsolusidigital/worksuite-core/stores/app.js'
4
+ import { useI18n } from 'vue-i18n'
5
+ import { useNotifications } from '@initsolusidigital/worksuite-core/composables/useNotifications.js'
6
+ import { X } from '@lucide/vue'
7
+ import AppNotificationContent from './AppNotificationContent.vue'
8
+
9
+ const { t } = useI18n()
10
+ const appStore = useAppStore()
11
+ const { markAllAsRead } = useNotifications()
12
+
13
+ // Fokus pindah ke laci saat terbuka dan kembali ke lonceng saat tertutup: tanpa ini pengguna papan tik
14
+ // & pembaca layar tertinggal di belakang backdrop. Escape ditangani AppNotificationPopup, yang tetap
15
+ // ter-mount (tersembunyi lewat CSS) di lebar ini dan memakai state yang sama.
16
+ const closeButton = ref(null)
17
+ let openerEl = null
18
+ watch(() => appStore.showNotification, async (open) => {
19
+ if (open) {
20
+ openerEl = document.activeElement
21
+ await nextTick()
22
+ closeButton.value?.focus()
23
+ } else {
24
+ if (openerEl?.isConnected) openerEl.focus()
25
+ openerEl = null
26
+ }
27
+ })
28
+ </script>
29
+
30
+ <template>
31
+ <!-- Backdrop gelap — klik untuk tutup drawer -->
32
+ <Transition name="fade">
33
+ <div
34
+ v-if="appStore.showNotification"
35
+ class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[70]"
36
+ @click="appStore.closeNotification()"
37
+ />
38
+ </Transition>
39
+
40
+ <!-- Panel drawer notifikasi — muncul dari kanan -->
41
+ <Transition name="slide-right">
42
+ <aside
43
+ v-if="appStore.showNotification"
44
+ role="dialog"
45
+ aria-modal="true"
46
+ :aria-label="t('notification.title')"
47
+ class="fixed top-0 right-0 h-full w-[min(24rem,88vw)] pt-[env(safe-area-inset-top)] bg-white dark:bg-gray-900 z-[80] flex flex-col shadow-2xl"
48
+ >
49
+ <!-- Header: judul + tandai dibaca + tombol tutup -->
50
+ <div class="px-4 py-2 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between shrink-0">
51
+ <h2 class="text-sm font-semibold text-gray-800 dark:text-white">{{ t('notification.title') }}</h2>
52
+ <div class="flex items-center gap-3">
53
+ <button
54
+ @click="markAllAsRead"
55
+ class="text-xs text-blue-600 dark:text-blue-400 hover:underline transition-colors"
56
+ >
57
+ {{ t('notification.markAllRead') }}
58
+ </button>
59
+ <button
60
+ ref="closeButton"
61
+ type="button"
62
+ @click="appStore.closeNotification()"
63
+ class="w-10 h-10 -mr-2 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
64
+ :aria-label="t('notification.close')"
65
+ :title="t('notification.close')"
66
+ >
67
+ <X class="w-5 h-5" aria-hidden="true" />
68
+ </button>
69
+ </div>
70
+ </div>
71
+
72
+ <!-- Konten bersama: tab bar + daftar notifikasi -->
73
+ <AppNotificationContent />
74
+ </aside>
75
+ </Transition>
76
+ </template>
77
+
78
+ <style scoped>
79
+ /* Transisi fade untuk backdrop */
80
+ .fade-enter-active,
81
+ .fade-leave-active {
82
+ transition: opacity 0.3s ease;
83
+ }
84
+ .fade-enter-from,
85
+ .fade-leave-to {
86
+ opacity: 0;
87
+ }
88
+
89
+ /* Transisi slide dari kanan untuk panel drawer */
90
+ .slide-right-enter-active,
91
+ .slide-right-leave-active {
92
+ transition: transform 0.3s ease-in-out;
93
+ }
94
+ .slide-right-enter-from,
95
+ .slide-right-leave-to {
96
+ transform: translateX(100%);
97
+ }
98
+ </style>
@@ -0,0 +1,67 @@
1
+ <script setup>
2
+ import { useAppStore } from '@initsolusidigital/worksuite-core/stores/app.js'
3
+ import { useDismissOnEscape } from '@initsolusidigital/worksuite-core/composables/useDismissOnEscape.js'
4
+ import { useI18n } from 'vue-i18n'
5
+ import { useNotifications } from '@initsolusidigital/worksuite-core/composables/useNotifications.js'
6
+ import AppNotificationContent from './AppNotificationContent.vue'
7
+
8
+ const { t } = useI18n()
9
+ const appStore = useAppStore()
10
+
11
+ // Escape menutup popup ini — mengalah bila ada dialog di atasnya (lihat composable).
12
+ useDismissOnEscape(() => appStore.showNotification, () => appStore.closeNotification())
13
+ const { markAllAsRead } = useNotifications()
14
+ </script>
15
+
16
+ <template>
17
+ <!-- Backdrop transparan — menutup popup saat klik di luar panel -->
18
+ <div
19
+ v-if="appStore.showNotification"
20
+ class="fixed inset-0 z-[55]"
21
+ @click="appStore.closeNotification()"
22
+ />
23
+
24
+ <!-- Wrapper posisi — mirror navbar (fixed + px-4 + max-w-7xl) agar popup
25
+ sejajar dengan batas kanan navbar di semua lebar layar -->
26
+ <!-- 8 px di bawah header: 88 = 16 + 64 (bilah layar sempit), 72 = 16 + 48 (pulau ≥lg) -->
27
+ <div class="fixed top-[88px] lg:top-[72px] left-0 right-0 z-[60] pointer-events-none">
28
+ <div class="w-full px-4 sm:px-6 lg:px-8 flex justify-end pointer-events-none">
29
+
30
+ <!-- Panel popup notifikasi desktop -->
31
+ <Transition name="fade-down">
32
+ <div
33
+ v-if="appStore.showNotification"
34
+ class="pointer-events-auto w-80 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-100 dark:border-gray-700 overflow-hidden flex flex-col max-h-[480px]"
35
+ >
36
+ <!-- Header: judul + tombol tandai semua dibaca -->
37
+ <div class="px-4 py-3 border-b border-gray-100 dark:border-gray-700 flex justify-between items-center bg-gray-50/50 dark:bg-gray-700/50 shrink-0">
38
+ <h3 class="text-sm font-semibold text-gray-800 dark:text-white">{{ t('notification.title') }}</h3>
39
+ <button
40
+ @click="markAllAsRead"
41
+ class="text-xs text-blue-600 dark:text-blue-400 hover:underline transition-colors"
42
+ >
43
+ {{ t('notification.markAllRead') }}
44
+ </button>
45
+ </div>
46
+
47
+ <!-- Konten bersama: tab bar + daftar notifikasi -->
48
+ <AppNotificationContent />
49
+ </div>
50
+ </Transition>
51
+
52
+ </div>
53
+ </div>
54
+ </template>
55
+
56
+ <style scoped>
57
+ /* Animasi popup muncul dari atas dengan sedikit geser ke bawah */
58
+ .fade-down-enter-active,
59
+ .fade-down-leave-active {
60
+ transition: opacity 0.2s ease, transform 0.2s ease;
61
+ }
62
+ .fade-down-enter-from,
63
+ .fade-down-leave-to {
64
+ opacity: 0;
65
+ transform: translateY(-8px);
66
+ }
67
+ </style>
@@ -0,0 +1,192 @@
1
+ <script setup>
2
+ import { computed, ref } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { useRouter } from 'vue-router'
5
+ import { usePlatformState } from '@initsolusidigital/worksuite-core/composables/usePlatformState.js'
6
+ import { useImpersonation } from '@initsolusidigital/worksuite-core/composables/useImpersonation.js'
7
+ import AppAlert from '@initsolusidigital/worksuite-ui/AppAlert.vue'
8
+ import { tenantTimeZone } from '@initsolusidigital/worksuite-core/utils/datetime.js'
9
+
10
+ // Banner keadaan PLATFORM. Tiga sumber, satu tumpukan, urutan TETAP:
11
+ //
12
+ // 1. Sesi impersonation — permanen, tak bisa ditutup. Paling atas karena ia mengubah arti
13
+ // SEMUA yang dilihat di bawahnya (data ini milik orang lain).
14
+ // 2. Workspace demo — permanen, tak bisa ditutup. Di atas langganan karena ia menjelaskan
15
+ // sifat SELURUH workspace (data disapu tiap malam, lalu dipurge) —
16
+ // keterangan yang lebih mendasar daripada status tagihannya.
17
+ // 3. Status langganan — past_due / suspended. Tak bisa ditutup: ia bukan pengumuman,
18
+ // melainkan keterangan kenapa tombol simpan berhenti bekerja.
19
+ // 4. Pengumuman platform — bisa ditutup (kontrak: "dismiss = state lokal").
20
+ //
21
+ // ⚠️ JANGAN membuat banner langganan bisa ditutup "biar tak mengganggu". Saat suspended, setiap
22
+ // write dijawab 403 oleh backend; tanpa banner, kegagalannya terbaca sebagai aplikasi rusak dan
23
+ // yang dihubungi bukan bagian keuangan tenant melainkan dukungan kami.
24
+ const { t, locale } = useI18n()
25
+ const router = useRouter()
26
+ const {
27
+ isPastDue, isSuspended, impersonation, visibleAnnouncements, dismissAnnouncement,
28
+ isTrial, subscriptionEndDate, purgeScheduledAt, isDemo, demoExpiresAt,
29
+ } = usePlatformState()
30
+ const { isImpersonating, endSession } = useImpersonation()
31
+
32
+ // "Akhiri Sesi" hanya ditawarkan pada tab yang MEMEGANG token-nya (`isImpersonating`), bukan
33
+ // setiap kali `/auth/me` membawa objek impersonation. Keduanya bisa berbeda: bila kelak backend
34
+ // juga menandai sisi tenant, banner tetap tampil di sana — dan tombol yang tak akan pernah
35
+ // bekerja lebih buruk daripada tak ada tombol.
36
+ const ending = ref(false)
37
+ async function stopImpersonation() {
38
+ ending.value = true
39
+ try {
40
+ await endSession() // melepas token DULU, baru mencabut izin — lihat useImpersonation
41
+ router.push({ name: 'dashboard' })
42
+ } finally {
43
+ ending.value = false
44
+ }
45
+ }
46
+
47
+ function formatDateTime(iso) {
48
+ if (!iso) return ''
49
+ return new Date(iso).toLocaleString(locale.value === 'id' ? 'id-ID' : 'en-US', {
50
+ day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short', timeZone: tenantTimeZone(),
51
+ })
52
+ }
53
+
54
+ // Tautan "lihat tagihan" hanya dipasang bila app ini MEMANG punya halamannya.
55
+ // Komponen ini dipakai juga oleh app yang tak punya rute `my-subscription`, dan
56
+ // `router-link :to="{ name }"` ke rute tak dikenal melempar saat render — satu banner yang
57
+ // salah asumsi akan mematikan seluruh halaman, bukan cuma dirinya sendiri.
58
+ const billingRoute = computed(() => (router.hasRoute('my-subscription') ? { name: 'my-subscription' } : null))
59
+
60
+ // ── Siklus hidup langganan ──────────────────────────────────────────────────────────────────
61
+ // Tanggal saja (tanpa jam): keduanya tenggat kalender, dan menambahkan "14:37" pada kalimat
62
+ // "data dihapus permanen" cuma menyiratkan presisi yang tak dijanjikan backend.
63
+ function formatDate(iso) {
64
+ if (!iso) return ''
65
+ return new Date(iso).toLocaleDateString(locale.value === 'id' ? 'id-ID' : 'en-US', {
66
+ day: '2-digit', month: 'long', year: 'numeric',
67
+ })
68
+ }
69
+
70
+ // Sisa hari menuju penghapusan. Dibulatkan KE ATAS supaya "besok" tak pernah tampil sebagai
71
+ // "0 hari lagi" — pada banner yang memperingatkan kehilangan data permanen, meremehkan sisa
72
+ // waktu jauh lebih berbahaya daripada melebihkannya.
73
+ const purgeDaysLeft = computed(() => {
74
+ if (!purgeScheduledAt.value) return null
75
+ const ms = new Date(purgeScheduledAt.value).getTime() - Date.now()
76
+ if (Number.isNaN(ms)) return null
77
+ return Math.max(0, Math.ceil(ms / 86_400_000))
78
+ })
79
+
80
+ // Banner trial hanya untuk tenant yang MEMANG sedang trial dan belum lewat tenggat. Tenant
81
+ // trial yang sudah past_due/suspended melihat banner read-only saja — dua banner yang
82
+ // mengatakan hal berbeda tentang keadaan yang sama justru membingungkan.
83
+ const showTrialBanner = computed(() => isTrial.value && !isPastDue.value && !isSuspended.value)
84
+
85
+ // Level pengumuman → varian AppAlert. `maintenance` memakai varian padat (kontrak: paling
86
+ // mencolok); level tak dikenal jatuh ke `info`, bukan disembunyikan — pengumuman yang tak
87
+ // tampil karena level baru dari backend adalah kegagalan yang tak terlihat siapa pun.
88
+ const LEVEL_VARIANT = { info: 'info', warning: 'warning', maintenance: 'maintenance' }
89
+ const variantFor = (level) => LEVEL_VARIANT[level] ?? 'info'
90
+ </script>
91
+
92
+ <template>
93
+ <div v-if="impersonation || isDemo || isPastDue || isSuspended || showTrialBanner || visibleAnnouncements.length" class="space-y-2 mb-4">
94
+
95
+ <!-- 1. Sesi impersonation — permanen -->
96
+ <AppAlert
97
+ v-if="impersonation"
98
+ variant="support"
99
+ :title="t('platformBanner.impersonation.title', { name: impersonation.impersonator_name })"
100
+ :message="t('platformBanner.impersonation.body', { expires: formatDateTime(impersonation.expires_at) })"
101
+ >
102
+ <button
103
+ v-if="isImpersonating"
104
+ type="button"
105
+ class="mt-2 px-3 py-1 rounded-md bg-white/20 hover:bg-white/30 text-sm font-semibold transition-colors disabled:opacity-60"
106
+ :disabled="ending"
107
+ @click="stopImpersonation"
108
+ >
109
+ {{ ending ? t('common.processing') : t('platformBanner.impersonation.endSession') }}
110
+ </button>
111
+ </AppAlert>
112
+
113
+ <!-- 2. Workspace demo — permanen, tak bisa ditutup -->
114
+ <AppAlert
115
+ v-if="isDemo"
116
+ variant="info"
117
+ :title="t('platformBanner.demo.title')"
118
+ :message="demoExpiresAt
119
+ ? t('platformBanner.demo.body', { date: formatDate(demoExpiresAt) })
120
+ : t('platformBanner.demo.bodyNoDate')"
121
+ />
122
+
123
+ <!-- 3. Status langganan -->
124
+ <AppAlert
125
+ v-if="isSuspended"
126
+ variant="error"
127
+ :title="t('platformBanner.suspended.title')"
128
+ :message="t('platformBanner.suspended.body')"
129
+ >
130
+ <p v-if="purgeScheduledAt" class="mt-1.5 font-semibold">
131
+ {{ t('platformBanner.purge.warning', { date: formatDate(purgeScheduledAt), days: purgeDaysLeft }) }}
132
+ </p>
133
+ <router-link
134
+ v-if="billingRoute"
135
+ :to="billingRoute"
136
+ class="inline-block mt-1.5 font-semibold underline underline-offset-2"
137
+ >
138
+ {{ t('platformBanner.viewBilling') }}
139
+ </router-link>
140
+ </AppAlert>
141
+ <!-- ⚠️ `past_due` memakai varian ERROR, bukan `warning`: status ini berarti read-only
142
+ seketika, bukan "menunggak tapi masih bisa kerja". Nada peringatan ringan pada keadaan yang memblokir semua
143
+ penyimpanan akan membuat tenant terus mencoba lalu menyimpulkan aplikasinya rusak. -->
144
+ <AppAlert
145
+ v-else-if="isPastDue"
146
+ variant="error"
147
+ :title="t('platformBanner.pastDue.title')"
148
+ :message="t('platformBanner.pastDue.body')"
149
+ >
150
+ <p v-if="purgeScheduledAt" class="mt-1.5 font-semibold">
151
+ {{ t('platformBanner.purge.warning', { date: formatDate(purgeScheduledAt), days: purgeDaysLeft }) }}
152
+ </p>
153
+ <router-link
154
+ v-if="billingRoute"
155
+ :to="billingRoute"
156
+ class="inline-block mt-1.5 font-semibold underline underline-offset-2"
157
+ >
158
+ {{ t('platformBanner.viewBilling') }}
159
+ </router-link>
160
+ </AppAlert>
161
+
162
+ <!-- 3b. Trial berjalan — informatif, bukan peringatan. Tenant trial belum melakukan apa pun
163
+ yang salah; yang berguna baginya adalah tahu kapan berakhir & ke mana upgrade. -->
164
+ <AppAlert
165
+ v-else-if="showTrialBanner"
166
+ variant="info"
167
+ :title="t('platformBanner.trial.title')"
168
+ :message="subscriptionEndDate
169
+ ? t('platformBanner.trial.body', { date: formatDate(subscriptionEndDate) })
170
+ : t('platformBanner.trial.bodyNoDate')"
171
+ >
172
+ <router-link
173
+ v-if="billingRoute"
174
+ :to="billingRoute"
175
+ class="inline-block mt-1.5 font-semibold underline underline-offset-2"
176
+ >
177
+ {{ t('platformBanner.trial.upgrade') }}
178
+ </router-link>
179
+ </AppAlert>
180
+
181
+ <!-- 4. Pengumuman platform -->
182
+ <AppAlert
183
+ v-for="a in visibleAnnouncements"
184
+ :key="a.id"
185
+ :variant="variantFor(a.level)"
186
+ :title="a.title"
187
+ :message="a.body"
188
+ dismissible
189
+ @dismiss="dismissAnnouncement(a.id)"
190
+ />
191
+ </div>
192
+ </template>
@@ -0,0 +1,226 @@
1
+ <script setup>
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import { useRoute } from 'vue-router'
4
+ import { useI18n } from 'vue-i18n'
5
+ import { ChevronsLeft, ChevronsRight, X } from '@lucide/vue'
6
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
7
+ import { useAppStore } from '@initsolusidigital/worksuite-core/stores/app.js'
8
+ import { useNavigation } from '../../composables/useNavigation.js'
9
+ import { SIDEBAR_BASE, brandAlpha, brandInitialsColor, brandTextOn, normalizeBrandColor } from '@initsolusidigital/worksuite-core/utils/brandColor.js'
10
+ import AppAvatar from '@initsolusidigital/worksuite-ui/AppAvatar.vue'
11
+ import AppTooltip from '@initsolusidigital/worksuite-ui/AppTooltip.vue'
12
+ import AppSidebarNav from './AppSidebarNav.vue'
13
+ import AppUserMenuContent from './AppUserMenuContent.vue'
14
+
15
+ const route = useRoute()
16
+ const { t } = useI18n()
17
+ const authStore = useAuthStore()
18
+ const appStore = useAppStore()
19
+ const { groups, bottomGroups } = useNavigation()
20
+
21
+ const collapsed = computed(() => appStore.sidebarCollapsed)
22
+ const drawerGroups = computed(() => [...groups.value, ...bottomGroups.value])
23
+
24
+ // Warna brand boleh ber-alpha, sampai transparan penuh (logo berlatar sendiri).
25
+ const brandColor = computed(() => normalizeBrandColor(authStore.brandingColor))
26
+ const initialsColor = computed(() => brandInitialsColor(brandColor.value))
27
+ const brandStyle = computed(() => ({
28
+ backgroundColor: brandColor.value,
29
+ color: brandTextOn(brandColor.value, appStore.darkMode ? SIDEBAR_BASE.dark : SIDEBAR_BASE.light),
30
+ }))
31
+ // Garis tepi membuat brand yang nyaris sewarna latar sidebar tetap terbaca sebagai blok. Pada alpha 0
32
+ // tak ada blok yang perlu ditegaskan: garisnya justru membingkai logo yang dimaksudkan telanjang.
33
+ const brandRing = computed(() => (brandAlpha(brandColor.value) > 0 ? 'ring-1 ring-inset ring-black/5 dark:ring-white/10' : ''))
34
+ // Mode rail hanya menyisakan logo, jadi nama app & tenant pindah ke tooltip.
35
+ const brandTooltip = computed(() => [t('common.appName'), authStore.companyName].filter(Boolean).join(' · '))
36
+ const userName = computed(() => authStore.user?.first_name || t('user.fallback'))
37
+ const userFullName = computed(() => [authStore.user?.first_name, authStore.user?.last_name].filter(Boolean).join(' ') || userName.value)
38
+ const userEmail = computed(() => authStore.user?.email || '')
39
+
40
+ // ── Gulungan daftar menu ─────────────────────────────────────────────────────────────────────
41
+ // AppLayout dibungkus per halaman, jadi komponen ini remount tiap pindah rute. Tanpa pemulihan,
42
+ // daftar panjang melompat ke atas setiap kali satu item bawahnya diklik.
43
+ const scrollEl = ref(null)
44
+ function saveScroll() {
45
+ if (scrollEl.value) appStore.sidebarScrollTop = scrollEl.value.scrollTop
46
+ }
47
+ function revealActive() {
48
+ const box = scrollEl.value
49
+ const active = box?.querySelector('[aria-current="page"]')
50
+ if (!active) return
51
+ // Dihitung manual: `scrollIntoView` ikut menggulung leluhur lain bila ada yang bisa digulung.
52
+ const boxRect = box.getBoundingClientRect()
53
+ const rect = active.getBoundingClientRect()
54
+ if (rect.top < boxRect.top) box.scrollTop -= boxRect.top - rect.top
55
+ else if (rect.bottom > boxRect.bottom) box.scrollTop += rect.bottom - boxRect.bottom
56
+ }
57
+ onMounted(() => {
58
+ if (scrollEl.value) scrollEl.value.scrollTop = appStore.sidebarScrollTop
59
+ revealActive()
60
+ })
61
+
62
+ // ── Laci layar sempit ────────────────────────────────────────────────────────────────────────
63
+ const closeButton = ref(null)
64
+ let openerEl = null
65
+ function onKeydown(e) {
66
+ if (e.key === 'Escape') appStore.closeSidebar()
67
+ }
68
+ watch(() => appStore.showMobileSidebar, async (open) => {
69
+ if (open) {
70
+ openerEl = document.activeElement
71
+ window.addEventListener('keydown', onKeydown)
72
+ await nextTick()
73
+ closeButton.value?.focus()
74
+ } else {
75
+ window.removeEventListener('keydown', onKeydown)
76
+ if (openerEl?.isConnected) openerEl.focus()
77
+ openerEl = null
78
+ }
79
+ })
80
+ watch(() => route.fullPath, () => appStore.closeSidebar())
81
+ // Keadaan laci hidup di store sedangkan komponen ini remount tiap pindah rute: tanpa ini laci yang
82
+ // ditinggalkan navigasi terprogram tetap terbuka di halaman berikutnya.
83
+ onBeforeUnmount(() => {
84
+ window.removeEventListener('keydown', onKeydown)
85
+ appStore.closeSidebar()
86
+ })
87
+ </script>
88
+
89
+ <template>
90
+ <!-- ── Sidebar ≥ lg ─────────────────────────────────────────────────────────────────────── -->
91
+ <aside
92
+ class="fixed inset-y-0 left-0 z-40 hidden lg:flex flex-col bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 transition-[width] duration-200"
93
+ :class="collapsed ? 'w-20' : 'w-64'"
94
+ >
95
+ <!-- Puncak sejajar pulau header (top-4 + h-12), supaya blok brand & pulau header segaris. -->
96
+ <!-- `aria-current-value`: di dashboard RouterLink memberi logo `aria-current="page"`, dan "halaman
97
+ saat ini" akan terbaca dua kali bersama item Dashboard di menu. -->
98
+ <!-- Jarak luar milik pembungkus tooltip (span `inline-flex`): tautan di dalamnya mengisi lebarnya. -->
99
+ <AppTooltip class="mt-4 shrink-0" :class="collapsed ? 'mx-2' : 'mx-3'" placement="right" :text="collapsed ? brandTooltip : ''">
100
+ <router-link
101
+ :to="{ name: 'dashboard' }"
102
+ :aria-label="t('common.goToDashboard')"
103
+ aria-current-value="false"
104
+ data-sidebar-brand
105
+ class="w-full h-12 flex items-center gap-2.5 rounded-xl transition-colors duration-300"
106
+ :class="[collapsed ? 'justify-center' : 'px-2.5', brandRing]"
107
+ :style="brandStyle"
108
+ >
109
+ <span class="w-8 h-8 shrink-0 flex items-center justify-center">
110
+ <img v-if="authStore.brandingLogo" :src="authStore.brandingLogo" class="w-full h-full object-contain" alt="">
111
+ <span v-else class="w-8 h-8 rounded-full bg-white flex items-center justify-center font-black text-[11px] leading-none" :style="{ color: initialsColor }">{{ authStore.companyInitials }}</span>
112
+ </span>
113
+ <span v-if="!collapsed" class="min-w-0 flex flex-col leading-tight">
114
+ <span class="text-sm font-semibold truncate">{{ t('common.appName') }}</span>
115
+ <span v-if="authStore.companyName" data-sidebar-tenant class="text-xs opacity-75 truncate" :title="authStore.companyName">{{ authStore.companyName }}</span>
116
+ </span>
117
+ </router-link>
118
+ </AppTooltip>
119
+
120
+ <!-- Jarak ke blok brand dibagi dua: `mt-2` di LUAR area gulir tetap ada saat menu digulir, sedangkan
121
+ padding di dalamnya ikut tergulung. Tepi atas dipudarkan setinggi `pt-3`, jadi menu yang tergulung
122
+ memudar alih-alih terpotong rata; saat diam jalur pudar itu kosong. -->
123
+ <div ref="scrollEl" data-sidebar-scroll class="flex-1 min-h-0 mt-2 overflow-y-auto overflow-x-hidden scrollbar-rounded px-3 pt-3 pb-5 [mask-image:linear-gradient(to_bottom,transparent,#000_12px)]" @scroll.passive="saveScroll">
124
+ <AppSidebarNav :groups="groups" :collapsed="collapsed" />
125
+ </div>
126
+
127
+ <div class="shrink-0 border-t border-gray-200 dark:border-gray-800 p-3 flex flex-col gap-3">
128
+ <AppSidebarNav v-if="bottomGroups.length" :groups="bottomGroups" :collapsed="collapsed" :aria-label="t('common.secondaryNav')" />
129
+ <AppTooltip class="w-full" placement="right" :text="collapsed ? t('common.sidebarExpand') : ''">
130
+ <button
131
+ type="button"
132
+ :aria-label="collapsed ? t('common.sidebarExpand') : t('common.sidebarCollapse')"
133
+ :aria-expanded="!collapsed"
134
+ class="w-full h-10 flex items-center gap-3 rounded-lg px-3 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors"
135
+ :class="collapsed ? 'justify-center' : ''"
136
+ @click="appStore.toggleSidebarCollapsed()"
137
+ >
138
+ <ChevronsRight v-if="collapsed" class="w-5 h-5 shrink-0" aria-hidden="true" />
139
+ <template v-else>
140
+ <ChevronsLeft class="w-5 h-5 shrink-0" aria-hidden="true" />
141
+ <span class="truncate">{{ t('common.sidebarCollapse') }}</span>
142
+ </template>
143
+ </button>
144
+ </AppTooltip>
145
+ </div>
146
+ </aside>
147
+
148
+ <!-- ── Laci < lg ─────────────────────────────────────────────────────────────────────────── -->
149
+ <!-- `v-if`, bukan `v-show`: dua <nav> bernama sama tak boleh hidup bersamaan di pohon aksesibilitas. -->
150
+ <Transition name="sidebar-fade">
151
+ <div
152
+ v-if="appStore.showMobileSidebar"
153
+ class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60] lg:hidden"
154
+ @click="appStore.closeSidebar()"
155
+ ></div>
156
+ </Transition>
157
+ <Transition name="sidebar-slide">
158
+ <aside
159
+ v-if="appStore.showMobileSidebar"
160
+ role="dialog"
161
+ aria-modal="true"
162
+ :aria-label="t('common.mainMenu')"
163
+ class="fixed inset-y-0 left-0 w-72 max-w-[85vw] bg-white dark:bg-gray-900 z-[70] flex flex-col shadow-2xl lg:hidden"
164
+ >
165
+ <div class="shrink-0 flex items-center gap-2 p-3 border-b border-gray-200 dark:border-gray-800">
166
+ <div
167
+ class="flex-1 min-w-0 h-14 flex items-center gap-3 px-3 rounded-2xl"
168
+ :class="brandRing"
169
+ :style="brandStyle"
170
+ >
171
+ <span class="w-9 h-9 shrink-0 flex items-center justify-center">
172
+ <img v-if="authStore.brandingLogo" :src="authStore.brandingLogo" class="w-full h-full object-contain" alt="">
173
+ <span v-else class="w-8 h-8 rounded-full bg-white flex items-center justify-center font-black text-xs leading-none" :style="{ color: initialsColor }">{{ authStore.companyInitials }}</span>
174
+ </span>
175
+ <span class="min-w-0 flex flex-col leading-tight">
176
+ <span class="text-base font-semibold truncate">{{ t('common.appName') }}</span>
177
+ <span v-if="authStore.companyName" class="text-xs opacity-75 truncate" :title="authStore.companyName">{{ authStore.companyName }}</span>
178
+ </span>
179
+ </div>
180
+ <button
181
+ ref="closeButton"
182
+ type="button"
183
+ :aria-label="t('common.closeMenu')"
184
+ class="w-10 h-10 flex items-center justify-center rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
185
+ @click="appStore.closeSidebar()"
186
+ >
187
+ <X class="w-5 h-5" aria-hidden="true" />
188
+ </button>
189
+ </div>
190
+
191
+ <div class="flex-1 min-h-0 overflow-y-auto scrollbar-rounded px-3 py-5">
192
+ <AppSidebarNav :groups="drawerGroups" @navigate="appStore.closeSidebar()" />
193
+ </div>
194
+
195
+ <div class="shrink-0 border-t border-gray-200 dark:border-gray-800">
196
+ <div class="p-4 flex items-center gap-3">
197
+ <AppAvatar :src="authStore.user?.avatar ?? null" :name="userFullName" size="lg" />
198
+ <div class="min-w-0">
199
+ <p class="text-sm font-semibold text-gray-800 dark:text-white truncate">{{ userName }}</p>
200
+ <p class="text-xs text-gray-400 dark:text-gray-500 break-all">{{ userEmail }}</p>
201
+ </div>
202
+ </div>
203
+ <AppUserMenuContent />
204
+ </div>
205
+ </aside>
206
+ </Transition>
207
+ </template>
208
+
209
+ <style scoped>
210
+ .sidebar-fade-enter-active,
211
+ .sidebar-fade-leave-active {
212
+ transition: opacity 0.2s ease;
213
+ }
214
+ .sidebar-fade-enter-from,
215
+ .sidebar-fade-leave-to {
216
+ opacity: 0;
217
+ }
218
+ .sidebar-slide-enter-active,
219
+ .sidebar-slide-leave-active {
220
+ transition: transform 0.2s ease-out;
221
+ }
222
+ .sidebar-slide-enter-from,
223
+ .sidebar-slide-leave-to {
224
+ transform: translateX(-100%);
225
+ }
226
+ </style>