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