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