@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,402 @@
1
+ <script setup>
2
+ import { ref, computed, onMounted, onUnmounted } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { useI18n } from 'vue-i18n'
5
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
6
+ import { useAppStore } from '@initsolusidigital/worksuite-core/stores/app.js'
7
+ import { useModulePermission } from '@initsolusidigital/worksuite-core/composables/useModulePermission.js'
8
+ import { useSelfCompanyItem } from '../../composables/useNavigation.js'
9
+ import { getTenantSettingsEntryRoute, getBreadcrumbs } from '../../shellSetup.js'
10
+ import { useLgViewport } from '../../composables/useLgViewport.js'
11
+ import { useNotifications } from '@initsolusidigital/worksuite-core/composables/useNotifications.js'
12
+ import { useGlobalSearch } from '@initsolusidigital/worksuite-core/composables/useGlobalSearch.js'
13
+ import { brandInitialsColor, brandSolid, brandTextOn, normalizeBrandColor } from '@initsolusidigital/worksuite-core/utils/brandColor.js'
14
+ import { userName } from '@initsolusidigital/worksuite-core/utils/userName.js'
15
+ import AppLanguageSwitcher from '@initsolusidigital/worksuite-core/components/ui/AppLanguageSwitcher.vue'
16
+ import AppTooltip from '@initsolusidigital/worksuite-ui/AppTooltip.vue'
17
+ import AppBreadcrumb from '@initsolusidigital/worksuite-ui/AppBreadcrumb.vue'
18
+
19
+ const router = useRouter()
20
+ const { t } = useI18n()
21
+ const authStore = useAuthStore()
22
+ const appStore = useAppStore()
23
+ const myCompanyItem = useSelfCompanyItem()
24
+ // Di ≥ lg breadcrumb tinggal di header sebagai pulau kiri, sebaris dengan pulau utilitas; di
25
+ // bawahnya `AppLayout` yang merendernya di atas konten. Keduanya memilih lewat ref yang sama.
26
+ const isLg = useLgViewport()
27
+ const { breadcrumbs } = getBreadcrumbs()()
28
+ const { unreadCount, fetchUnreadCount, initNotificationRealtime, teardownNotificationRealtime } = useNotifications()
29
+
30
+ // ── Smart Scroll ─────────────────────────────────────────────────────────────
31
+ // Header disembunyikan saat pengguna scroll ke bawah, muncul kembali saat ke atas
32
+ const isHidden = ref(false)
33
+ let lastScrollY = 0
34
+ let scrollTimer = null
35
+
36
+ function onScroll() {
37
+ const y = window.scrollY
38
+ // Sembunyikan saat scroll ke bawah melewati 50px dari atas
39
+ if (y > lastScrollY && y > 50) {
40
+ isHidden.value = true
41
+ } else if (y < lastScrollY) {
42
+ isHidden.value = false
43
+ }
44
+ lastScrollY = y <= 0 ? 0 : y
45
+ // Tampilkan kembali setelah 800ms berhenti scroll
46
+ clearTimeout(scrollTimer)
47
+ scrollTimer = setTimeout(() => { isHidden.value = false }, 800)
48
+ }
49
+
50
+ // State fullscreen
51
+ const isFullscreen = ref(false)
52
+
53
+ // Toggle fullscreen browser
54
+ function toggleFullscreen() {
55
+ if (!document.fullscreenElement) {
56
+ document.documentElement.requestFullscreen()
57
+ isFullscreen.value = true
58
+ } else {
59
+ document.exitFullscreen()
60
+ isFullscreen.value = false
61
+ }
62
+ }
63
+
64
+ // Ikon gear Setelan Tenant: tampil bila app memasok `tenantSettingsEntryRoute` lewat
65
+ // configureShell() dan user berhak membuka minimal satu seksi Setelan Tenant. Seksi Pesan,
66
+ // Integrasi & API punya gerbang modulnya sendiri, jadi pemegangnya berhak masuk walau tanpa
67
+ // `companies,can_configure`; tanpa cabang ini satu-satunya jalan mereka adalah mengetik URL.
68
+ // Daftar seksi + gerbangnya milik app (satu sumber dengan tab Setelan Tenant), bukan diulang di sini.
69
+ const { can } = useModulePermission()
70
+ const { openSearch } = useGlobalSearch()
71
+ const settingsEntryRoute = computed(() => getTenantSettingsEntryRoute()?.(can) ?? null)
72
+ function navigateToSelfSettings() {
73
+ if (!authStore.user?.company_id || !settingsEntryRoute.value) return
74
+ router.push({ name: settingsEntryRoute.value, params: { companyId: authStore.user.company_id } })
75
+ appStore.closeUserMenu()
76
+ appStore.closeNotification()
77
+ }
78
+
79
+ // ── Lifecycle ────────────────────────────────────────────────────────────────
80
+ onMounted(() => {
81
+ window.addEventListener('scroll', onScroll, { passive: true })
82
+ fetchUnreadCount()
83
+ // Badge lonceng real-time — independen dari permission modul `chats`, makanya
84
+ // di-init di sini (navbar selalu mount), BUKAN di AppChatWidget (digerbang izin chat).
85
+ initNotificationRealtime(authStore.user?.id)
86
+ })
87
+
88
+ onUnmounted(() => {
89
+ window.removeEventListener('scroll', onScroll)
90
+ clearTimeout(scrollTimer)
91
+ teardownNotificationRealtime()
92
+ })
93
+
94
+ // ── Warna branding ───────────────────────────────────────────────────────────
95
+ // Pil logo mengisi lebar layar sempit sebagai navbar utama — latar transparan tidak terbaca di sana,
96
+ // jadi alpha warna brand dibuang.
97
+ const brandingColorSolid = computed(() => brandSolid(normalizeBrandColor(authStore.brandingColor)))
98
+ // Teks & ikon di atas pil dinilai terhadap warna pil itu sendiri: pil selalu solid.
99
+ const brandingText = computed(() => brandTextOn(brandingColorSolid.value))
100
+ const brandingInitials = computed(() => brandInitialsColor(brandingColorSolid.value))
101
+
102
+ // ── Data user (computed dari auth store) ──────────────────────────────────────
103
+ // Inisial dua huruf: huruf pertama first_name + huruf pertama last_name (misal: WP)
104
+ const userInitials = computed(() => {
105
+ const first = authStore.user?.first_name?.charAt(0)?.toUpperCase() || ''
106
+ const last = authStore.user?.last_name?.charAt(0)?.toUpperCase() || ''
107
+ return first + last || 'U'
108
+ })
109
+ // Backend selalu mengirim avatar sebagai absolute URL siap pakai
110
+ const userAvatar = computed(() => authStore.user?.avatar ?? null)
111
+ const userDisplayName = computed(() => userName(authStore.user))
112
+ // Fallback ke inisial jika gambar avatar gagal dimuat
113
+ const avatarError = ref(false)
114
+ </script>
115
+
116
+ <template>
117
+ <!-- Frosted blur mask — menutupi dari tepi atas viewport hingga ke zona buffer
118
+ sebelum konten halaman. Tingginya WAJIB sama dengan padding atas `main` di AppLayout
119
+ (h-28/pt-28 di layar sempit, h-24/pt-24 di ≥lg tempat pulau header 48 px) agar fade
120
+ selesai tepat di awal konten. Fade dimulai sejak 20% agar transisi blur tersebar merata
121
+ di seluruh area navbar dan menghilang halus di 90%. -->
122
+ <div
123
+ class="fixed top-0 left-0 right-0 h-28 lg:h-24 pointer-events-none z-[49]
124
+ bg-gradient-to-b from-gray-100/80 dark:from-gray-900/80 to-transparent
125
+ backdrop-blur transition-transform duration-500"
126
+ :class="[isHidden ? '-translate-y-[200%]' : 'translate-y-0', appStore.sidebarCollapsed ? 'lg:left-20' : 'lg:left-64']"
127
+ style="mask-image: linear-gradient(to bottom, black 20%, transparent 90%);
128
+ -webkit-mask-image: linear-gradient(to bottom, black 20%, transparent 90%);"
129
+ aria-hidden="true"
130
+ />
131
+
132
+ <!-- ── Floating Header Wrapper ──────────────────────────────────────────── -->
133
+ <!-- Fixed di atas viewport, tersembunyi dengan transform saat scroll ke bawah. Kiri mengikuti lebar
134
+ sidebar, dan `pointer-events-none`: baris ini selebar layar sedangkan isinya hanya pulau di
135
+ kanan — tanpa itu ruang kosongnya menelan klik pada konten yang tergulung di bawahnya. -->
136
+ <div
137
+ class="fixed top-4 left-0 right-0 z-50 transition-transform duration-500 pointer-events-none"
138
+ :class="[isHidden ? '-translate-y-[200%]' : 'translate-y-0', appStore.sidebarCollapsed ? 'lg:left-20' : 'lg:left-64']"
139
+ >
140
+ <!-- Padding sama dengan popup di bawah (AppUserMenu/AppNotificationPopup/AppLanguagePopup) supaya
141
+ tepi kanannya sejajar. Konten halaman lebarnya diatur per-halaman via prop AppLayout `width`. -->
142
+ <div class="w-full px-4 sm:px-6 lg:px-8 flex gap-3">
143
+
144
+ <!-- ── Layar sempit: logo + utilitas + hamburger ─────────────────────── -->
145
+ <!-- Warna latar dari primary_color perusahaan aktif. Dioper lewat `:style`, bukan `v-bind()` di
146
+ <style>: `v-bind()` mengandalkan `useCssVars` hasil compiler SFC, dan pada dev server yang
147
+ sudah lama hidup generate itu bisa hilang sehingga latar jatuh ke transparan. -->
148
+ <div
149
+ class="logo-pill pointer-events-auto lg:hidden h-16 rounded-2xl shadow-xl flex items-center px-4 md:px-6 shrink-0 w-full justify-between transition-colors duration-300"
150
+ :style="{ '--logo-pill-solid': brandingColorSolid, color: brandingText }"
151
+ >
152
+
153
+ <!-- Gambar logo: rounded-lg + object-contain agar tidak terpotong; inisial: lingkaran bg putih.
154
+ `aria-label` WAJIB: tanpa itu namanya jatuh ke `alt="Logo"` — lolos audit, tapi yang
155
+ diumumkan BENDAnya, bukan TUJUANnya, padahal ini kontrol pertama dalam urutan tab. -->
156
+ <router-link :to="{ name: 'dashboard' }" :aria-label="t('common.goToDashboard')" class="flex items-center shrink-0">
157
+ <div
158
+ class="w-8 h-8 flex items-center justify-center shrink-0"
159
+ :class="authStore.brandingLogo ? '' : 'rounded-full bg-white overflow-hidden'"
160
+ >
161
+ <img
162
+ v-if="authStore.brandingLogo"
163
+ :src="authStore.brandingLogo"
164
+ class="w-full h-full object-contain"
165
+ alt="Logo"
166
+ >
167
+ <span
168
+ v-else
169
+ class="font-black text-xs leading-none"
170
+ :style="{ color: brandingInitials }"
171
+ >{{ authStore.companyInitials }}</span>
172
+ </div>
173
+ </router-link>
174
+
175
+ <div class="flex items-center gap-3">
176
+
177
+ <!-- Pill kecil berisi ikon utilities (dark mode, notif, my company, avatar) — dibesarkan
178
+ sedikit (py-1.5→py-2.5, ikon w-4→w-5) agar target tap lebih nyaman di mobile -->
179
+ <div class="bg-white text-gray-600 rounded-full px-3.5 py-2.5 flex items-center gap-3.5 shadow-md">
180
+
181
+ <!-- Gear Setelan Tenant mobile -->
182
+ <button v-if="settingsEntryRoute" @click="navigateToSelfSettings" class="hover:text-blue-600 transition-colors" :title="t('tab.company.settings')" :aria-label="t('tab.company.settings')">
183
+ <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
184
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
185
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
186
+ </svg>
187
+ </button>
188
+
189
+ <!-- Pencarian global mobile -->
190
+ <button :aria-label="t('search.title')" @click="openSearch" class="hover:text-blue-600 transition-colors" :title="t('search.title')" data-open-search>
191
+ <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-4.35-4.35M17 10.5a6.5 6.5 0 11-13 0 6.5 6.5 0 0113 0z"/></svg>
192
+ </button>
193
+
194
+ <!-- App Launcher mobile — grid 9-titik -->
195
+ <button :aria-label="t('launcher.title')" @click.stop="appStore.toggleAppLauncher()" class="hover:text-blue-600 transition-colors" :title="t('launcher.title')">
196
+ <svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
197
+ <circle cx="5" cy="5" r="1.6" /><circle cx="12" cy="5" r="1.6" /><circle cx="19" cy="5" r="1.6" />
198
+ <circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" />
199
+ <circle cx="5" cy="19" r="1.6" /><circle cx="12" cy="19" r="1.6" /><circle cx="19" cy="19" r="1.6" />
200
+ </svg>
201
+ </button>
202
+
203
+ <!-- Toggle dark mode mobile -->
204
+ <button :aria-label="t('common.toggleDark')" @click="appStore.toggleDarkMode()" class="hover:text-blue-600 transition-colors" :title="t('common.toggleDark')">
205
+ <!-- Ikon matahari saat dark mode aktif -->
206
+ <svg v-if="appStore.darkMode" class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
207
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
208
+ </svg>
209
+ <!-- Ikon bulan saat mode terang aktif -->
210
+ <svg v-else class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
211
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
212
+ </svg>
213
+ </button>
214
+
215
+ <!-- Ikon lonceng notifikasi mobile — membuka AppNotificationDrawer -->
216
+ <button :aria-label="t('notification.title')" @click.stop="appStore.toggleNotification()" class="relative hover:text-blue-600 transition-colors">
217
+ <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
218
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
219
+ </svg>
220
+ <span
221
+ v-if="unreadCount > 0"
222
+ class="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none"
223
+ >{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
224
+ </button>
225
+
226
+ <!-- Language switcher mobile (compact + navbar: popup fixed di bawah navbar) -->
227
+ <AppLanguageSwitcher compact navbar />
228
+
229
+ <!-- Perusahaan Saya — HANYA System Owner; user tenant memilikinya di sidebar. -->
230
+ <router-link
231
+ v-if="myCompanyItem"
232
+ :to="myCompanyItem.to"
233
+ class="hover:text-blue-600 transition-colors"
234
+ :title="myCompanyItem.label"
235
+ :aria-label="myCompanyItem.label"
236
+ >
237
+ <component :is="myCompanyItem.icon" class="w-5 h-5" />
238
+ </router-link>
239
+
240
+ </div>
241
+
242
+ <!-- Tombol hamburger untuk membuka sidebar -->
243
+ <!-- Kunci chrome hidup di `common.*`, BUKAN `nav.*`: blok `nav`
244
+ berisi label menu milik tiap app dan isinya memang beda per app — kunci chrome
245
+ yang ditaruh di sana tak akan pernah terdeteksi hilang oleh pemeriksa locale. -->
246
+ <button :aria-label="t('common.openMenu')" @click="appStore.openSidebar()" class="hover:opacity-70 transition-opacity">
247
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
248
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
249
+ </svg>
250
+ </button>
251
+ </div>
252
+ </div>
253
+
254
+ <!-- ── Pulau utilitas (lg ke atas) ─────────────────────────────────── -->
255
+ <!-- ── Layar lebar: pulau breadcrumb (kiri) ─────────────────────────── -->
256
+ <!-- `min-w-0` + `flex-1`: jejak panjang menggulung di dalam pulau (gulungan mendatar
257
+ milik AppBreadcrumb) alih-alih mendorong pulau utilitas keluar layar. -->
258
+ <div
259
+ v-if="isLg"
260
+ class="pointer-events-auto flex min-w-0 flex-1 h-12 bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 items-center px-4"
261
+ >
262
+ <AppBreadcrumb :breadcrumbs="breadcrumbs" fade-overflow class="!mb-0 min-w-0" />
263
+ </div>
264
+
265
+ <div class="pointer-events-auto hidden lg:flex shrink-0 ml-auto h-12 bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 items-center px-2">
266
+
267
+ <!-- Utilitas kanan (fullscreen, dark mode, notif, language switcher, my company, profil) -->
268
+ <!-- Semua ikon (fullscreen s.d. my company) pakai AppTooltip agar UX hover lebih jelas -->
269
+ <div class="flex items-center gap-1 shrink-0">
270
+
271
+ <!-- Pencarian global — juga lewat Ctrl/⌘+K -->
272
+ <AppTooltip placement="bottom" :text="t('search.tooltip')">
273
+ <button :aria-label="t('search.title')" @click="openSearch" class="w-8 h-8 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" data-open-search>
274
+ <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-4.35-4.35M17 10.5a6.5 6.5 0 11-13 0 6.5 6.5 0 0113 0z"/></svg>
275
+ </button>
276
+ </AppTooltip>
277
+
278
+ <!-- Tombol fullscreen browser -->
279
+ <AppTooltip placement="bottom" :text="t('common.fullscreen')">
280
+ <button :aria-label="t('common.fullscreen')" @click="toggleFullscreen" class="w-8 h-8 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">
281
+ <!-- Ikon expand saat belum fullscreen -->
282
+ <svg v-if="!isFullscreen" class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
283
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
284
+ </svg>
285
+ <!-- Ikon compress saat fullscreen aktif -->
286
+ <svg v-else class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
287
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"/>
288
+ </svg>
289
+ </button>
290
+ </AppTooltip>
291
+
292
+ <!-- Toggle dark mode desktop -->
293
+ <AppTooltip placement="bottom" :text="t('common.toggleDark')">
294
+ <button :aria-label="t('common.toggleDark')" @click="appStore.toggleDarkMode()" class="w-8 h-8 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">
295
+ <svg v-if="appStore.darkMode" class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
296
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
297
+ </svg>
298
+ <svg v-else class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
299
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
300
+ </svg>
301
+ </button>
302
+ </AppTooltip>
303
+
304
+ <!-- Tombol notifikasi desktop — membuka AppNotificationPopup -->
305
+ <AppTooltip placement="bottom" :text="t('notification.title')">
306
+ <button :aria-label="t('notification.title')"
307
+ @click.stop="appStore.toggleNotification()"
308
+ class="relative w-8 h-8 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"
309
+ >
310
+ <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
311
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
312
+ </svg>
313
+ <span
314
+ v-if="unreadCount > 0"
315
+ class="absolute top-0.5 right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none"
316
+ >{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
317
+ </button>
318
+ </AppTooltip>
319
+
320
+ <!-- Language switcher desktop (navbar: popup fixed di bawah navbar) -->
321
+ <AppTooltip placement="bottom" :text="t('lang.switchTitle')">
322
+ <AppLanguageSwitcher navbar />
323
+ </AppTooltip>
324
+
325
+ <!-- Perusahaan Saya — HANYA System Owner (di antara language switcher & avatar); user tenant
326
+ memilikinya di sidebar. -->
327
+ <AppTooltip v-if="myCompanyItem" placement="bottom" :text="myCompanyItem.label">
328
+ <router-link
329
+ :to="myCompanyItem.to"
330
+ :aria-label="myCompanyItem.label"
331
+ :aria-current="myCompanyItem.active ? 'page' : undefined"
332
+ class="w-8 h-8 flex items-center justify-center rounded-full transition-colors"
333
+ :class="myCompanyItem.active
334
+ ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
335
+ : 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
336
+ >
337
+ <component :is="myCompanyItem.icon" class="w-[18px] h-[18px]" />
338
+ </router-link>
339
+ </AppTooltip>
340
+
341
+ <!-- Gear Setelan Tenant: hanya bila app memasok tenantSettingsEntryRoute. -->
342
+ <AppTooltip v-if="settingsEntryRoute" placement="bottom" :text="t('tab.company.settings')">
343
+ <button
344
+ @click="navigateToSelfSettings"
345
+ :aria-label="t('tab.company.settings')"
346
+ class="w-8 h-8 flex items-center justify-center rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
347
+ >
348
+ <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
349
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
350
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
351
+ </svg>
352
+ </button>
353
+ </AppTooltip>
354
+
355
+ <!-- App Launcher — grid 9-titik ala Google, popup lompat cepat lintas app -->
356
+ <AppTooltip placement="bottom" :text="t('launcher.title')">
357
+ <button :aria-label="t('launcher.title')"
358
+ @click.stop="appStore.toggleAppLauncher()"
359
+ class="w-8 h-8 flex items-center justify-center rounded-full transition-colors"
360
+ :class="appStore.showAppLauncher
361
+ ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
362
+ : 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
363
+ >
364
+ <svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
365
+ <circle cx="5" cy="5" r="1.6" /><circle cx="12" cy="5" r="1.6" /><circle cx="19" cy="5" r="1.6" />
366
+ <circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" />
367
+ <circle cx="5" cy="19" r="1.6" /><circle cx="12" cy="19" r="1.6" /><circle cx="19" cy="19" r="1.6" />
368
+ </svg>
369
+ </button>
370
+ </AppTooltip>
371
+
372
+ <!-- Tombol avatar user desktop — membuka AppUserMenu. Nama hanya di ≥xl: di 1024–1279 px
373
+ lebarnya dipakai pulau breadcrumb. Nama aksesibel memuat nama yang terlihat, supaya
374
+ perintah suara "klik <nama>" tetap mengenai tombol ini. -->
375
+ <button
376
+ :aria-label="userDisplayName ? `${t('user.profile')}: ${userDisplayName}` : t('user.profile')"
377
+ @click.stop="appStore.toggleUserMenu()"
378
+ class="flex items-center gap-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 py-1 pl-1 pr-2 transition-colors ml-1"
379
+ >
380
+ <div class="w-7 h-7 rounded-full bg-blue-600 text-white flex items-center justify-center text-xs font-bold shrink-0 overflow-hidden">
381
+ <img v-if="userAvatar && !avatarError" :src="userAvatar" @error="avatarError = true" class="w-full h-full object-cover" alt="">
382
+ <span v-else>{{ userInitials }}</span>
383
+ </div>
384
+ <span v-if="userDisplayName" class="hidden xl:inline max-w-40 truncate text-sm font-medium text-gray-700 dark:text-gray-200">{{ userDisplayName }}</span>
385
+ <svg class="w-3 h-3 text-gray-400 transition-transform" :class="{ 'rotate-180': appStore.showUserMenu }" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
386
+ <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
387
+ </svg>
388
+ </button>
389
+ </div>
390
+ </div>
391
+
392
+ </div>
393
+ </div>
394
+ </template>
395
+
396
+ <style scoped>
397
+ /* Nilai `--logo-pill-solid` disuntikkan lewat `:style` di elemen .logo-pill (lihat catatan di template);
398
+ fallback #363636 supaya pil tak pernah tembus pandang seandainya variabelnya tak terpasang. */
399
+ .logo-pill {
400
+ background-color: var(--logo-pill-solid, #363636);
401
+ }
402
+ </style>
@@ -0,0 +1,173 @@
1
+ <script setup>
2
+ import { onMounted, watch } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { Bell, Loader2, MessageSquareText, AlertTriangle, LifeBuoy, ListChecks } from '@lucide/vue'
5
+ import { useI18n } from 'vue-i18n'
6
+ import { useNotifications } from '@initsolusidigital/worksuite-core/composables/useNotifications.js'
7
+ import { useChat } from '@initsolusidigital/worksuite-core/composables/useChat.js'
8
+ import { useCrossAppLink } from '@initsolusidigital/worksuite-core/composables/useCrossAppLink.js'
9
+ import { useAppStore } from '@initsolusidigital/worksuite-core/stores/app.js'
10
+ import { notificationTitle, notificationBody } from '@initsolusidigital/worksuite-core/utils/notificationText.js'
11
+
12
+ const { t, locale } = useI18n()
13
+ const router = useRouter()
14
+ const appStore = useAppStore()
15
+ const { openConversationFromNotification } = useChat()
16
+ // Katalog sub-app (singleton) — dipakai penahan tautan lintas-app di onItemClick().
17
+ const { fetchApps } = useCrossAppLink()
18
+ // Pakai singleton state — tab dan data dibagikan dengan popup & drawer
19
+ const {
20
+ activeTab, tabs, filteredNotifications, loading, hasMore,
21
+ fetchNotifications, fetchMore, markAsRead,
22
+ } = useNotifications()
23
+
24
+ // ── Judul/isi notifikasi: i18n by `type` + variabel dari `data` ───────────────
25
+ // Resolvernya hidup di `utils/notificationText.js` — DIPAKAI BERSAMA dengan toast real-time
26
+ // (`useNotifications.js`). Selama ia privat di berkas ini, toast tak punya akses ke sana dan
27
+ // terpaksa memakai `title`/`body` mentah, sehingga permukaan yang paling sering dilihat justru
28
+ // yang selalu berbahasa Inggris. Alasan & tiga pagarnya ditulis lengkap di util itu.
29
+ const notifTitle = notificationTitle
30
+ const notifBody = notificationBody
31
+
32
+ // Fetch tiap kali panel dibuka — parent (Popup/Drawer) mount ulang komponen ini
33
+ // via v-if setiap kali appStore.showNotification jadi true, jadi data selalu segar.
34
+ // Katalog app ikut dimuat ULANG di sini: penahan tautan lintas-app memakai status app saat item
35
+ // diklik, dan status itu berubah selagi halaman terbuka.
36
+ onMounted(() => {
37
+ fetchNotifications()
38
+ fetchApps(true)
39
+ })
40
+ watch(activeTab, fetchNotifications)
41
+
42
+ // Ikon per tipe notifikasi (fallback Bell). backup_alert/mass_deletion_alert = peringatan,
43
+ // chat_message/chat_access_request = pesan. backup_alert TIDAK ada deep-link (backend tidak
44
+ // kirim `data` sama sekali utk tipe ini, murni alarm infra System Owner, tidak ada halaman
45
+ // tujuan) — tetap klik-utk-mark-read saja, sengaja generic.
46
+ function iconFor(type) {
47
+ if (type === 'chat_message' || type === 'chat_access_request') return MessageSquareText
48
+ if (type === 'backup_alert' || type === 'mass_deletion_alert' || type === 'app_status_alert') return AlertTriangle
49
+ // Permintaan akses dukungan MENUNTUT keputusan user; ikon lonceng generik membuatnya terbaca
50
+ // sebagai kabar biasa yang boleh diabaikan.
51
+ if (type === 'impersonation_request' || type === 'impersonation_resolved') return LifeBuoy
52
+ // Penugasan & komentar task — alasan yang sama dengan tiga baris di atas:
53
+ // keduanya menuntut tindakan orangnya, dan lonceng generik membuatnya terbaca sebagai kabar
54
+ // lewat. Notifikasi ini mendarat di app MANA PUN (tabel platform-wide), jadi ikonnya sering
55
+ // jadi satu-satunya petunjuk cepat bahwa ini soal pekerjaan di Project.
56
+ if (type === 'project_task_assigned' || type === 'project_task_comment') return ListChecks
57
+ return Bell
58
+ }
59
+ function iconClass(type) {
60
+ if (type === 'backup_alert' || type === 'mass_deletion_alert' || type === 'app_status_alert') return 'text-amber-500'
61
+ if (type === 'chat_message' || type === 'chat_access_request') return 'text-blue-500'
62
+ if (type === 'impersonation_request' || type === 'impersonation_resolved') return 'text-violet-500'
63
+ if (type === 'project_task_assigned' || type === 'project_task_comment') return 'text-emerald-500'
64
+ return 'text-gray-400 dark:text-gray-500'
65
+ }
66
+
67
+ // Identitas app ini sendiri — VITE_APP_KEY (lihat .env). Dipakai bareng target_app di bawah.
68
+ // Navigasi lintas-app hidup di `composables/useCrossAppLink.js` — kartu entitas chat
69
+ // butuh perilaku PERSIS sama, dan dua resolver berarti dua tempat yang harus ingat pagar N-A,
70
+ // peta origin, & aturan "app sendiri tak pernah digerbang". Yang kedua pasti tertinggal.
71
+ const { openTarget } = useCrossAppLink()
72
+
73
+ function onItemClick(notif) {
74
+ markAsRead(notif)
75
+ if ((notif.type === 'chat_message' || notif.type === 'chat_access_request') && notif.data?.conversation_id) {
76
+ appStore.closeNotification()
77
+ openConversationFromNotification(notif.data.conversation_id)
78
+ return
79
+ }
80
+ // `impersonation_request` — jalur LOKAL, bukan `target_app`/`target_path`, dan itu
81
+ // BUKAN pelanggaran kanon "backend yang menentukan tujuan" melainkan kasus yang di luar
82
+ // jangkauannya: halaman tujuannya `/profile/support-access` ada di SETIAP app (paket shell), jadi
83
+ // tak ada satu `target_app` yang benar — user harus mendarat di app yang sedang ia buka.
84
+ // Tipe chat di atas memakai jalur yang sama. ⚠️ Jangan menambah tipe lain ke jalur ini
85
+ // kecuali halamannya memang milik paket dan ada di semua app; selain itu, backend yang menentukan.
86
+ if (notif.type === 'impersonation_request') {
87
+ appStore.closeNotification()
88
+ router.push({ name: 'profile.supportAccess' })
89
+ return
90
+ }
91
+ openTarget(notif.data, { beforeNavigate: () => appStore.closeNotification() })
92
+ }
93
+
94
+ function formatTime(iso) {
95
+ return new Date(iso).toLocaleString(locale.value === 'id' ? 'id-ID' : 'en-US', {
96
+ dateStyle: 'medium',
97
+ timeStyle: 'short',
98
+ })
99
+ }
100
+ </script>
101
+
102
+ <template>
103
+ <!-- Wrapper flex column — mengisi sisa ruang di dalam popup maupun drawer -->
104
+ <div class="flex flex-col flex-1 overflow-hidden">
105
+
106
+ <!-- Tab bar filter notifikasi -->
107
+ <div class="flex border-b border-gray-100 dark:border-gray-800 shrink-0">
108
+ <button
109
+ v-for="tab in tabs"
110
+ :key="tab.key"
111
+ @click="activeTab = tab.key"
112
+ class="flex-1 py-2.5 text-xs font-medium transition-colors"
113
+ :class="activeTab === tab.key
114
+ ? 'text-blue-600 border-b-2 border-blue-600 dark:text-blue-400 dark:border-blue-400'
115
+ : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'"
116
+ >
117
+ {{ t(tab.labelKey) }}
118
+ </button>
119
+ </div>
120
+
121
+ <!-- Daftar notifikasi (scrollable) -->
122
+ <div class="flex-1 overflow-y-auto scrollbar-rounded">
123
+
124
+ <!-- Loading awal -->
125
+ <div v-if="loading && filteredNotifications.length === 0" class="flex items-center justify-center h-full py-12">
126
+ <Loader2 class="w-5 h-5 text-gray-300 dark:text-gray-600 animate-spin" />
127
+ </div>
128
+
129
+ <!-- Empty state -->
130
+ <div v-else-if="filteredNotifications.length === 0" class="flex flex-col items-center justify-center h-full py-12">
131
+ <Bell class="w-12 h-12 text-gray-200 dark:text-gray-700 mb-3" :stroke-width="1.5" />
132
+ <p class="text-sm font-medium text-gray-400 dark:text-gray-500">{{ t('notification.empty') }}</p>
133
+ <p class="text-xs text-gray-300 dark:text-gray-600 mt-1">{{ t('notification.emptyDesc') }}</p>
134
+ </div>
135
+
136
+ <!-- List item notifikasi -->
137
+ <template v-else>
138
+ <button
139
+ v-for="notif in filteredNotifications"
140
+ :key="notif.id"
141
+ @click="onItemClick(notif)"
142
+ class="w-full text-left px-4 py-3 border-b border-gray-50 dark:border-gray-800/60 transition-colors relative flex gap-2.5"
143
+ :class="notif.read_at ? 'hover:bg-gray-50 dark:hover:bg-gray-800/50' : 'bg-blue-50/60 dark:bg-blue-900/10 hover:bg-blue-50 dark:hover:bg-blue-900/20'"
144
+ >
145
+ <!-- Titik penanda belum dibaca -->
146
+ <span v-if="!notif.read_at" class="absolute left-1.5 top-4 w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
147
+ <!-- Ikon per tipe notifikasi -->
148
+ <component :is="iconFor(notif.type)" class="w-4 h-4 mt-0.5 shrink-0" :class="iconClass(notif.type)" />
149
+ <div class="flex-1 min-w-0">
150
+ <p
151
+ class="text-sm"
152
+ :class="notif.read_at ? 'text-gray-600 dark:text-gray-300 font-normal' : 'text-gray-800 dark:text-white font-semibold'"
153
+ >{{ notifTitle(notif) }}</p>
154
+ <p v-if="notifBody(notif)" class="text-xs text-gray-400 dark:text-gray-500 mt-0.5 line-clamp-2">{{ notifBody(notif) }}</p>
155
+ <p class="text-[11px] text-gray-300 dark:text-gray-600 mt-1">{{ formatTime(notif.created_at) }}</p>
156
+ </div>
157
+ </button>
158
+
159
+ <!-- Muat lebih banyak -->
160
+ <div v-if="hasMore" class="p-3 text-center">
161
+ <button
162
+ @click="fetchMore"
163
+ :disabled="loading"
164
+ class="text-xs text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50 transition-colors"
165
+ >
166
+ {{ loading ? t('common.loading') : t('notification.loadMore') }}
167
+ </button>
168
+ </div>
169
+ </template>
170
+
171
+ </div>
172
+ </div>
173
+ </template>