@initsolusidigital/worksuite-shell 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +122 -2
  3. package/package.json +44 -4
  4. package/src/components/ai/AppAssistantDraft.vue +120 -0
  5. package/src/components/ai/AppAssistantWidget.vue +245 -0
  6. package/src/components/ai/AppCitedAnswer.vue +86 -0
  7. package/src/components/approvals/AppApprovalInbox.vue +265 -0
  8. package/src/components/approvals/AppApprovalSubjectModal.vue +197 -0
  9. package/src/components/chat/AppChatBubble.vue +324 -0
  10. package/src/components/chat/AppChatList.vue +249 -0
  11. package/src/components/chat/AppChatPanel.vue +315 -0
  12. package/src/components/chat/AppChatWidget.vue +126 -0
  13. package/src/components/communications/AppChannelBadge.vue +47 -0
  14. package/src/components/communications/AppConversationThread.vue +439 -0
  15. package/src/components/customFields/CustomFieldFilterPanel.vue +50 -0
  16. package/src/components/customFields/CustomFieldInput.vue +422 -0
  17. package/src/components/customFields/CustomFieldValue.vue +129 -0
  18. package/src/components/customFields/CustomFieldsSection.vue +65 -0
  19. package/src/components/customFields/fieldTypes.js +23 -0
  20. package/src/components/dashboard/AppCustomWidget.vue +138 -0
  21. package/src/components/dashboard/AppCustomWidgetForm.vue +173 -0
  22. package/src/components/dashboard/AppDashboardGrid.vue +200 -0
  23. package/src/components/dashboard/AppDashboardToolbar.vue +66 -0
  24. package/src/components/dashboard/AppSubAppsCatalog.vue +89 -0
  25. package/src/components/dataTools/AppImportExport.vue +724 -0
  26. package/src/components/dataTools/AppImportSchemaGuide.vue +130 -0
  27. package/src/components/hrMetrics/AppHrMetricsDashboard.vue +231 -0
  28. package/src/components/hrMetrics/AppHrMetricsEmployees.vue +87 -0
  29. package/src/components/hrMetrics/AppHrMetricsStat.vue +61 -0
  30. package/src/components/kpi/AppKpiAppraisalConfig.vue +284 -0
  31. package/src/components/kpi/AppKpiAppraisals.vue +733 -0
  32. package/src/components/kpi/AppKpiDefinitionModal.vue +317 -0
  33. package/src/components/kpi/AppKpiEntryModal.vue +122 -0
  34. package/src/components/kpi/AppKpiLeaderboard.vue +108 -0
  35. package/src/components/kpi/AppKpiMyCard.vue +134 -0
  36. package/src/components/kpi/AppKpiPeriodPicker.vue +115 -0
  37. package/src/components/kpi/AppKpiReportTable.vue +214 -0
  38. package/src/components/kpi/AppKpiScorecard.vue +242 -0
  39. package/src/components/kpi/AppKpiTargetsModal.vue +299 -0
  40. package/src/components/kpi/AppKpiTeam.vue +221 -0
  41. package/src/components/kpi/AppKpiWeightProfiles.vue +244 -0
  42. package/src/components/kpi/AppKpiWorkspace.vue +479 -0
  43. package/src/components/layout/AppGlobalSearch.vue +149 -0
  44. package/src/components/layout/AppLanguagePopup.vue +94 -0
  45. package/src/components/layout/AppLauncherPopup.vue +147 -0
  46. package/src/components/layout/AppLegalConsentModal.vue +484 -0
  47. package/src/components/layout/AppNavbar.vue +402 -0
  48. package/src/components/layout/AppNotificationContent.vue +173 -0
  49. package/src/components/layout/AppNotificationDrawer.vue +98 -0
  50. package/src/components/layout/AppNotificationPopup.vue +67 -0
  51. package/src/components/layout/AppPlatformBanners.vue +192 -0
  52. package/src/components/layout/AppSidebar.vue +226 -0
  53. package/src/components/layout/AppSidebarNav.vue +119 -0
  54. package/src/components/layout/AppUserMenu.vue +89 -0
  55. package/src/components/layout/AppUserMenuContent.vue +59 -0
  56. package/src/composables/useKpi.js +232 -0
  57. package/src/composables/useLgViewport.js +25 -0
  58. package/src/composables/useNavigation.js +177 -0
  59. package/src/layouts/AppLayout.vue +207 -0
  60. package/src/locales/en.json +1362 -0
  61. package/src/locales/id.json +1396 -0
  62. package/src/pages/auth/AccountBlocked.vue +55 -0
  63. package/src/pages/auth/IdentityRedirect.vue +37 -0
  64. package/src/pages/profile/Layout.vue +210 -0
  65. package/src/pages/profile/partials/AccountSection.vue +139 -0
  66. package/src/pages/profile/partials/ApprovalDelegationSection.vue +389 -0
  67. package/src/pages/profile/partials/DevicesCard.vue +222 -0
  68. package/src/pages/profile/partials/PreferencesSection.vue +87 -0
  69. package/src/pages/profile/partials/SecuritySection.vue +93 -0
  70. package/src/pages/profile/partials/SessionsCard.vue +418 -0
  71. package/src/pages/profile/partials/SessionsSection.vue +58 -0
  72. package/src/pages/profile/partials/SupportAccessSection.vue +173 -0
  73. package/src/pages/profile/partials/TrustedDevicesCard.vue +141 -0
  74. package/src/pages/profile/partials/TwoFactorSection.vue +304 -0
  75. package/src/shellSetup.js +46 -0
@@ -0,0 +1,119 @@
1
+ <script setup>
2
+ import { useI18n } from 'vue-i18n'
3
+ import { ChevronDown } from '@lucide/vue'
4
+ import { useAppStore } from '@initsolusidigital/worksuite-core/stores/app.js'
5
+ import AppTooltip from '@initsolusidigital/worksuite-ui/AppTooltip.vue'
6
+
7
+ const props = defineProps({
8
+ // [{ key, label, items: [{ labelKey, label, icon, to, active, badge, children?, childActive? }] }]
9
+ groups: { type: Array, required: true },
10
+ collapsed: { type: Boolean, default: false },
11
+ // Sidebar desktop punya dua <nav> (tengah + kaki); nama yang sama membuat keduanya tak bisa
12
+ // dibedakan pembaca layar maupun `getByRole('navigation', { name })`.
13
+ ariaLabel: { type: String, default: '' },
14
+ })
15
+ const emit = defineEmits(['navigate'])
16
+
17
+ const { t } = useI18n()
18
+ const appStore = useAppStore()
19
+
20
+ // Kunci absen = ikut keadaan aktif, supaya akordeon yang memuat halaman sekarang terbuka sendiri
21
+ // tanpa menimpa pilihan pengguna yang sudah menutupnya.
22
+ function isOpen(item) {
23
+ return appStore.sidebarOpenGroups[item.labelKey] ?? !!item.childActive
24
+ }
25
+
26
+ function onParentClick(item) {
27
+ // Rail tak punya ruang untuk anak: klik memperluas sidebar lalu membuka akordeonnya.
28
+ if (props.collapsed) {
29
+ appStore.toggleSidebarCollapsed()
30
+ appStore.sidebarOpenGroups[item.labelKey] = true
31
+ return
32
+ }
33
+ appStore.sidebarOpenGroups[item.labelKey] = !isOpen(item)
34
+ }
35
+
36
+ function linkClass(active) {
37
+ return active
38
+ ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
39
+ : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
40
+ }
41
+ function badgeText(n) {
42
+ return n > 99 ? '99+' : n
43
+ }
44
+ </script>
45
+
46
+ <template>
47
+ <nav :aria-label="ariaLabel || t('common.mainNav')" class="flex flex-col gap-6">
48
+ <div v-for="group in groups" :key="group.key">
49
+ <p
50
+ v-if="group.label && !collapsed"
51
+ class="px-3 mb-1 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 truncate"
52
+ >{{ group.label }}</p>
53
+ <!-- Rail kehilangan label grup: garis tipis menjaga pengelompokannya tetap terbaca. -->
54
+ <div v-else-if="group.label" class="mx-3 mb-2 border-t border-gray-200 dark:border-gray-800" aria-hidden="true"></div>
55
+
56
+ <ul class="space-y-0.5">
57
+ <li v-for="item in group.items" :key="item.labelKey">
58
+ <AppTooltip class="w-full" placement="right" :text="collapsed ? item.label : ''">
59
+ <!-- `custom` + <a> sendiri: `aria-current` bawaan RouterLink hanya menyala pada rute yang
60
+ PERSIS sama, sedangkan item menu juga aktif di halaman turunannya. -->
61
+ <router-link v-if="!item.children" v-slot="{ href, navigate }" :to="item.to" custom>
62
+ <a
63
+ :href="href"
64
+ :aria-current="item.active ? 'page' : undefined"
65
+ class="relative w-full h-10 flex items-center gap-3 rounded-lg px-3 text-sm font-medium transition-colors"
66
+ :class="[linkClass(item.active), collapsed ? 'justify-center' : '']"
67
+ @click="(e) => { navigate(e); emit('navigate') }"
68
+ >
69
+ <component :is="item.icon" class="w-5 h-5 shrink-0" aria-hidden="true" />
70
+ <span :class="collapsed ? 'sr-only' : 'truncate'">{{ item.label }}</span>
71
+ <span
72
+ v-if="item.badge > 0"
73
+ class="inline-flex items-center justify-center rounded-full bg-red-500 text-white font-semibold leading-none"
74
+ :class="collapsed ? 'absolute top-1 right-1 h-4 min-w-4 px-1 text-[10px]' : 'ml-auto h-5 min-w-5 px-1.5 text-[11px]'"
75
+ :aria-label="t('common.pendingCount', { n: item.badge })"
76
+ >{{ badgeText(item.badge) }}</span>
77
+ </a>
78
+ </router-link>
79
+
80
+ <!-- Item ber-`children`: akordeon satu tingkat. -->
81
+ <button
82
+ v-else
83
+ type="button"
84
+ :aria-expanded="collapsed ? undefined : isOpen(item)"
85
+ class="w-full h-10 flex items-center gap-3 rounded-lg px-3 text-sm font-medium transition-colors"
86
+ :class="[linkClass(item.childActive && (collapsed || !isOpen(item))), collapsed ? 'justify-center' : '']"
87
+ @click="onParentClick(item)"
88
+ >
89
+ <component :is="item.icon" class="w-5 h-5 shrink-0" aria-hidden="true" />
90
+ <span :class="collapsed ? 'sr-only' : 'flex-1 truncate text-left'">{{ item.label }}</span>
91
+ <ChevronDown
92
+ v-if="!collapsed"
93
+ class="w-4 h-4 shrink-0 text-gray-400 transition-transform"
94
+ :class="{ 'rotate-180': isOpen(item) }"
95
+ aria-hidden="true"
96
+ />
97
+ </button>
98
+ </AppTooltip>
99
+
100
+ <ul v-if="item.children && !collapsed && isOpen(item)" class="mt-0.5 space-y-0.5">
101
+ <li v-for="child in item.children" :key="child.labelKey">
102
+ <router-link v-slot="{ href, navigate }" :to="child.to" custom>
103
+ <a
104
+ :href="href"
105
+ :aria-current="child.active ? 'page' : undefined"
106
+ class="w-full h-9 flex items-center rounded-lg pl-11 pr-3 text-sm transition-colors"
107
+ :class="[linkClass(child.active), child.active ? 'font-medium' : '']"
108
+ @click="(e) => { navigate(e); emit('navigate') }"
109
+ >
110
+ <span class="truncate">{{ child.label }}</span>
111
+ </a>
112
+ </router-link>
113
+ </li>
114
+ </ul>
115
+ </li>
116
+ </ul>
117
+ </div>
118
+ </nav>
119
+ </template>
@@ -0,0 +1,89 @@
1
+ <script setup>
2
+ import { ref, computed } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { useAppStore } from '@initsolusidigital/worksuite-core/stores/app.js'
5
+ import { useDismissOnEscape } from '@initsolusidigital/worksuite-core/composables/useDismissOnEscape.js'
6
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
7
+ import AppUserMenuContent from './AppUserMenuContent.vue'
8
+
9
+ const { t } = useI18n()
10
+
11
+ const appStore = useAppStore()
12
+
13
+ // Escape menutup popup ini — mengalah bila ada dialog di atasnya (lihat composable).
14
+ useDismissOnEscape(() => appStore.showUserMenu, () => appStore.closeUserMenu())
15
+ const authStore = useAuthStore()
16
+
17
+ // ── Data user untuk header popup ─────────────────────────────────────────────
18
+ const userInitials = computed(() => {
19
+ const first = authStore.user?.first_name?.charAt(0)?.toUpperCase() || ''
20
+ const last = authStore.user?.last_name?.charAt(0)?.toUpperCase() || ''
21
+ return first + last || 'U'
22
+ })
23
+ // Backend selalu mengirim avatar sebagai absolute URL siap pakai
24
+ const userAvatar = computed(() => authStore.user?.avatar ?? null)
25
+ const avatarError = ref(false)
26
+ const userName = computed(() => {
27
+ const first = authStore.user?.first_name || ''
28
+ const last = authStore.user?.last_name || ''
29
+ return [first, last].filter(Boolean).join(' ') || t('user.fallback')
30
+ })
31
+ const userEmail = computed(() => authStore.user?.email || '')
32
+ const userUsername = computed(() => authStore.user?.username || '')
33
+ </script>
34
+
35
+ <template>
36
+ <!-- Backdrop transparan — menutup popup saat klik di luar panel -->
37
+ <div
38
+ v-if="appStore.showUserMenu"
39
+ class="fixed inset-0 z-[55]"
40
+ @click="appStore.closeUserMenu()"
41
+ />
42
+
43
+ <!-- Wrapper posisi — mirror navbar (px-4 + max-w-7xl) agar sejajar batas kanan navbar -->
44
+ <div class="fixed top-[88px] lg:top-[72px] left-0 right-0 z-[60] pointer-events-none">
45
+ <div class="w-full px-4 sm:px-6 lg:px-8 flex justify-end pointer-events-none">
46
+
47
+ <!-- Panel popup user menu desktop -->
48
+ <Transition name="fade-down">
49
+ <div
50
+ v-if="appStore.showUserMenu"
51
+ class="pointer-events-auto w-64 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-100 dark:border-gray-700 overflow-hidden"
52
+ >
53
+ <!-- Header: avatar + nama + email dengan gradient gelap -->
54
+ <div class="px-4 py-4 bg-gradient-to-br from-[#363636] to-gray-700 text-white flex items-center gap-3">
55
+ <div class="w-10 h-10 rounded-full bg-blue-500 text-white flex items-center justify-center text-base font-bold shrink-0 ring-2 ring-white/20 overflow-hidden">
56
+ <img v-if="userAvatar && !avatarError" :src="userAvatar" @error="avatarError = true" class="w-full h-full object-cover" alt="">
57
+ <span v-else>{{ userInitials }}</span>
58
+ </div>
59
+ <div class="min-w-0">
60
+ <p class="text-sm font-bold truncate">{{ userName }}</p>
61
+ <p class="text-xs text-gray-300 truncate">{{ userEmail }}</p>
62
+ <span
63
+ v-if="userUsername"
64
+ class="mt-1.5 inline-block text-[10px] font-medium bg-white/15 text-white/90 px-2 py-0.5 rounded-full leading-none"
65
+ >@{{ userUsername }}</span>
66
+ </div>
67
+ </div>
68
+
69
+ <!-- Menu items bersama — cukup ubah AppUserMenuContent untuk kedua platform -->
70
+ <AppUserMenuContent />
71
+ </div>
72
+ </Transition>
73
+
74
+ </div>
75
+ </div>
76
+ </template>
77
+
78
+ <style scoped>
79
+ /* Animasi popup muncul dari atas dengan sedikit geser ke bawah */
80
+ .fade-down-enter-active,
81
+ .fade-down-leave-active {
82
+ transition: opacity 0.2s ease, transform 0.2s ease;
83
+ }
84
+ .fade-down-enter-from,
85
+ .fade-down-leave-to {
86
+ opacity: 0;
87
+ transform: translateY(-8px);
88
+ }
89
+ </style>
@@ -0,0 +1,59 @@
1
+ <script setup>
2
+ import { useRouter } from 'vue-router'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
5
+ import { useAppStore } from '@initsolusidigital/worksuite-core/stores/app.js'
6
+ import { LogOut, UserCog } from '@lucide/vue'
7
+ import { redirectToIdentityLogin } from '@initsolusidigital/worksuite-core/utils/loginRedirect.js'
8
+ import { getLogoutHandler } from '../../shellSetup.js'
9
+
10
+ const router = useRouter()
11
+ const { t } = useI18n()
12
+ const authStore = useAuthStore()
13
+ const appStore = useAppStore()
14
+
15
+ // Logout: tutup menu, hapus state sesi, lalu pindah halaman. Bawaannya FULL PAGE ke pintu
16
+ // identitas, sebab layar login tak ada di app staf; pintu tak diketahui (dev per-port tanpa
17
+ // VITE_AUTH_URL) → rute login lokal yang memperlihatkan keadaan itu. App yang butuh langkah
18
+ // sendiri (membersihkan store miliknya, atau pintu masuk yang berbeda) memasok `onLogout`.
19
+ function redirect() {
20
+ if (!redirectToIdentityLogin()) router.push({ name: 'login' }).catch(() => {})
21
+ }
22
+
23
+ async function logout() {
24
+ appStore.closeUserMenu()
25
+ await authStore.logout()
26
+ const onLogout = getLogoutHandler()
27
+ if (onLogout) onLogout({ router, redirect })
28
+ else redirect()
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <!-- Daftar menu aksi user — tambah item baru di sini -->
34
+ <div class="py-2">
35
+ <!-- Profil Saya → Setelan Akun (default tab Akun); perangkat & sesi ada di seksi
36
+ "Sesi & Perangkat" di dalam profil. Setelan Tenant tak ada di menu ini: hub memberinya
37
+ tombol gear sendiri di navbar. Item ini berikon karena tombol Keluar di bawah berikon —
38
+ tanpa ikon kedua baris menu tak sejajar. -->
39
+ <router-link
40
+ :to="{ name: 'profile.account' }"
41
+ class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
42
+ @click="appStore.closeUserMenu()"
43
+ >
44
+ <UserCog class="w-4 h-4 shrink-0" />
45
+ {{ t('user.profile') }}
46
+ </router-link>
47
+ </div>
48
+
49
+ <!-- Tombol logout — selalu di bawah, dipisah garis -->
50
+ <div class="border-t border-gray-100 dark:border-gray-700 py-2">
51
+ <button
52
+ @click="logout"
53
+ class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-left"
54
+ >
55
+ <LogOut class="w-4 h-4 shrink-0" />
56
+ {{ t('user.logout') }}
57
+ </button>
58
+ </div>
59
+ </template>
@@ -0,0 +1,232 @@
1
+ import { ref, computed } from 'vue'
2
+ import { useI18n } from 'vue-i18n'
3
+ import { kpiApi, kpiSubjectsApi } from '@initsolusidigital/worksuite-core/api/endpoints/kpi.js'
4
+ import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
5
+
6
+ /**
7
+ * State + aturan angka mesin KPI. Dipakai bersama seluruh permukaan KPI (HRIS tingkat 2, CRM &
8
+ * Project tingkat 1) supaya tiga app tak pernah menghitung atau merender angka yang sama dengan
9
+ * cara berbeda.
10
+ *
11
+ * ⚠️ **`actual: null` = BELUM ADA KEJADIAN, bukan nol.** "Belum ada deal won" ≠ "sales cycle 0
12
+ * hari", dan merender 0 untuk keduanya menghapus perbedaan yang justru paling penting saat
13
+ * penilaian. Semua formatter di sini mengembalikan tanda pisah (—) untuk null.
14
+ *
15
+ * ⚠️ **Angka "Capaian" = `score` (0–120), BUKAN `achievement_pct`.** Skor dihitung server dengan
16
+ * rumus potong-garis yang sama dengan Excel laporan bulanan (toleransi = 50, target = 100,
17
+ * stretch = 120); `achievement_pct` hanya rasio mentah realisasi/target dan tetap boleh tampil
18
+ * sebagai info sekunder. Realisasi 0 pada KPI arah turun berskor 120 tapi rasionya `null` (target
19
+ * ÷ 0), jadi layar yang merender rasio sebagai angka utama menampilkan "—" untuk kinerja terbaik.
20
+ *
21
+ * ⚠️ **Warna dari `status` server** (`green|yellow|red|null`), jangan dari ambang sendiri: status
22
+ * dihitung dari skor yang sudah dibulatkan 1 desimal, jadi ambang tulisan tangan bisa berbeda
23
+ * warna tepat di batasnya — dan dua warna untuk satu angka tak punya jawaban benar.
24
+ *
25
+ * ⚠️ **`state`** membedakan tiga hal yang semuanya "tak ada skor": `value` (ada angka, termasuk 0)
26
+ * · `na` (tidak ada kejadian, dikeluarkan dari komposit) · `missing` (belum diisi). Merender
27
+ * ketiganya sebagai "—" menghapus satu-satunya petunjuk apa yang harus dikerjakan berikutnya.
28
+ */
29
+ export function useKpi(companyId) {
30
+ const { t, te, locale } = useI18n()
31
+
32
+ const resolvers = ref([])
33
+ const definitions = ref([])
34
+ const hrisTier = ref(false)
35
+ /** Pengali tier bawaan global `{up:{threshold,stretch}, down:{…}}` — milik server, beda per deployment. */
36
+ const defaultTiers = ref(null)
37
+ const loadingCatalog = ref(false)
38
+ const catalogError = ref('')
39
+
40
+ const rows = ref([])
41
+ const reportMonths = ref([])
42
+ const reportClosed = ref(false)
43
+ const loadingReport = ref(false)
44
+ const reportError = ref('')
45
+
46
+ const users = ref([])
47
+ const employees = ref([])
48
+
49
+ const cid = () => (typeof companyId === 'function' ? companyId() : companyId?.value ?? companyId)
50
+
51
+ async function fetchCatalog(includeInactive = false) {
52
+ if (!cid()) return
53
+ loadingCatalog.value = true
54
+ catalogError.value = ''
55
+ try {
56
+ const { data } = await kpiApi.catalog(cid(), includeInactive ? { include_inactive: 1 } : undefined)
57
+ const body = data.data ?? data
58
+ resolvers.value = body.resolvers ?? []
59
+ definitions.value = body.definitions ?? []
60
+ hrisTier.value = !!body.hris_tier
61
+ defaultTiers.value = body.default_tiers ?? null
62
+ } catch (err) {
63
+ const e = normalizeApiError(err)
64
+ catalogError.value = e.isForbidden ? t('kpi.noPermission') : (e.backendMessage || t('common.serverError'))
65
+ resolvers.value = []
66
+ definitions.value = []
67
+ } finally {
68
+ loadingCatalog.value = false
69
+ }
70
+ }
71
+
72
+ async function fetchReport(params) {
73
+ if (!cid()) return
74
+ loadingReport.value = true
75
+ reportError.value = ''
76
+ try {
77
+ const { data } = await kpiApi.report(cid(), params)
78
+ const body = data.data ?? data
79
+ rows.value = body.rows ?? []
80
+ reportMonths.value = body.months ?? []
81
+ reportClosed.value = !!body.closed
82
+ } catch (err) {
83
+ const e = normalizeApiError(err)
84
+ reportError.value = e.reason === 'invalid_period_span'
85
+ ? t('kpi.errInvalidSpan')
86
+ : (e.isForbidden ? t('kpi.noPermission') : (e.backendMessage || t('common.serverError')))
87
+ rows.value = []
88
+ } finally {
89
+ loadingReport.value = false
90
+ }
91
+ }
92
+
93
+ /** Pemilih subjek: user selalu, karyawan hanya bermakna di tenant ber-seat `kpis`. */
94
+ async function fetchSubjects() {
95
+ try {
96
+ const { data } = await kpiSubjectsApi.users()
97
+ users.value = (data.data?.data ?? data.data ?? []).map(u => ({
98
+ value: u.id,
99
+ label: [u.first_name, u.last_name].filter(Boolean).join(' ').trim() || u.username || u.id,
100
+ }))
101
+ } catch { /* pemilih user kosong — formnya mengatakan itu lewat daftar kosong */ }
102
+
103
+ if (!hrisTier.value) return
104
+ try {
105
+ const { data } = await kpiSubjectsApi.employees()
106
+ employees.value = (data.data?.data ?? data.data ?? []).map(e => ({
107
+ value: e.id,
108
+ // `first_name`/`last_name` baris karyawan hanya terisi untuk karyawan TANPA akun; nama karyawan
109
+ // ber-akun hanya ada di `display_name` (disajikan `/get_employees`).
110
+ label: (e.display_name ?? '').trim() || [e.first_name, e.last_name].filter(Boolean).join(' ').trim() || e.code || e.id,
111
+ }))
112
+ } catch { /* idem */ }
113
+ }
114
+
115
+ /**
116
+ * Nama resolver dalam bahasa UI. Backend mengirim `label`/`description` **English** (kanon
117
+ * language-neutral), jadi kamus lokal dicoba lebih dulu dan `label` backend jadi cadangan —
118
+ * bukan sebaliknya, dan TIDAK pernah di-parse.
119
+ */
120
+ function resolverLabel(r) {
121
+ const key = `kpi.resolver.${r.key}.label`
122
+ return te(key) ? t(key) : (r.label ?? r.key)
123
+ }
124
+ function resolverDescription(r) {
125
+ const key = `kpi.resolver.${r.key}.description`
126
+ return te(key) ? t(key) : (r.description ?? '')
127
+ }
128
+
129
+ const nf = () => (locale.value === 'id' ? 'id-ID' : 'en-US')
130
+
131
+ /** Angka realisasi/target sesuai `metric_type`. `null` → "—", TIDAK PERNAH 0. */
132
+ function formatMetric(value, def) {
133
+ if (value === null || value === undefined) return '—'
134
+ const n = Number(value)
135
+ if (Number.isNaN(n)) return String(value)
136
+ switch (def?.metric_type) {
137
+ case 'currency':
138
+ return new Intl.NumberFormat(nf(), { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(n)
139
+ case 'percent':
140
+ return `${new Intl.NumberFormat(nf(), { maximumFractionDigits: 2 }).format(n)}%`
141
+ case 'duration':
142
+ return `${new Intl.NumberFormat(nf(), { maximumFractionDigits: 1 }).format(n)} ${def.unit || ''}`.trim()
143
+ default:
144
+ return new Intl.NumberFormat(nf(), { maximumFractionDigits: 2 }).format(n)
145
+ }
146
+ }
147
+
148
+ /** Rasio mentah realisasi/target — info SEKUNDER, bukan angka Capaian. */
149
+ function formatAchievement(pct) {
150
+ if (pct === null || pct === undefined) return '—'
151
+ return `${new Intl.NumberFormat(nf(), { maximumFractionDigits: 0 }).format(Number(pct))}%`
152
+ }
153
+
154
+ /** Skor 0–120. Dua desimal mengikuti kolom Capaian Excel (106,67), bukan dibulatkan ke bilangan bulat. */
155
+ function formatScore(score) {
156
+ if (score === null || score === undefined) return '—'
157
+ return new Intl.NumberFormat(nf(), { maximumFractionDigits: 2 }).format(Number(score))
158
+ }
159
+
160
+ /** `status` server → warna `AppBadge`. `null` (tanpa skor) = abu. */
161
+ function statusTone(status) {
162
+ return { green: 'green', yellow: 'amber', red: 'red' }[status] ?? 'gray'
163
+ }
164
+
165
+ /** "KPI-DEV-01 — Bug produksi" bila berkode; kode = rujukan yang dipakai Excel & berita acara. */
166
+ function definitionLabel(def) {
167
+ if (!def) return ''
168
+ return def.code ? `${def.code} — ${def.name}` : (def.name ?? '')
169
+ }
170
+
171
+ /**
172
+ * "toleransi 15 · target 30 · stretch 36" dari tier EFEKTIF baris (server mengisi bawaan bila
173
+ * tier tak dipasang). Ditampilkan apa adanya, tak pernah dihitung ulang: pengali bawaan bisa
174
+ * ditimpa per definisi dan dibatasi 100 untuk persen, dan salinan rumus itu di klien pasti basi.
175
+ */
176
+ function formatTiers(row, def) {
177
+ if (row?.threshold === null || row?.threshold === undefined) return ''
178
+ return [
179
+ `${t('kpi.tier.threshold')} ${formatMetric(row.threshold, def)}`,
180
+ `${t('kpi.tier.target')} ${formatMetric(row.target, def)}`,
181
+ `${t('kpi.tier.stretch')} ${formatMetric(row.stretch, def)}`,
182
+ ].join(' · ')
183
+ }
184
+
185
+ const activeDefinitions = computed(() => definitions.value.filter(d => d.is_active !== false))
186
+
187
+ return {
188
+ resolvers, definitions, activeDefinitions, hrisTier, defaultTiers, loadingCatalog, catalogError,
189
+ rows, reportMonths, reportClosed, loadingReport, reportError,
190
+ users, employees,
191
+ fetchCatalog, fetchReport, fetchSubjects,
192
+ resolverLabel, resolverDescription,
193
+ formatMetric, formatAchievement, formatScore, statusTone, definitionLabel, formatTiers,
194
+ }
195
+ }
196
+
197
+ /**
198
+ * PRATINJAU tier yang akan berlaku bila toleransi/stretch dikosongkan, SEBELUM disimpan — urutan
199
+ * sumber mengikuti kontrak `kp1-kpi-tracking.md`: pengali definisi → `default_tiers`
200
+ * katalog; persen `percent_bounded` arah naik: stretch dibatasi 100. Sesudah disimpan, angka yang
201
+ * dipakai tetap tier EFEKTIF di respons, bukan hasil fungsi ini.
202
+ * @returns {{threshold:number, stretch:number}|null} null bila target belum valid / pengali tak diketahui
203
+ */
204
+ export function previewTiers(target, def, defaultTiers) {
205
+ const tv = Number(target)
206
+ if (!(tv > 0) || !def) return null
207
+ const arah = def.direction === 'down' ? 'down' : 'up'
208
+ const thrR = def.default_threshold_ratio ?? defaultTiers?.[arah]?.threshold
209
+ const strR = def.default_stretch_ratio ?? defaultTiers?.[arah]?.stretch
210
+ if (thrR == null || strR == null) return null
211
+ let stretch = tv * Number(strR)
212
+ if (arah === 'up' && def.metric_type === 'percent' && def.percent_bounded) stretch = Math.min(stretch, 100)
213
+ return { threshold: tv * Number(thrR), stretch }
214
+ }
215
+
216
+ /**
217
+ * Bentuk periode yang diterima backend. **Daftar ini mengikat** — mengarang bentuk lain
218
+ * (mis. `2026-W12`) dijawab 422 `invalid_period_span`, dan pemilih yang menawarkannya hanya
219
+ * memindahkan penolakan itu ke sesudah pengguna menekan tombol.
220
+ */
221
+ export const PERIOD_KINDS = ['month', 'quarter', 'semester', 'year', 'custom']
222
+
223
+ export function buildPeriod(kind, { year, month, quarter, semester, from, to }) {
224
+ switch (kind) {
225
+ case 'month': return `${year}-${String(month).padStart(2, '0')}`
226
+ case 'quarter': return `${year}-Q${quarter}`
227
+ case 'semester': return `${year}-S${semester}`
228
+ case 'year': return String(year)
229
+ case 'custom': return `${from}:${to}`
230
+ default: return `${year}-${String(month).padStart(2, '0')}`
231
+ }
232
+ }
@@ -0,0 +1,25 @@
1
+ // Apakah viewport sedang ≥ lg (1024 px, breakpoint Tailwind `lg`).
2
+ //
3
+ // Satu ref tingkat modul untuk semua pemakai. Breadcrumb dirender di DUA tempat menurut lebar
4
+ // layar (pil di header pada ≥ lg, baris di atas konten di bawahnya), dan keduanya memilih lewat
5
+ // `v-if` pada ref yang SAMA: satu sumber berarti tepat satu breadcrumb di DOM pada lebar apa pun.
6
+ // Menyembunyikan salah satunya lewat kelas responsif (`hidden lg:flex`) meninggalkan dua `nav`
7
+ // ber-label sama di pohon aksesibilitas, dan pencari `getByRole('navigation', { name })` di uji
8
+ // menemukan dua elemen.
9
+ import { ref } from 'vue'
10
+
11
+ const QUERY = '(min-width: 1024px)'
12
+ const isLg = ref(false)
13
+ let mql = null
14
+
15
+ function ensure() {
16
+ if (mql || typeof window === 'undefined' || !window.matchMedia) return
17
+ mql = window.matchMedia(QUERY)
18
+ isLg.value = mql.matches
19
+ mql.addEventListener('change', (e) => { isLg.value = e.matches })
20
+ }
21
+
22
+ export function useLgViewport() {
23
+ ensure()
24
+ return isLg
25
+ }