@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,221 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
// "Tim Saya" — orang yang dipimpin pemanggil beserta KPI jabatannya untuk SATU bulan
|
|
3
|
+
// (`GET /kpi/team`, kontrak `kp1-kpi-tracking.md`). Dipakai pimpinan tanggal 1–3 untuk
|
|
4
|
+
// memasang target & mengisi realisasi tim, lalu memantau siapa yang belum dinilai.
|
|
5
|
+
//
|
|
6
|
+
// ⚠️ **Tombol digambar dari `can_edit_target`/`can_edit_entry` PER METRIK**, bukan dari izin modul:
|
|
7
|
+
// pimpinan tanpa izin `kpis` boleh mengisi untuk timnya, sedangkan target dirinya sendiri ditetapkan
|
|
8
|
+
// atasannya (server 403). Form-nya dipakai ulang dari Workspace lewat emit — layar ini tak memiliki
|
|
9
|
+
// modal sendiri, supaya pimpinan dan pengelola KPI mengisi lewat form yang sama persis.
|
|
10
|
+
//
|
|
11
|
+
// "Memimpin" ditentukan server (atasan berantai + PIC departemen, PIC tak berantai); layar ini tak
|
|
12
|
+
// menyaring ulang anggota, sebab ia tak tahu siapa memimpin siapa.
|
|
13
|
+
import { ref, computed, watch } from 'vue'
|
|
14
|
+
import { useI18n } from 'vue-i18n'
|
|
15
|
+
import { ArrowDown, ArrowUp, Users } from '@lucide/vue'
|
|
16
|
+
import { kpiApi } from '@initsolusidigital/worksuite-core/api/endpoints/kpi.js'
|
|
17
|
+
import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
|
|
18
|
+
import { useKpi } from '../../composables/useKpi.js'
|
|
19
|
+
import AppBadge from '@initsolusidigital/worksuite-ui/AppBadge.vue'
|
|
20
|
+
import AppCard from '@initsolusidigital/worksuite-ui/AppCard.vue'
|
|
21
|
+
import AppAlert from '@initsolusidigital/worksuite-ui/AppAlert.vue'
|
|
22
|
+
import AppSpinner from '@initsolusidigital/worksuite-ui/AppSpinner.vue'
|
|
23
|
+
import AppEmptyState from '@initsolusidigital/worksuite-ui/AppEmptyState.vue'
|
|
24
|
+
|
|
25
|
+
const props = defineProps({
|
|
26
|
+
companyId: { type: String, required: true },
|
|
27
|
+
/** Periode Workspace — tim hanya bermakna per BULAN. */
|
|
28
|
+
period: { type: String, default: '' },
|
|
29
|
+
/** Definisi katalog (untuk `percent_bounded`/pengali saat membuka form target). */
|
|
30
|
+
definitions: { type: Array, default: () => [] },
|
|
31
|
+
/** Dinaikkan pemanggil sesudah isian tersimpan. */
|
|
32
|
+
refreshToken: { type: Number, default: 0 },
|
|
33
|
+
})
|
|
34
|
+
const emit = defineEmits(['enter', 'target', 'open-appraisal'])
|
|
35
|
+
|
|
36
|
+
const { t } = useI18n()
|
|
37
|
+
const { formatMetric, formatScore, statusTone, definitionLabel, formatTiers } = useKpi(() => props.companyId)
|
|
38
|
+
|
|
39
|
+
const members = ref([])
|
|
40
|
+
const closed = ref(false)
|
|
41
|
+
const loading = ref(false)
|
|
42
|
+
const loadError = ref('')
|
|
43
|
+
|
|
44
|
+
const bulanan = computed(() => /^\d{4}-\d{2}$/.test(props.period))
|
|
45
|
+
const APPRAISAL_TONE = { draft: 'gray', submitted: 'blue', acknowledged: 'amber', closed: 'green' }
|
|
46
|
+
|
|
47
|
+
async function muat() {
|
|
48
|
+
if (!props.companyId || !bulanan.value) { members.value = []; return }
|
|
49
|
+
loading.value = true
|
|
50
|
+
loadError.value = ''
|
|
51
|
+
try {
|
|
52
|
+
const { data } = await kpiApi.team(props.companyId, { period: props.period })
|
|
53
|
+
const body = data.data ?? data
|
|
54
|
+
members.value = body.members ?? []
|
|
55
|
+
closed.value = !!body.closed
|
|
56
|
+
} catch (err) {
|
|
57
|
+
const e = normalizeApiError(err)
|
|
58
|
+
loadError.value = e.isForbidden ? t('kpi.noPermission') : (e.backendMessage || t('common.serverError'))
|
|
59
|
+
members.value = []
|
|
60
|
+
} finally {
|
|
61
|
+
loading.value = false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Metrik tim → bentuk "definisi" yang dipahami form isian & form target. */
|
|
66
|
+
function definisi(m) {
|
|
67
|
+
const katalog = props.definitions.find(d => d.id === m.kpi_definition_id)
|
|
68
|
+
return katalog ?? {
|
|
69
|
+
id: m.kpi_definition_id, code: m.code, name: m.name, metric_type: m.metric_type,
|
|
70
|
+
direction: m.direction, unit: m.unit, resolver_key: m.resolver_key, allow_na: m.allow_na,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function baris(member, m) {
|
|
74
|
+
return {
|
|
75
|
+
definition: { ...definisi(m), allow_na: m.allow_na, resolver_key: m.resolver_key },
|
|
76
|
+
subject_type: m.subject_type ?? 'employee',
|
|
77
|
+
subject_id: m.subject_id ?? member.employee_id,
|
|
78
|
+
subject_name: member.display_name,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function isi(member, m) { emit('enter', baris(member, m)) }
|
|
82
|
+
function target(member, m) {
|
|
83
|
+
emit('target', {
|
|
84
|
+
definition: definisi(m),
|
|
85
|
+
subject: { type: m.subject_type ?? 'employee', id: m.subject_id ?? member.employee_id, label: member.display_name },
|
|
86
|
+
target: m.target,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hasScore = (m) => m.score !== null && m.score !== undefined
|
|
91
|
+
|
|
92
|
+
watch(() => [props.companyId, props.period, props.refreshToken], muat, { immediate: true })
|
|
93
|
+
defineExpose({ reload: muat })
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<template>
|
|
97
|
+
<div class="space-y-4">
|
|
98
|
+
<AppAlert v-if="!bulanan" variant="info">{{ t('kpi.team.monthlyOnly') }}</AppAlert>
|
|
99
|
+
<AppAlert v-else-if="loadError" variant="error">{{ loadError }}</AppAlert>
|
|
100
|
+
<div v-else-if="loading" class="py-12 text-center"><AppSpinner /></div>
|
|
101
|
+
|
|
102
|
+
<AppCard v-else-if="!members.length">
|
|
103
|
+
<AppEmptyState :label="t('kpi.team.empty')" :description="t('kpi.team.emptyHint')">
|
|
104
|
+
<template #icon><Users class="w-6 h-6 text-gray-400 dark:text-gray-500" :stroke-width="1.5" /></template>
|
|
105
|
+
</AppEmptyState>
|
|
106
|
+
</AppCard>
|
|
107
|
+
|
|
108
|
+
<template v-else>
|
|
109
|
+
<AppAlert v-if="closed" variant="info">{{ t('kpi.report.closedHint') }}</AppAlert>
|
|
110
|
+
|
|
111
|
+
<AppCard
|
|
112
|
+
v-for="mb in members"
|
|
113
|
+
:key="mb.employee_id"
|
|
114
|
+
padding="none"
|
|
115
|
+
title-tag="h3"
|
|
116
|
+
:data-kpi-team-member="mb.employee_id"
|
|
117
|
+
>
|
|
118
|
+
<template #header>
|
|
119
|
+
<div class="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex flex-wrap items-center justify-between gap-3">
|
|
120
|
+
<div class="min-w-0">
|
|
121
|
+
<h3 class="text-sm font-semibold text-gray-900 dark:text-white truncate">{{ mb.display_name }}</h3>
|
|
122
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
123
|
+
{{ [mb.occupation?.name, mb.department?.name].filter(Boolean).join(' · ') || '—' }}
|
|
124
|
+
<template v-if="mb.is_leader"> · {{ t('kpi.team.isLeader') }}</template>
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
<div class="flex flex-wrap items-center gap-1.5">
|
|
128
|
+
<AppBadge v-if="mb.missing_count" color="amber">{{ t('kpi.team.missing', { n: mb.missing_count }) }}</AppBadge>
|
|
129
|
+
<AppBadge v-if="mb.no_target_count" color="orange">{{ t('kpi.team.noTarget', { n: mb.no_target_count }) }}</AppBadge>
|
|
130
|
+
<button
|
|
131
|
+
v-if="mb.appraisal"
|
|
132
|
+
type="button"
|
|
133
|
+
class="inline-flex items-center gap-1.5"
|
|
134
|
+
:aria-label="t('kpi.team.openAppraisal')"
|
|
135
|
+
@click="emit('open-appraisal', mb.appraisal.id)"
|
|
136
|
+
>
|
|
137
|
+
<AppBadge :color="APPRAISAL_TONE[mb.appraisal.status] ?? 'gray'">{{ t(`kpi.appraisal.status.${mb.appraisal.status}`) }}</AppBadge>
|
|
138
|
+
<AppBadge v-if="mb.appraisal.final_score != null" :color="statusTone(mb.appraisal.final_status)">
|
|
139
|
+
{{ formatScore(mb.appraisal.final_score) }}
|
|
140
|
+
</AppBadge>
|
|
141
|
+
</button>
|
|
142
|
+
<AppBadge v-else color="gray">{{ t('kpi.team.noAppraisal') }}</AppBadge>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</template>
|
|
146
|
+
|
|
147
|
+
<p v-if="!mb.metrics?.length" class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">{{ t('kpi.team.noMetrics') }}</p>
|
|
148
|
+
|
|
149
|
+
<div v-else class="overflow-x-auto scrollbar-rounded">
|
|
150
|
+
<table class="min-w-full text-sm">
|
|
151
|
+
<thead class="bg-gray-50 dark:bg-gray-900/40 text-xs text-gray-500 dark:text-gray-400">
|
|
152
|
+
<tr>
|
|
153
|
+
<th scope="col" class="text-left font-medium px-6 py-2">{{ t('kpi.report.colDefinition') }}</th>
|
|
154
|
+
<th scope="col" class="text-right font-medium px-6 py-2">{{ t('kpi.scorecard.colWeight') }}</th>
|
|
155
|
+
<th scope="col" class="text-right font-medium px-6 py-2">{{ t('kpi.report.colTarget') }}</th>
|
|
156
|
+
<th scope="col" class="text-right font-medium px-6 py-2">{{ t('kpi.report.colActual') }}</th>
|
|
157
|
+
<th scope="col" class="text-right font-medium px-6 py-2">{{ t('kpi.report.colAchievement') }}</th>
|
|
158
|
+
<th scope="col" class="text-right font-medium px-6 py-2"><span class="sr-only">{{ t('kpi.report.colActions') }}</span></th>
|
|
159
|
+
</tr>
|
|
160
|
+
</thead>
|
|
161
|
+
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
162
|
+
<tr v-for="m in mb.metrics" :key="m.kpi_definition_id">
|
|
163
|
+
<td class="px-6 py-3">
|
|
164
|
+
<div class="flex items-center gap-1.5 min-w-0">
|
|
165
|
+
<component
|
|
166
|
+
:is="m.direction === 'down' ? ArrowDown : ArrowUp"
|
|
167
|
+
class="w-3.5 h-3.5 shrink-0"
|
|
168
|
+
:class="m.direction === 'down' ? 'text-blue-500' : 'text-gray-400'"
|
|
169
|
+
:aria-label="t(`kpi.direction.${m.direction || 'up'}`)"
|
|
170
|
+
/>
|
|
171
|
+
<span class="text-gray-900 dark:text-white truncate">{{ definitionLabel(m) }}</span>
|
|
172
|
+
</div>
|
|
173
|
+
</td>
|
|
174
|
+
<td class="px-6 py-3 text-right tabular-nums text-gray-600 dark:text-gray-300">{{ m.weight ?? '—' }}</td>
|
|
175
|
+
<td class="px-6 py-3 text-right tabular-nums text-gray-600 dark:text-gray-300">
|
|
176
|
+
<template v-if="m.target === null || m.target === undefined">
|
|
177
|
+
<span class="text-orange-600 dark:text-orange-400">{{ t('kpi.team.targetUnset') }}</span>
|
|
178
|
+
</template>
|
|
179
|
+
<template v-else>
|
|
180
|
+
{{ formatMetric(m.target, m) }}
|
|
181
|
+
<span v-if="formatTiers(m, m)" class="block text-[11px] text-gray-400 dark:text-gray-500 whitespace-nowrap">{{ formatTiers(m, m) }}</span>
|
|
182
|
+
</template>
|
|
183
|
+
</td>
|
|
184
|
+
<td class="px-6 py-3 text-right tabular-nums text-gray-900 dark:text-white">
|
|
185
|
+
<span v-if="m.state === 'na'" class="text-gray-500 dark:text-gray-400">{{ t('kpi.state.na') }}</span>
|
|
186
|
+
<span v-else-if="m.state === 'missing'" class="text-amber-600 dark:text-amber-400">{{ t('kpi.state.missing') }}</span>
|
|
187
|
+
<template v-else>{{ formatMetric(m.actual, m) }}</template>
|
|
188
|
+
<span
|
|
189
|
+
v-if="m.entry_source === 'self'"
|
|
190
|
+
class="ml-1.5 align-middle inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
|
|
191
|
+
:title="t('kpi.report.sourceSelfHint')"
|
|
192
|
+
>{{ t('kpi.report.sourceSelf') }}</span>
|
|
193
|
+
</td>
|
|
194
|
+
<td class="px-6 py-3 text-right">
|
|
195
|
+
<AppBadge v-if="hasScore(m)" :color="statusTone(m.status)">{{ formatScore(m.score) }}</AppBadge>
|
|
196
|
+
<span v-else class="text-gray-400 dark:text-gray-500">—</span>
|
|
197
|
+
</td>
|
|
198
|
+
<td class="px-6 py-3 text-right whitespace-nowrap">
|
|
199
|
+
<div class="inline-flex items-center gap-3">
|
|
200
|
+
<button
|
|
201
|
+
v-if="m.can_edit_target"
|
|
202
|
+
type="button"
|
|
203
|
+
class="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
|
204
|
+
@click="target(mb, m)"
|
|
205
|
+
>{{ t('kpi.report.setTarget') }}</button>
|
|
206
|
+
<button
|
|
207
|
+
v-if="m.can_edit_entry && !m.resolver_key"
|
|
208
|
+
type="button"
|
|
209
|
+
class="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
|
210
|
+
@click="isi(mb, m)"
|
|
211
|
+
>{{ t('kpi.report.fillActual') }}</button>
|
|
212
|
+
</div>
|
|
213
|
+
</td>
|
|
214
|
+
</tr>
|
|
215
|
+
</tbody>
|
|
216
|
+
</table>
|
|
217
|
+
</div>
|
|
218
|
+
</AppCard>
|
|
219
|
+
</template>
|
|
220
|
+
</div>
|
|
221
|
+
</template>
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
// Profil bobot KPI per JOBDESK — daftar metrik + persen yang totalnya wajib 100.
|
|
3
|
+
//
|
|
4
|
+
// Bobot melekat pada JABATAN, bukan pada orang: dua sales dengan jobdesk sama dinilai dengan
|
|
5
|
+
// timbangan yang sama, dan itulah yang membuat komposit antar-orang bisa dibandingkan. Bobot
|
|
6
|
+
// per-ORANG tetap mungkin lewat kolom override di dialog target — itu pengecualian, dan
|
|
7
|
+
// scorecard menandainya sebagai `override` supaya terlihat bahwa ia menyimpang dari profil.
|
|
8
|
+
//
|
|
9
|
+
// ⚠️ **REPLACE-ALL per occupation.** Menyimpan daftar yang lebih pendek MENGHAPUS sisanya di
|
|
10
|
+
// server, jadi form ini selalu mengirim SELURUH baris yang tampil — termasuk yang tak disentuh.
|
|
11
|
+
// Menyimpan "hanya yang diubah" akan menghapus metrik lain tanpa satu pun peringatan.
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ Σ bobot WAJIB tepat 100 (422 `weights_not_100`). Totalnya karena itu dihitung hidup di
|
|
14
|
+
// layar: menekan Simpan lalu ditolak karena selisih 0,5 adalah penolakan yang seharusnya tak
|
|
15
|
+
// pernah perlu terjadi.
|
|
16
|
+
import { ref, computed, watch } from 'vue'
|
|
17
|
+
import { useI18n } from 'vue-i18n'
|
|
18
|
+
import { Plus, Trash2 } from '@lucide/vue'
|
|
19
|
+
import { kpiApi } from '@initsolusidigital/worksuite-core/api/endpoints/kpi.js'
|
|
20
|
+
import { useFormSubmit } from '@initsolusidigital/worksuite-core/composables/useFormSubmit.js'
|
|
21
|
+
import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
|
|
22
|
+
import AppCard from '@initsolusidigital/worksuite-ui/AppCard.vue'
|
|
23
|
+
import AppCombobox from '@initsolusidigital/worksuite-ui/AppCombobox.vue'
|
|
24
|
+
import AppInput from '@initsolusidigital/worksuite-ui/AppInput.vue'
|
|
25
|
+
import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
|
|
26
|
+
import AppAlert from '@initsolusidigital/worksuite-ui/AppAlert.vue'
|
|
27
|
+
import AppSpinner from '@initsolusidigital/worksuite-ui/AppSpinner.vue'
|
|
28
|
+
import AppEmptyState from '@initsolusidigital/worksuite-ui/AppEmptyState.vue'
|
|
29
|
+
|
|
30
|
+
const props = defineProps({
|
|
31
|
+
companyId: { type: String, required: true },
|
|
32
|
+
/** Jabatan `{value,label}` — app tanpa master jabatan tak memanggil layar ini sama sekali. */
|
|
33
|
+
occupations: { type: Array, default: () => [] },
|
|
34
|
+
/** Definisi KPI aktif `{id,name}` untuk pemilih metrik. */
|
|
35
|
+
definitions: { type: Array, default: () => [] },
|
|
36
|
+
canManage: { type: Boolean, default: false },
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const { t } = useI18n()
|
|
40
|
+
const { formError, saving, submit } = useFormSubmit()
|
|
41
|
+
|
|
42
|
+
const occupationId = ref('')
|
|
43
|
+
const baris = ref([])
|
|
44
|
+
/**
|
|
45
|
+
* Definisi yang IKUT di baris tersimpan tapi tak ada di daftar aktif — metrik yang dinonaktifkan
|
|
46
|
+
* setelah profilnya disusun. Backend tetap mengirim relasi `definition`-nya, jadi namanya bisa
|
|
47
|
+
* ditampilkan; tanpa ini pemilihnya jatuh ke placeholder dan barisnya tampil sebagai **bobot
|
|
48
|
+
* tanpa nama metrik** — angka yang tak bisa dipertanggungjawabkan, dan tak ada petunjuk bahwa
|
|
49
|
+
* yang hilang adalah metriknya yang dinonaktifkan.
|
|
50
|
+
*/
|
|
51
|
+
const definisiTakAktif = ref([])
|
|
52
|
+
const loading = ref(false)
|
|
53
|
+
const loadError = ref('')
|
|
54
|
+
|
|
55
|
+
const labelDef = (d) => (d.code ? `${d.code} — ${d.name}` : d.name)
|
|
56
|
+
const definitionOptions = computed(() => [
|
|
57
|
+
...props.definitions.map(d => ({ value: d.id, label: labelDef(d) })),
|
|
58
|
+
...definisiTakAktif.value.map(d => ({ value: d.id, label: `${labelDef(d)} — ${t('kpi.weights.inactiveMetric')}` })),
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
const total = computed(() =>
|
|
62
|
+
baris.value.reduce((n, b) => n + (Number(b.weight) || 0), 0))
|
|
63
|
+
|
|
64
|
+
// Dibandingkan dengan toleransi: bobot pecahan (33,33 × 3) tak pernah menjumlah persis 100 dalam
|
|
65
|
+
// aritmetika desimal, dan menolak baris yang benar hanya karena galat pembulatan lebih buruk
|
|
66
|
+
// daripada membiarkan server yang menghitungnya.
|
|
67
|
+
const totalPas = computed(() => Math.abs(total.value - 100) < 0.005)
|
|
68
|
+
const adaBaris = computed(() => baris.value.some(b => b.kpi_definition_id))
|
|
69
|
+
// Profil tersimpan di server (bukan baris yang sedang diketik) — hanya itu yang bisa dihapus.
|
|
70
|
+
const adaTersimpan = ref(false)
|
|
71
|
+
const konfirmasiHapus = ref(false)
|
|
72
|
+
|
|
73
|
+
async function muat() {
|
|
74
|
+
if (!occupationId.value) { baris.value = []; return }
|
|
75
|
+
loading.value = true
|
|
76
|
+
loadError.value = ''
|
|
77
|
+
try {
|
|
78
|
+
const { data } = await kpiApi.weightProfiles(props.companyId, { occupation_id: occupationId.value })
|
|
79
|
+
const rows = data.data?.data ?? data.data ?? []
|
|
80
|
+
baris.value = rows.map(r => ({ kpi_definition_id: r.kpi_definition_id, weight: r.weight }))
|
|
81
|
+
adaTersimpan.value = rows.length > 0
|
|
82
|
+
konfirmasiHapus.value = false
|
|
83
|
+
const aktif = new Set(props.definitions.map(d => d.id))
|
|
84
|
+
definisiTakAktif.value = rows
|
|
85
|
+
.filter(r => r.definition && !aktif.has(r.kpi_definition_id))
|
|
86
|
+
.map(r => ({ id: r.kpi_definition_id, name: r.definition.name, code: r.definition.code }))
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const e = normalizeApiError(err)
|
|
89
|
+
loadError.value = e.isForbidden ? t('kpi.noPermission') : (e.backendMessage || t('common.serverError'))
|
|
90
|
+
baris.value = []
|
|
91
|
+
definisiTakAktif.value = []
|
|
92
|
+
adaTersimpan.value = false
|
|
93
|
+
} finally {
|
|
94
|
+
loading.value = false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
watch(occupationId, muat)
|
|
99
|
+
|
|
100
|
+
function tambah() { baris.value.push({ kpi_definition_id: '', weight: '' }) }
|
|
101
|
+
function hapus(i) { baris.value.splice(i, 1) }
|
|
102
|
+
|
|
103
|
+
/** Bagi rata ke seluruh baris — jalan pintas yang menutup hampir semua kasus nyata. */
|
|
104
|
+
function ratakan() {
|
|
105
|
+
const n = baris.value.length
|
|
106
|
+
if (!n) return
|
|
107
|
+
const each = Math.floor((100 / n) * 100) / 100
|
|
108
|
+
baris.value.forEach((b, i) => { b.weight = i === n - 1 ? Number((100 - each * (n - 1)).toFixed(2)) : each })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function simpan() {
|
|
112
|
+
const isi = baris.value.filter(b => b.kpi_definition_id && b.weight !== '')
|
|
113
|
+
const ok = await submit(
|
|
114
|
+
() => kpiApi.upsertWeightProfiles(props.companyId, {
|
|
115
|
+
occupation_id: occupationId.value,
|
|
116
|
+
items: isi.map(b => ({ kpi_definition_id: b.kpi_definition_id, weight: b.weight })),
|
|
117
|
+
}),
|
|
118
|
+
{
|
|
119
|
+
successKey: 'kpi.weights.saved',
|
|
120
|
+
errorKey: 'kpi.weights.saveError',
|
|
121
|
+
// Dua sebab, dua obat: total meleset menuntut angkanya dibetulkan, definisi ganda menuntut
|
|
122
|
+
// salah satu barisnya dibuang. Keduanya 422 tanpa error per-field.
|
|
123
|
+
onError: (e) => {
|
|
124
|
+
if (e.reason === 'weights_not_100') formError.value = t('kpi.weights.errNot100', { total: total.value })
|
|
125
|
+
if (e.reason === 'duplicate_definition') formError.value = t('kpi.weights.errDuplicate')
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
if (ok) muat()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// `items: []` menghapus profil jabatan ini: skor komposit kembali ke bobot setara. Inilah jalan
|
|
133
|
+
// keluar untuk profil yang masih menunjuk metrik nonaktif — bobot metrik itu tetap dihitung
|
|
134
|
+
// selama barisnya ada di profil.
|
|
135
|
+
async function hapusProfil() {
|
|
136
|
+
const ok = await submit(
|
|
137
|
+
() => kpiApi.upsertWeightProfiles(props.companyId, { occupation_id: occupationId.value, items: [] }),
|
|
138
|
+
{ successKey: 'kpi.weights.cleared', errorKey: 'kpi.weights.saveError' },
|
|
139
|
+
)
|
|
140
|
+
konfirmasiHapus.value = false
|
|
141
|
+
if (ok) muat()
|
|
142
|
+
}
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<template>
|
|
146
|
+
<AppCard :title="t('kpi.weights.title')" :description="t('kpi.weights.subtitle')">
|
|
147
|
+
<div class="space-y-4">
|
|
148
|
+
<AppAlert v-if="formError" variant="error">{{ formError }}</AppAlert>
|
|
149
|
+
<AppAlert v-if="loadError" variant="error">{{ loadError }}</AppAlert>
|
|
150
|
+
|
|
151
|
+
<div class="w-72">
|
|
152
|
+
<AppCombobox
|
|
153
|
+
v-model="occupationId"
|
|
154
|
+
:label="t('kpi.weights.fieldOccupation')"
|
|
155
|
+
:options="occupations"
|
|
156
|
+
:placeholder="t('kpi.weights.selectOccupation')"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<AppSpinner v-if="loading" />
|
|
161
|
+
|
|
162
|
+
<template v-else-if="occupationId">
|
|
163
|
+
<div v-if="baris.length" class="space-y-2">
|
|
164
|
+
<div v-for="(b, i) in baris" :key="i" class="flex flex-wrap items-end gap-2">
|
|
165
|
+
<div class="grow min-w-[14rem]">
|
|
166
|
+
<AppCombobox
|
|
167
|
+
v-model="b.kpi_definition_id"
|
|
168
|
+
:label="i === 0 ? t('kpi.weights.fieldMetric') : ''"
|
|
169
|
+
:aria-label="i === 0 ? '' : t('kpi.weights.fieldMetric')"
|
|
170
|
+
:options="definitionOptions"
|
|
171
|
+
:placeholder="t('kpi.weights.selectMetric')"
|
|
172
|
+
:disabled="!canManage"
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
<div class="w-32">
|
|
176
|
+
<AppInput
|
|
177
|
+
v-model="b.weight"
|
|
178
|
+
type="number"
|
|
179
|
+
step="any"
|
|
180
|
+
:label="i === 0 ? t('kpi.weights.fieldWeight') : ''"
|
|
181
|
+
:aria-label="i === 0 ? '' : t('kpi.weights.fieldWeight')"
|
|
182
|
+
:disabled="!canManage"
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
<AppButton
|
|
186
|
+
v-if="canManage"
|
|
187
|
+
variant="ghost"
|
|
188
|
+
size="sm"
|
|
189
|
+
:aria-label="t('common.delete')"
|
|
190
|
+
@click="hapus(i)"
|
|
191
|
+
>
|
|
192
|
+
<Trash2 class="w-4 h-4 text-red-600 dark:text-red-400" />
|
|
193
|
+
</AppButton>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<AppEmptyState
|
|
198
|
+
v-else
|
|
199
|
+
:title="t('kpi.weights.emptyTitle')"
|
|
200
|
+
:description="t('kpi.weights.emptyBody')"
|
|
201
|
+
/>
|
|
202
|
+
|
|
203
|
+
<div v-if="canManage" class="flex flex-wrap items-center gap-2">
|
|
204
|
+
<AppButton variant="ghost" size="sm" @click="tambah">
|
|
205
|
+
<Plus class="w-4 h-4" />{{ t('kpi.weights.addRow') }}
|
|
206
|
+
</AppButton>
|
|
207
|
+
<AppButton v-if="baris.length > 1" variant="ghost" size="sm" @click="ratakan">
|
|
208
|
+
{{ t('kpi.weights.spreadEvenly') }}
|
|
209
|
+
</AppButton>
|
|
210
|
+
<template v-if="adaTersimpan">
|
|
211
|
+
<AppButton v-if="!konfirmasiHapus" variant="ghost" size="sm" @click="konfirmasiHapus = true">
|
|
212
|
+
<Trash2 class="w-4 h-4 text-red-600 dark:text-red-400" />{{ t('kpi.weights.clear') }}
|
|
213
|
+
</AppButton>
|
|
214
|
+
<template v-else>
|
|
215
|
+
<span class="text-xs text-gray-600 dark:text-gray-300">{{ t('kpi.weights.clearConfirm') }}</span>
|
|
216
|
+
<AppButton variant="danger" size="sm" :loading="saving" @click="hapusProfil">{{ t('common.delete') }}</AppButton>
|
|
217
|
+
<AppButton variant="secondary" size="sm" :disabled="saving" @click="konfirmasiHapus = false">{{ t('common.cancel') }}</AppButton>
|
|
218
|
+
</template>
|
|
219
|
+
</template>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<!-- Metrik nonaktif di dalam profil bukan galat, tapi juga bukan keadaan yang boleh
|
|
223
|
+
didiamkan: bobotnya masih ikut menghitung skor komposit. -->
|
|
224
|
+
<p v-if="definisiTakAktif.length" class="text-xs text-amber-600 dark:text-amber-400">
|
|
225
|
+
{{ t('kpi.weights.hasInactive', { n: definisiTakAktif.length }) }}
|
|
226
|
+
</p>
|
|
227
|
+
|
|
228
|
+
<div v-if="adaBaris" class="flex flex-wrap items-center justify-between gap-3 pt-1">
|
|
229
|
+
<p class="text-sm" :class="totalPas ? 'text-gray-600 dark:text-gray-300' : 'text-amber-600 dark:text-amber-400'">
|
|
230
|
+
{{ t('kpi.weights.total', { total: Number(total.toFixed(2)) }) }}
|
|
231
|
+
<span v-if="!totalPas"> — {{ t('kpi.weights.mustBe100') }}</span>
|
|
232
|
+
</p>
|
|
233
|
+
<AppButton v-if="canManage" :loading="saving" :disabled="!totalPas" @click="simpan">
|
|
234
|
+
{{ t('common.save') }}
|
|
235
|
+
</AppButton>
|
|
236
|
+
</div>
|
|
237
|
+
</template>
|
|
238
|
+
|
|
239
|
+
<!-- Tanpa jabatan terpilih tak ada yang bisa ditampilkan; kalimatnya menyebut itu alih-alih
|
|
240
|
+
membiarkan area kosong yang terbaca seperti gagal memuat. -->
|
|
241
|
+
<p v-else class="text-sm text-gray-500 dark:text-gray-400">{{ t('kpi.weights.pickFirst') }}</p>
|
|
242
|
+
</div>
|
|
243
|
+
</AppCard>
|
|
244
|
+
</template>
|