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