@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,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>
|