@initsolusidigital/worksuite-shell 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +122 -2
- package/package.json +44 -4
- package/src/components/ai/AppAssistantDraft.vue +120 -0
- package/src/components/ai/AppAssistantWidget.vue +245 -0
- package/src/components/ai/AppCitedAnswer.vue +86 -0
- package/src/components/approvals/AppApprovalInbox.vue +265 -0
- package/src/components/approvals/AppApprovalSubjectModal.vue +197 -0
- package/src/components/chat/AppChatBubble.vue +324 -0
- package/src/components/chat/AppChatList.vue +249 -0
- package/src/components/chat/AppChatPanel.vue +315 -0
- package/src/components/chat/AppChatWidget.vue +126 -0
- package/src/components/communications/AppChannelBadge.vue +47 -0
- package/src/components/communications/AppConversationThread.vue +439 -0
- package/src/components/customFields/CustomFieldFilterPanel.vue +50 -0
- package/src/components/customFields/CustomFieldInput.vue +422 -0
- package/src/components/customFields/CustomFieldValue.vue +129 -0
- package/src/components/customFields/CustomFieldsSection.vue +65 -0
- package/src/components/customFields/fieldTypes.js +23 -0
- package/src/components/dashboard/AppCustomWidget.vue +138 -0
- package/src/components/dashboard/AppCustomWidgetForm.vue +173 -0
- package/src/components/dashboard/AppDashboardGrid.vue +200 -0
- package/src/components/dashboard/AppDashboardToolbar.vue +66 -0
- package/src/components/dashboard/AppSubAppsCatalog.vue +89 -0
- package/src/components/dataTools/AppImportExport.vue +724 -0
- package/src/components/dataTools/AppImportSchemaGuide.vue +130 -0
- package/src/components/hrMetrics/AppHrMetricsDashboard.vue +231 -0
- package/src/components/hrMetrics/AppHrMetricsEmployees.vue +87 -0
- package/src/components/hrMetrics/AppHrMetricsStat.vue +61 -0
- package/src/components/kpi/AppKpiAppraisalConfig.vue +284 -0
- package/src/components/kpi/AppKpiAppraisals.vue +733 -0
- package/src/components/kpi/AppKpiDefinitionModal.vue +317 -0
- package/src/components/kpi/AppKpiEntryModal.vue +122 -0
- package/src/components/kpi/AppKpiLeaderboard.vue +108 -0
- package/src/components/kpi/AppKpiMyCard.vue +134 -0
- package/src/components/kpi/AppKpiPeriodPicker.vue +115 -0
- package/src/components/kpi/AppKpiReportTable.vue +214 -0
- package/src/components/kpi/AppKpiScorecard.vue +242 -0
- package/src/components/kpi/AppKpiTargetsModal.vue +299 -0
- package/src/components/kpi/AppKpiTeam.vue +221 -0
- package/src/components/kpi/AppKpiWeightProfiles.vue +244 -0
- package/src/components/kpi/AppKpiWorkspace.vue +479 -0
- package/src/components/layout/AppGlobalSearch.vue +149 -0
- package/src/components/layout/AppLanguagePopup.vue +94 -0
- package/src/components/layout/AppLauncherPopup.vue +147 -0
- package/src/components/layout/AppLegalConsentModal.vue +484 -0
- package/src/components/layout/AppNavbar.vue +402 -0
- package/src/components/layout/AppNotificationContent.vue +173 -0
- package/src/components/layout/AppNotificationDrawer.vue +98 -0
- package/src/components/layout/AppNotificationPopup.vue +67 -0
- package/src/components/layout/AppPlatformBanners.vue +192 -0
- package/src/components/layout/AppSidebar.vue +226 -0
- package/src/components/layout/AppSidebarNav.vue +119 -0
- package/src/components/layout/AppUserMenu.vue +89 -0
- package/src/components/layout/AppUserMenuContent.vue +59 -0
- package/src/composables/useKpi.js +232 -0
- package/src/composables/useLgViewport.js +25 -0
- package/src/composables/useNavigation.js +177 -0
- package/src/layouts/AppLayout.vue +207 -0
- package/src/locales/en.json +1362 -0
- package/src/locales/id.json +1396 -0
- package/src/pages/auth/AccountBlocked.vue +55 -0
- package/src/pages/auth/IdentityRedirect.vue +37 -0
- package/src/pages/profile/Layout.vue +210 -0
- package/src/pages/profile/partials/AccountSection.vue +139 -0
- package/src/pages/profile/partials/ApprovalDelegationSection.vue +389 -0
- package/src/pages/profile/partials/DevicesCard.vue +222 -0
- package/src/pages/profile/partials/PreferencesSection.vue +87 -0
- package/src/pages/profile/partials/SecuritySection.vue +93 -0
- package/src/pages/profile/partials/SessionsCard.vue +418 -0
- package/src/pages/profile/partials/SessionsSection.vue +58 -0
- package/src/pages/profile/partials/SupportAccessSection.vue +173 -0
- package/src/pages/profile/partials/TrustedDevicesCard.vue +141 -0
- package/src/pages/profile/partials/TwoFactorSection.vue +304 -0
- package/src/shellSetup.js +46 -0
|
@@ -0,0 +1,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>
|