@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,130 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useI18n } from 'vue-i18n'
|
|
4
|
+
import { Loader2 } from '@lucide/vue'
|
|
5
|
+
|
|
6
|
+
// Panduan isi template yang diturunkan dari `GET /imports/{module}/schema`, dipakai otomatis oleh
|
|
7
|
+
// AppImportExport bila halaman tak mengisi slot `guide`. Satu sumber kebenaran: begitu importer
|
|
8
|
+
// backend mengubah kolom, wajib-isi, atau daftar nilai sah, panduan ikut berubah tanpa satu baris
|
|
9
|
+
// frontend disunting — panduan tulisan tangan per halaman basi diam-diam pada perubahan pertama.
|
|
10
|
+
//
|
|
11
|
+
// Kalimat disusun dari field TERSTRUKTUR (required/type/options/default/example) lewat i18n. Petunjuk
|
|
12
|
+
// per kolom dipilih dari `hint_key` + `hint_params` (kontrak `data-import-export.md` §Terjemahan
|
|
13
|
+
// petunjuk): satu kode = satu kalimat lintas modul, jadi satu entri `dataTools.guide.hintKey.*`
|
|
14
|
+
// melayani semua importer. `hint` (English) hanya cadangan — kode yang belum dikenal kamus, atau
|
|
15
|
+
// kalimat yang menuntut parameter yang tak dikirim, jatuh ke sana alih-alih merender `{max}` kosong.
|
|
16
|
+
// DILARANG memilih kalimat dengan mencocokkan teks `hint`: backend bebas menyuntingnya.
|
|
17
|
+
const props = defineProps({
|
|
18
|
+
schema: { type: Object, default: null }, // data.* respons skema
|
|
19
|
+
loading: { type: Boolean, default: false },
|
|
20
|
+
error: { type: Boolean, default: false },
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const { t, locale, getLocaleMessage } = useI18n()
|
|
24
|
+
|
|
25
|
+
const rows = computed(() => [
|
|
26
|
+
...(props.schema?.fields ?? []),
|
|
27
|
+
...(props.schema?.custom_fields ?? []),
|
|
28
|
+
])
|
|
29
|
+
const hasExample = computed(() => rows.value.some(f => f.example != null && f.example !== ''))
|
|
30
|
+
const hasLookup = computed(() => rows.value.some(f => f.type === 'lookup'))
|
|
31
|
+
|
|
32
|
+
// Parameter array (mis. `types` daftar tipe kontak) dirangkai di sini — kontrak menyerahkannya ke
|
|
33
|
+
// frontend supaya pemisahnya mengikuti bahasa tampilan, bukan koma Inggris yang dibekukan backend.
|
|
34
|
+
function hintParams(f) {
|
|
35
|
+
const out = {}
|
|
36
|
+
for (const [k, v] of Object.entries(f.hint_params ?? {})) {
|
|
37
|
+
out[k] = Array.isArray(v) ? v.join(', ') : v
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hintOf(f) {
|
|
43
|
+
if (f.hint_key) {
|
|
44
|
+
// Dibaca dari kamus locale AKTIF, bukan lewat `te()`: di vue-i18n v11 `te()` ikut menghitung
|
|
45
|
+
// locale cadangan, sehingga locale tanpa terjemahan (en) mendapat kalimat Indonesia dari
|
|
46
|
+
// cadangan alih-alih kalimat backend yang memang berbahasa Inggris. String mentah juga
|
|
47
|
+
// dibutuhkan untuk memeriksa `{param}` yang tak terisi — hasil render sudah kehilangan jejaknya.
|
|
48
|
+
const key = `dataTools.guide.hintKey.${f.hint_key}`
|
|
49
|
+
const raw = key.split('.').reduce((o, k) => (o == null ? o : o[k]), getLocaleMessage(locale.value))
|
|
50
|
+
const params = hintParams(f)
|
|
51
|
+
const complete = typeof raw === 'string' && (raw.match(/\{(\w+)\}/g) ?? []).every((tok) => {
|
|
52
|
+
const v = params[tok.slice(1, -1)]
|
|
53
|
+
return v !== undefined && v !== null && v !== ''
|
|
54
|
+
})
|
|
55
|
+
if (complete) return t(key, params)
|
|
56
|
+
}
|
|
57
|
+
return f.hint || ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Pilihan yang ditampilkan sebagai <code> supaya bisa disalin persis. Boolean tidak membawa
|
|
61
|
+
// `options` di skema, tapi importer menerima yes/no — itu yang ditulis pengisi.
|
|
62
|
+
function choicesOf(f) {
|
|
63
|
+
if (f.type === 'boolean') return ['yes', 'no']
|
|
64
|
+
return Array.isArray(f.options) ? f.options : []
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function typeText(f) {
|
|
68
|
+
if (f.type === 'email') return t('dataTools.guide.type.email')
|
|
69
|
+
if (f.type?.startsWith('custom_field:')) return t('dataTools.guide.type.customField')
|
|
70
|
+
return ''
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function choicesLabel(f) {
|
|
74
|
+
return f.type === 'lookup' ? t('dataTools.guide.type.lookup') : t('dataTools.guide.type.enum')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Contoh lookup berasal dari baris contoh template yang statis (mis. role "Sales"), bukan dari data
|
|
78
|
+
// tenant; bila nilainya tak ada di daftar sah tenant ini, menampilkannya justru mengajak pengisi
|
|
79
|
+
// menulis nilai yang pasti ditolak.
|
|
80
|
+
function exampleOf(f) {
|
|
81
|
+
if (f.example == null || f.example === '') return ''
|
|
82
|
+
if (f.type === 'lookup' && Array.isArray(f.options) && !f.options.includes(f.example)) return ''
|
|
83
|
+
return f.example
|
|
84
|
+
}
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<template>
|
|
88
|
+
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40 p-3 space-y-2 text-xs text-gray-600 dark:text-gray-400">
|
|
89
|
+
<p class="font-medium text-gray-800 dark:text-gray-200">{{ t('dataTools.guide.title') }}</p>
|
|
90
|
+
|
|
91
|
+
<p v-if="loading" class="flex items-center gap-1.5">
|
|
92
|
+
<Loader2 class="w-3.5 h-3.5 animate-spin" />{{ t('dataTools.guide.loading') }}
|
|
93
|
+
</p>
|
|
94
|
+
<p v-else-if="error" class="text-amber-600 dark:text-amber-400">{{ t('dataTools.guide.loadError') }}</p>
|
|
95
|
+
|
|
96
|
+
<template v-else-if="rows.length">
|
|
97
|
+
<p v-if="hasLookup">{{ t('dataTools.guide.lookupNote') }}</p>
|
|
98
|
+
<p v-if="hasExample">{{ t('dataTools.guide.exampleRowNote') }}</p>
|
|
99
|
+
|
|
100
|
+
<ul class="divide-y divide-gray-200 dark:divide-gray-700 max-h-72 overflow-y-auto scrollbar-rounded pr-1">
|
|
101
|
+
<li v-for="f in rows" :key="f.key" class="py-1.5 space-y-1">
|
|
102
|
+
<div class="flex flex-wrap items-center gap-1.5">
|
|
103
|
+
<code class="text-gray-800 dark:text-gray-100 font-medium">{{ f.key }}</code>
|
|
104
|
+
<span
|
|
105
|
+
:class="f.required
|
|
106
|
+
? 'px-1.5 rounded bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
|
107
|
+
: 'px-1.5 rounded bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'"
|
|
108
|
+
>{{ f.required ? t('dataTools.guide.required') : t('dataTools.guide.optional') }}</span>
|
|
109
|
+
<span v-if="typeText(f)">{{ typeText(f) }}</span>
|
|
110
|
+
<span v-if="f.default != null && f.default !== ''">{{ t('dataTools.guide.default', { value: f.default }) }}</span>
|
|
111
|
+
<span v-if="exampleOf(f)">{{ t('dataTools.guide.example', { value: exampleOf(f) }) }}</span>
|
|
112
|
+
</div>
|
|
113
|
+
<p v-if="hintOf(f)">{{ hintOf(f) }}</p>
|
|
114
|
+
<div v-if="choicesOf(f).length" class="flex flex-wrap items-center gap-1 max-h-24 overflow-y-auto scrollbar-rounded">
|
|
115
|
+
<span>{{ choicesLabel(f) }}</span>
|
|
116
|
+
<code
|
|
117
|
+
v-for="o in choicesOf(f)"
|
|
118
|
+
:key="o"
|
|
119
|
+
class="px-1.5 py-0.5 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-gray-800 dark:text-gray-100"
|
|
120
|
+
>{{ o }}</code>
|
|
121
|
+
</div>
|
|
122
|
+
<p v-else-if="f.type === 'lookup'" :class="f.required ? 'text-amber-600 dark:text-amber-400' : ''">
|
|
123
|
+
{{ t('dataTools.guide.lookupEmpty') }}
|
|
124
|
+
</p>
|
|
125
|
+
<p v-if="f.options_truncated" class="italic">{{ t('dataTools.guide.truncated') }}</p>
|
|
126
|
+
</li>
|
|
127
|
+
</ul>
|
|
128
|
+
</template>
|
|
129
|
+
</div>
|
|
130
|
+
</template>
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
// Dasbor metrik SDM (kontrak `ha2-hr-metrics.md`): kartu bulan terakhir, tren per bulan (satu
|
|
3
|
+
// metrik per grafik — tak ada sumbu ganda), dan rincian satu bulan per dimensi.
|
|
4
|
+
// Dipakai identik oleh Hub (`/hr-metrics`, ringkasan direksi tanpa seat HRIS) dan HRIS (plus klik
|
|
5
|
+
// angka → daftar per orang lewat emit `drill`). Endpoint & gerbang milik pemanggil lewat `companyId`.
|
|
6
|
+
//
|
|
7
|
+
// 403 `module_not_licensed` (tenant tanpa lisensi HRIS) → emit `unlicensed` dan tak merender apa pun
|
|
8
|
+
// (kanon: sembunyikan senyap); 403 `forbidden` biasa tetap ditampilkan sebagai galat.
|
|
9
|
+
import { ref, computed, watch, onMounted } from 'vue'
|
|
10
|
+
import { useI18n } from 'vue-i18n'
|
|
11
|
+
import { useAppStore } from '@initsolusidigital/worksuite-core/stores/app.js'
|
|
12
|
+
import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
|
|
13
|
+
import { hrMetricsApi, HR_METRICS_DIMENSIONS, HR_METRICS_KEYS, HR_METRICS_CHARTS } from '@initsolusidigital/worksuite-core/api/endpoints/hrMetrics.js'
|
|
14
|
+
import AppHrMetricsStat from './AppHrMetricsStat.vue'
|
|
15
|
+
import AppInput from '@initsolusidigital/worksuite-ui/AppInput.vue'
|
|
16
|
+
import AppCombobox from '@initsolusidigital/worksuite-ui/AppCombobox.vue'
|
|
17
|
+
import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
|
|
18
|
+
import AppAlert from '@initsolusidigital/worksuite-ui/AppAlert.vue'
|
|
19
|
+
import AppSpinner from '@initsolusidigital/worksuite-ui/AppSpinner.vue'
|
|
20
|
+
import AppEmptyState from '@initsolusidigital/worksuite-ui/AppEmptyState.vue'
|
|
21
|
+
import { BarChart3, Table2, LineChart } from '@lucide/vue'
|
|
22
|
+
|
|
23
|
+
const props = defineProps({
|
|
24
|
+
companyId: { type: String, required: true },
|
|
25
|
+
// HRIS: angka headcount/masuk/keluar bisa diklik → `drill({period, status, department_id})`.
|
|
26
|
+
drillable: { type: Boolean, default: false },
|
|
27
|
+
})
|
|
28
|
+
const emit = defineEmits(['unlicensed', 'drill'])
|
|
29
|
+
const { t, te, locale } = useI18n()
|
|
30
|
+
const appStore = useAppStore()
|
|
31
|
+
|
|
32
|
+
// ── Filter: 12 bulan terakhir, opsional departemen ──
|
|
33
|
+
const ym = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
34
|
+
const now = new Date()
|
|
35
|
+
const from = ref(ym(new Date(now.getFullYear(), now.getMonth() - 11, 1)))
|
|
36
|
+
const to = ref(ym(now))
|
|
37
|
+
const departmentId = ref(null)
|
|
38
|
+
const departments = ref([])
|
|
39
|
+
const departmentOptions = computed(() => departments.value.map((d) => ({ value: d.id, label: d.name })))
|
|
40
|
+
|
|
41
|
+
const loading = ref(true)
|
|
42
|
+
const error = ref('')
|
|
43
|
+
const unlicensed = ref(false)
|
|
44
|
+
const summary = ref(null)
|
|
45
|
+
const periods = computed(() => summary.value?.periods ?? [])
|
|
46
|
+
const minGroup = computed(() => summary.value?.min_group_size ?? 5)
|
|
47
|
+
const latest = computed(() => [...periods.value].reverse().find((p) => !p.snapshot_missing) ?? periods.value[periods.value.length - 1] ?? null)
|
|
48
|
+
const statKeys = computed(() => HR_METRICS_KEYS.filter((k) => k !== 'payroll_cost_per_employee' || summary.value?.includes_pay))
|
|
49
|
+
const definition = (k) => summary.value?.definitions?.[k] ?? ''
|
|
50
|
+
|
|
51
|
+
async function load() {
|
|
52
|
+
loading.value = true; error.value = ''
|
|
53
|
+
try {
|
|
54
|
+
const params = { from: from.value, to: to.value }
|
|
55
|
+
if (departmentId.value) params.department_id = departmentId.value
|
|
56
|
+
const { data } = await hrMetricsApi.summary(props.companyId, params)
|
|
57
|
+
summary.value = data?.data ?? null
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const e = normalizeApiError(err)
|
|
60
|
+
if (e.reason === 'module_not_licensed') { unlicensed.value = true; emit('unlicensed'); return }
|
|
61
|
+
error.value = e.isValidation && e.fieldErrors ? Object.values(e.fieldErrors).join(' ') : e.isForbidden ? t('common.forbidden') : t('hrMetrics.loadError')
|
|
62
|
+
} finally { loading.value = false }
|
|
63
|
+
}
|
|
64
|
+
onMounted(async () => {
|
|
65
|
+
load()
|
|
66
|
+
try { departments.value = (await hrMetricsApi.departments()).data?.data ?? [] } catch { departments.value = [] }
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// ── Grafik tren: satu metrik per grafik, satu seri → tanpa legenda, 2 px, marker saat hover ──
|
|
70
|
+
const trendView = ref('chart')
|
|
71
|
+
const fmtNum = (v, digits = 1) => (v === null || v === undefined ? '—' : new Intl.NumberFormat(locale.value === 'id' ? 'id-ID' : 'en-US', { maximumFractionDigits: digits }).format(v))
|
|
72
|
+
const chartKeys = HR_METRICS_CHARTS
|
|
73
|
+
function chartOptions(key) {
|
|
74
|
+
const dark = appStore.darkMode
|
|
75
|
+
const ps = periods.value
|
|
76
|
+
return {
|
|
77
|
+
chart: { type: 'line', toolbar: { show: false }, zoom: { enabled: false }, animations: { enabled: false }, background: 'transparent', fontFamily: 'inherit' },
|
|
78
|
+
theme: { mode: dark ? 'dark' : 'light' },
|
|
79
|
+
colors: ['#3b82f6'],
|
|
80
|
+
stroke: { width: 2, curve: 'straight' },
|
|
81
|
+
markers: { size: 0, hover: { size: 5 } },
|
|
82
|
+
grid: { borderColor: dark ? '#374151' : '#e5e7eb', strokeDashArray: 3, padding: { left: 8, right: 8 } },
|
|
83
|
+
xaxis: { categories: ps.map((p) => p.period), labels: { style: { colors: dark ? '#9ca3af' : '#6b7280', fontSize: '11px' } }, axisBorder: { show: false }, axisTicks: { show: false } },
|
|
84
|
+
yaxis: { labels: { style: { colors: dark ? '#9ca3af' : '#6b7280', fontSize: '11px' }, formatter: (v) => fmtNum(v, 1) }, forceNiceScale: true, min: 0 },
|
|
85
|
+
tooltip: {
|
|
86
|
+
theme: dark ? 'dark' : 'light',
|
|
87
|
+
// Tooltip ikut menyebut n — angka tanpa n adalah angka yang menyesatkan.
|
|
88
|
+
custom: ({ dataPointIndex }) => {
|
|
89
|
+
const p = ps[dataPointIndex]
|
|
90
|
+
if (!p) return ''
|
|
91
|
+
const body = p.snapshot_missing ? t('hrMetrics.snapshotMissing') : p.suppressed ? t('hrMetrics.suppressed', { min: minGroup.value }) : `${fmtNum(p[key])}`
|
|
92
|
+
return `<div class="px-2 py-1 text-xs"><div class="font-semibold">${p.period}</div><div>${body}</div><div class="opacity-70">n = ${p.sample_size ?? '—'}</div></div>`
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
dataLabels: { enabled: false },
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Nilai tersembunyi/absen = celah di garis (null), bukan nol.
|
|
99
|
+
const chartSeries = (key) => [{ name: t(`hrMetrics.metric.${key}`), data: periods.value.map((p) => (p.suppressed || p.snapshot_missing || p[key] === null || p[key] === undefined ? null : Number(p[key]))) }]
|
|
100
|
+
|
|
101
|
+
// ── Rincian satu bulan ──
|
|
102
|
+
const bdPeriod = ref(null)
|
|
103
|
+
const bdDimension = ref('department')
|
|
104
|
+
const breakdown = ref(null)
|
|
105
|
+
const bdLoading = ref(false)
|
|
106
|
+
const bdError = ref('')
|
|
107
|
+
const periodOptions = computed(() => periods.value.filter((p) => !p.snapshot_missing).map((p) => ({ value: p.period, label: p.period })))
|
|
108
|
+
const dimensionOptions = computed(() => HR_METRICS_DIMENSIONS.map((d) => ({ value: d, label: t(`hrMetrics.dimension.${d}`) })))
|
|
109
|
+
watch(latest, (p) => { if (p && !bdPeriod.value) bdPeriod.value = p.period })
|
|
110
|
+
async function loadBreakdown() {
|
|
111
|
+
if (!bdPeriod.value) return
|
|
112
|
+
bdLoading.value = true; bdError.value = ''
|
|
113
|
+
try {
|
|
114
|
+
const params = { period: bdPeriod.value, dimension: bdDimension.value }
|
|
115
|
+
if (departmentId.value) params.department_id = departmentId.value
|
|
116
|
+
breakdown.value = (await hrMetricsApi.breakdown(props.companyId, params)).data?.data ?? null
|
|
117
|
+
} catch (err) { bdError.value = normalizeApiError(err).isForbidden ? t('common.forbidden') : t('hrMetrics.loadError') } finally { bdLoading.value = false }
|
|
118
|
+
}
|
|
119
|
+
watch([bdPeriod, bdDimension], loadBreakdown)
|
|
120
|
+
const bdCols = computed(() => ['headcount', 'hires', 'separations', 'turnover_rate', 'absence_rate', 'average_tenure_months', ...(summary.value?.includes_pay ? ['payroll_cost_per_employee'] : [])].filter((k) => bdDimension.value !== 'exit_reason' || ['separations'].includes(k) || true))
|
|
121
|
+
// Label kelompok: nama (departemen/jabatan) dari backend; kode baku (band/usia/`unspecified`) diterjemahkan dari kunci.
|
|
122
|
+
const groupLabel = (item) => (te(`hrMetrics.band.${item.key}`) ? t(`hrMetrics.band.${item.key}`) : (item.label ?? item.key))
|
|
123
|
+
|
|
124
|
+
function drill(status, period = latest.value?.period) {
|
|
125
|
+
if (!props.drillable || !period) return
|
|
126
|
+
emit('drill', { period, status, department_id: departmentId.value })
|
|
127
|
+
}
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
<template>
|
|
131
|
+
<div v-if="!unlicensed" class="space-y-6" data-hr-metrics>
|
|
132
|
+
<!-- Filter: satu baris di atas grafik -->
|
|
133
|
+
<div class="flex flex-wrap items-end gap-3">
|
|
134
|
+
<AppInput v-model="from" type="month" :label="t('hrMetrics.filter.from')" class="w-40" />
|
|
135
|
+
<AppInput v-model="to" type="month" :label="t('hrMetrics.filter.to')" class="w-40" />
|
|
136
|
+
<AppCombobox v-model="departmentId" :options="departmentOptions" :label="t('hrMetrics.filter.department')" :placeholder="t('hrMetrics.filter.allDepartments')" class="w-64" />
|
|
137
|
+
<AppButton variant="secondary" size="lg" :loading="loading" data-hr-apply @click="load(); loadBreakdown()">{{ t('hrMetrics.filter.apply') }}</AppButton>
|
|
138
|
+
</div>
|
|
139
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('hrMetrics.evidenceNote', { min: minGroup }) }}</p>
|
|
140
|
+
|
|
141
|
+
<AppAlert v-if="error" variant="error" :message="error" />
|
|
142
|
+
<div v-else-if="loading" class="py-10 flex justify-center"><AppSpinner /></div>
|
|
143
|
+
<AppEmptyState v-else-if="!periods.length" :label="t('hrMetrics.noPeriods')">
|
|
144
|
+
<template #icon><BarChart3 class="w-6 h-6 text-gray-400" :stroke-width="1.5" /></template>
|
|
145
|
+
</AppEmptyState>
|
|
146
|
+
|
|
147
|
+
<template v-else>
|
|
148
|
+
<!-- Kartu bulan terakhir -->
|
|
149
|
+
<section>
|
|
150
|
+
<h2 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">{{ t('hrMetrics.latest') }} <span class="font-mono text-xs text-gray-400">{{ latest?.period }}</span><span v-if="drillable" class="ml-2 text-xs font-normal text-gray-400">{{ t('hrMetrics.employees.drillHint') }}</span></h2>
|
|
151
|
+
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-3">
|
|
152
|
+
<AppHrMetricsStat v-for="k in statKeys" :key="k" :metric-key="k" :period="latest" :min-group-size="minGroup" :definition="definition(k)"
|
|
153
|
+
:clickable="drillable && ['headcount', 'hires', 'separations'].includes(k)" @click="drill({ headcount: 'active', hires: 'joined', separations: 'exited' }[k])" />
|
|
154
|
+
</div>
|
|
155
|
+
</section>
|
|
156
|
+
|
|
157
|
+
<!-- Tren: grafik kecil berjajar (satu metrik per grafik) + tampilan tabel -->
|
|
158
|
+
<section class="space-y-3">
|
|
159
|
+
<div class="flex items-center justify-between gap-2">
|
|
160
|
+
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('hrMetrics.trend.title') }}</h2>
|
|
161
|
+
<div class="inline-flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden text-xs">
|
|
162
|
+
<button type="button" class="px-2.5 py-1.5 inline-flex items-center gap-1" :class="trendView === 'chart' ? 'bg-blue-600 text-white' : 'text-gray-600 dark:text-gray-300'" :aria-pressed="trendView === 'chart'" @click="trendView = 'chart'"><LineChart class="w-3.5 h-3.5" />{{ t('hrMetrics.trend.chart') }}</button>
|
|
163
|
+
<button type="button" class="px-2.5 py-1.5 inline-flex items-center gap-1" :class="trendView === 'table' ? 'bg-blue-600 text-white' : 'text-gray-600 dark:text-gray-300'" :aria-pressed="trendView === 'table'" @click="trendView = 'table'"><Table2 class="w-3.5 h-3.5" />{{ t('hrMetrics.trend.table') }}</button>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
<div v-if="trendView === 'chart'" class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
167
|
+
<div v-for="k in chartKeys" :key="k" class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3" :data-hr-chart="k">
|
|
168
|
+
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" :title="definition(k)">{{ t(`hrMetrics.metric.${k}`) }}</p>
|
|
169
|
+
<apexchart type="line" height="180" :options="chartOptions(k)" :series="chartSeries(k)" />
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
<div v-else class="overflow-x-auto scrollbar-rounded rounded-xl border border-gray-200 dark:border-gray-700">
|
|
173
|
+
<table class="min-w-full text-sm">
|
|
174
|
+
<thead class="bg-gray-50 dark:bg-gray-900/40 text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
175
|
+
<tr><th class="px-3 py-2 text-left">{{ t('hrMetrics.breakdown.period') }}</th><th class="px-3 py-2 text-right">n</th><th v-for="k in statKeys" :key="k" class="px-3 py-2 text-right whitespace-nowrap">{{ t(`hrMetrics.metric.${k}`) }}</th></tr>
|
|
176
|
+
</thead>
|
|
177
|
+
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
178
|
+
<tr v-for="p in periods" :key="p.period">
|
|
179
|
+
<td class="px-3 py-2 font-mono text-xs text-gray-700 dark:text-gray-300">{{ p.period }}</td>
|
|
180
|
+
<td class="px-3 py-2 text-right text-gray-500">{{ p.sample_size ?? '—' }}</td>
|
|
181
|
+
<td v-for="k in statKeys" :key="k" class="px-3 py-2 text-right text-gray-800 dark:text-gray-200">
|
|
182
|
+
<span v-if="p.snapshot_missing" class="text-xs text-gray-400">{{ t('hrMetrics.snapshotMissing') }}</span>
|
|
183
|
+
<span v-else-if="p.suppressed || p[k] === null || p[k] === undefined" class="text-xs text-amber-600 dark:text-amber-400">{{ t('hrMetrics.suppressed', { min: minGroup }) }}</span>
|
|
184
|
+
<span v-else>{{ fmtNum(p[k], k === 'payroll_cost_per_employee' ? 0 : 1) }}</span>
|
|
185
|
+
</td>
|
|
186
|
+
</tr>
|
|
187
|
+
</tbody>
|
|
188
|
+
</table>
|
|
189
|
+
</div>
|
|
190
|
+
</section>
|
|
191
|
+
|
|
192
|
+
<!-- Rincian satu bulan per dimensi -->
|
|
193
|
+
<section class="space-y-3" data-hr-breakdown>
|
|
194
|
+
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('hrMetrics.breakdown.title') }}</h2>
|
|
195
|
+
<div class="flex flex-wrap items-end gap-3">
|
|
196
|
+
<AppCombobox v-model="bdPeriod" :options="periodOptions" :label="t('hrMetrics.breakdown.period')" hide-clear class="w-40" />
|
|
197
|
+
<AppCombobox v-model="bdDimension" :options="dimensionOptions" :label="t('hrMetrics.breakdown.dimension')" hide-clear class="w-56" />
|
|
198
|
+
</div>
|
|
199
|
+
<AppAlert v-if="bdError" variant="error" :message="bdError" />
|
|
200
|
+
<div v-else-if="bdLoading" class="py-6 flex justify-center"><AppSpinner /></div>
|
|
201
|
+
<template v-else-if="breakdown">
|
|
202
|
+
<AppAlert v-if="breakdown.population_suppressed" variant="warning" :message="t('hrMetrics.breakdown.populationSuppressed')" />
|
|
203
|
+
<p v-else-if="!breakdown.items?.length" class="text-sm text-gray-400">{{ t('hrMetrics.breakdown.empty') }}</p>
|
|
204
|
+
<div v-else class="overflow-x-auto scrollbar-rounded rounded-xl border border-gray-200 dark:border-gray-700">
|
|
205
|
+
<table class="min-w-full text-sm">
|
|
206
|
+
<thead class="bg-gray-50 dark:bg-gray-900/40 text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
207
|
+
<tr><th class="px-3 py-2 text-left">{{ t('hrMetrics.breakdown.group') }}</th><th class="px-3 py-2 text-right">n</th><th v-for="k in bdCols" :key="k" class="px-3 py-2 text-right whitespace-nowrap">{{ t(`hrMetrics.metric.${k}`) }}</th></tr>
|
|
208
|
+
</thead>
|
|
209
|
+
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
210
|
+
<tr v-for="it in breakdown.items" :key="it.key" :data-hr-group="it.key">
|
|
211
|
+
<td class="px-3 py-2 text-gray-900 dark:text-white">{{ groupLabel(it) }}</td>
|
|
212
|
+
<td class="px-3 py-2 text-right text-gray-500">{{ it.sample_size ?? '—' }}</td>
|
|
213
|
+
<template v-if="it.suppressed">
|
|
214
|
+
<td :colspan="bdCols.length" class="px-3 py-2 text-xs text-amber-600 dark:text-amber-400">{{ t('hrMetrics.suppressed', { min: breakdown.min_group_size ?? minGroup }) }}</td>
|
|
215
|
+
</template>
|
|
216
|
+
<template v-else>
|
|
217
|
+
<td v-for="k in bdCols" :key="k" class="px-3 py-2 text-right text-gray-800 dark:text-gray-200">
|
|
218
|
+
<button v-if="drillable && bdDimension === 'department' && ['headcount', 'hires', 'separations'].includes(k) && it.key !== 'unspecified'" type="button" class="text-blue-600 dark:text-blue-400 hover:underline" @click="emit('drill', { period: bdPeriod, status: { headcount: 'active', hires: 'joined', separations: 'exited' }[k], department_id: it.key })">{{ fmtNum(it[k], 0) }}</button>
|
|
219
|
+
<span v-else>{{ fmtNum(it[k], k === 'payroll_cost_per_employee' ? 0 : 1) }}</span>
|
|
220
|
+
</td>
|
|
221
|
+
</template>
|
|
222
|
+
</tr>
|
|
223
|
+
</tbody>
|
|
224
|
+
</table>
|
|
225
|
+
</div>
|
|
226
|
+
<p class="text-xs text-gray-400">{{ t('hrMetrics.breakdown.secondaryNote') }}</p>
|
|
227
|
+
</template>
|
|
228
|
+
</section>
|
|
229
|
+
</template>
|
|
230
|
+
</div>
|
|
231
|
+
</template>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
// Daftar per orang di balik satu angka metrik (HRIS saja: `GET hr_metrics/employees` menuntut
|
|
3
|
+
// `hr_metrics` + `employees` can_read). Tanpa ambang kelompok — pemakainya HR ber-seat.
|
|
4
|
+
import { ref, watch, computed } from 'vue'
|
|
5
|
+
import { useI18n } from 'vue-i18n'
|
|
6
|
+
import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
|
|
7
|
+
import { hrMetricsApi } from '@initsolusidigital/worksuite-core/api/endpoints/hrMetrics.js'
|
|
8
|
+
import AppModal from '@initsolusidigital/worksuite-ui/AppModal.vue'
|
|
9
|
+
import AppSpinner from '@initsolusidigital/worksuite-ui/AppSpinner.vue'
|
|
10
|
+
import AppAlert from '@initsolusidigital/worksuite-ui/AppAlert.vue'
|
|
11
|
+
import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
|
|
12
|
+
import AppBadge from '@initsolusidigital/worksuite-ui/AppBadge.vue'
|
|
13
|
+
|
|
14
|
+
const props = defineProps({
|
|
15
|
+
companyId: { type: String, required: true },
|
|
16
|
+
query: { type: Object, required: true }, // { period, status, department_id? }
|
|
17
|
+
})
|
|
18
|
+
const emit = defineEmits(['close'])
|
|
19
|
+
const { t, locale } = useI18n()
|
|
20
|
+
const rows = ref([])
|
|
21
|
+
const total = ref(0)
|
|
22
|
+
const page = ref(1)
|
|
23
|
+
const loading = ref(false)
|
|
24
|
+
const error = ref('')
|
|
25
|
+
const perPage = 25
|
|
26
|
+
const pages = computed(() => Math.max(1, Math.ceil(total.value / perPage)))
|
|
27
|
+
const fmt = (v) => (v === null || v === undefined ? '—' : new Intl.NumberFormat(locale.value === 'id' ? 'id-ID' : 'en-US', { maximumFractionDigits: 1 }).format(v))
|
|
28
|
+
|
|
29
|
+
async function load() {
|
|
30
|
+
loading.value = true; error.value = ''
|
|
31
|
+
try {
|
|
32
|
+
const params = { period: props.query.period, status: props.query.status, page: page.value, rowsPerPage: perPage }
|
|
33
|
+
if (props.query.department_id) params.department_id = props.query.department_id
|
|
34
|
+
const { data } = await hrMetricsApi.employees(props.companyId, params)
|
|
35
|
+
const d = data?.data
|
|
36
|
+
rows.value = d?.data ?? (Array.isArray(d) ? d : [])
|
|
37
|
+
total.value = d?.total ?? rows.value.length
|
|
38
|
+
} catch (err) { error.value = normalizeApiError(err).isForbidden ? t('common.forbidden') : t('hrMetrics.employees.loadError') } finally { loading.value = false }
|
|
39
|
+
}
|
|
40
|
+
// Ganti kueri = kembali ke halaman 1; `page` dipantau sendiri supaya satu perubahan tak memuat dua kali.
|
|
41
|
+
watch(() => props.query, () => { if (page.value !== 1) page.value = 1; else load() }, { immediate: true, deep: true })
|
|
42
|
+
watch(page, load)
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<AppModal :title="t('hrMetrics.employees.title')" :subtitle="`${t(`hrMetrics.employees.status.${query.status}`)} · ${query.period}`" size="xl" @close="emit('close')">
|
|
47
|
+
<AppAlert v-if="error" variant="error" :message="error" />
|
|
48
|
+
<div v-else-if="loading" class="py-8 flex justify-center"><AppSpinner /></div>
|
|
49
|
+
<p v-else-if="!rows.length" class="text-sm text-gray-400 py-6 text-center">{{ t('hrMetrics.employees.empty') }}</p>
|
|
50
|
+
<div v-else class="overflow-x-auto scrollbar-rounded rounded-xl border border-gray-200 dark:border-gray-700" data-hr-employees>
|
|
51
|
+
<table class="min-w-full text-sm">
|
|
52
|
+
<thead class="bg-gray-50 dark:bg-gray-900/40 text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
53
|
+
<tr>
|
|
54
|
+
<th class="px-3 py-2 text-left">{{ t('hrMetrics.employees.colName') }}</th>
|
|
55
|
+
<th class="px-3 py-2 text-left">{{ t('hrMetrics.employees.colDepartment') }}</th>
|
|
56
|
+
<th class="px-3 py-2 text-right">{{ t('hrMetrics.employees.colTenure') }}</th>
|
|
57
|
+
<th class="px-3 py-2 text-right">{{ t('hrMetrics.employees.colPresent') }}</th>
|
|
58
|
+
<th class="px-3 py-2 text-right">{{ t('hrMetrics.employees.colAbsent') }}</th>
|
|
59
|
+
<th class="px-3 py-2 text-right">{{ t('hrMetrics.employees.colLate') }}</th>
|
|
60
|
+
<th class="px-3 py-2 text-right">{{ t('hrMetrics.employees.colOvertime') }}</th>
|
|
61
|
+
<th class="px-3 py-2 text-right">{{ t('hrMetrics.employees.colLeave') }}</th>
|
|
62
|
+
<th class="px-3 py-2 text-left">{{ t('hrMetrics.employees.colExit') }}</th>
|
|
63
|
+
</tr>
|
|
64
|
+
</thead>
|
|
65
|
+
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
66
|
+
<tr v-for="r in rows" :key="r.employee_id">
|
|
67
|
+
<td class="px-3 py-2"><p class="text-gray-900 dark:text-white">{{ r.employee_name }}</p><p class="text-xs font-mono text-gray-400">{{ r.employee_code }}</p></td>
|
|
68
|
+
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">{{ r.department_name || '—' }}</td>
|
|
69
|
+
<td class="px-3 py-2 text-right">{{ fmt(r.tenure_months) }}</td>
|
|
70
|
+
<td class="px-3 py-2 text-right">{{ fmt(r.present_days) }}</td>
|
|
71
|
+
<td class="px-3 py-2 text-right">{{ fmt(r.absent_days) }}</td>
|
|
72
|
+
<td class="px-3 py-2 text-right">{{ fmt(r.late_minutes) }}</td>
|
|
73
|
+
<td class="px-3 py-2 text-right">{{ fmt(r.overtime_minutes) }}</td>
|
|
74
|
+
<td class="px-3 py-2 text-right">{{ fmt(r.leave_days) }}</td>
|
|
75
|
+
<td class="px-3 py-2"><AppBadge v-if="r.exited_in_period" color="red">{{ r.exit_type || r.exit_reason_code || '—' }}</AppBadge><span v-else class="text-gray-300">—</span></td>
|
|
76
|
+
</tr>
|
|
77
|
+
</tbody>
|
|
78
|
+
</table>
|
|
79
|
+
</div>
|
|
80
|
+
<template #footer>
|
|
81
|
+
<span class="text-xs text-gray-400 mr-auto">{{ total }} · {{ page }}/{{ pages }}</span>
|
|
82
|
+
<AppButton variant="secondary" size="sm" :disabled="page <= 1 || loading" @click="page--">‹</AppButton>
|
|
83
|
+
<AppButton variant="secondary" size="sm" :disabled="page >= pages || loading" @click="page++">›</AppButton>
|
|
84
|
+
<AppButton variant="secondary" size="sm" @click="emit('close')">{{ t('common.close') }}</AppButton>
|
|
85
|
+
</template>
|
|
86
|
+
</AppModal>
|
|
87
|
+
</template>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
// Satu angka metrik SDM ber-konteks: nilai + periode + n. Tiga keadaan non-angka dibedakan
|
|
3
|
+
// terang-terangan — `suppressed` (kelompok < ambang), `snapshot_missing` (bulan belum difoto),
|
|
4
|
+
// null biasa — karena menulis "0" untuk ketiganya adalah vonis yang datanya tak pernah ada.
|
|
5
|
+
import { computed } from 'vue'
|
|
6
|
+
import { useI18n } from 'vue-i18n'
|
|
7
|
+
import { EyeOff, CalendarOff, Info } from '@lucide/vue'
|
|
8
|
+
|
|
9
|
+
const props = defineProps({
|
|
10
|
+
metricKey: { type: String, required: true },
|
|
11
|
+
period: { type: Object, default: null }, // satu baris `periods[]` dari summary
|
|
12
|
+
minGroupSize: { type: Number, default: 5 },
|
|
13
|
+
definition: { type: String, default: '' }, // kalimat rumus (English fallback dari backend)
|
|
14
|
+
clickable: { type: Boolean, default: false },
|
|
15
|
+
})
|
|
16
|
+
const emit = defineEmits(['click'])
|
|
17
|
+
const { t, te, locale } = useI18n()
|
|
18
|
+
|
|
19
|
+
const UNIT = {
|
|
20
|
+
turnover_rate: 'percent', voluntary_turnover_rate: 'percent', absence_rate: 'percent',
|
|
21
|
+
late_minutes_per_employee: 'minutes', overtime_hours_per_employee: 'hours', average_tenure_months: 'months',
|
|
22
|
+
headcount: 'people', average_headcount: 'people', hires: 'people', separations: 'people',
|
|
23
|
+
}
|
|
24
|
+
const label = computed(() => te(`hrMetrics.metric.${props.metricKey}`) ? t(`hrMetrics.metric.${props.metricKey}`) : props.metricKey)
|
|
25
|
+
const raw = computed(() => props.period?.[props.metricKey])
|
|
26
|
+
const state = computed(() => {
|
|
27
|
+
if (!props.period) return 'empty'
|
|
28
|
+
if (props.period.snapshot_missing) return 'missing'
|
|
29
|
+
if (props.period.suppressed || raw.value === null || raw.value === undefined) return 'suppressed'
|
|
30
|
+
return 'value'
|
|
31
|
+
})
|
|
32
|
+
const value = computed(() => {
|
|
33
|
+
const v = raw.value
|
|
34
|
+
if (props.metricKey === 'payroll_cost_per_employee') return new Intl.NumberFormat(locale.value === 'id' ? 'id-ID' : 'en-US', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(v)
|
|
35
|
+
const n = Number(v)
|
|
36
|
+
const digits = Number.isInteger(n) ? 0 : 1
|
|
37
|
+
return new Intl.NumberFormat(locale.value === 'id' ? 'id-ID' : 'en-US', { maximumFractionDigits: digits }).format(n)
|
|
38
|
+
})
|
|
39
|
+
const unit = computed(() => (UNIT[props.metricKey] ? t(`hrMetrics.unit.${UNIT[props.metricKey]}`) : ''))
|
|
40
|
+
const sampleText = computed(() => {
|
|
41
|
+
const n = props.metricKey === 'payroll_cost_per_employee' ? props.period?.payroll_sample_size : props.period?.sample_size
|
|
42
|
+
return n === null || n === undefined ? t('hrMetrics.sampleNoN', { period: props.period?.period ?? '' }) : t('hrMetrics.sample', { n, period: props.period?.period ?? '' })
|
|
43
|
+
})
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<component :is="clickable && state === 'value' ? 'button' : 'div'" :type="clickable ? 'button' : undefined"
|
|
48
|
+
class="w-full text-left rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 flex flex-col gap-1"
|
|
49
|
+
:class="clickable && state === 'value' ? 'hover:border-blue-400 dark:hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer' : ''"
|
|
50
|
+
:data-hr-stat="metricKey" @click="clickable && state === 'value' && emit('click')">
|
|
51
|
+
<div class="flex items-start justify-between gap-2">
|
|
52
|
+
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ label }}</p>
|
|
53
|
+
<span v-if="definition" class="text-gray-400 shrink-0" :title="definition" :aria-label="t('hrMetrics.definitions')"><Info class="w-3.5 h-3.5" /></span>
|
|
54
|
+
</div>
|
|
55
|
+
<p v-if="state === 'value'" class="text-2xl font-semibold text-gray-900 dark:text-white leading-tight">{{ value }}<span v-if="unit" class="ml-1 text-sm font-normal text-gray-400">{{ unit }}</span></p>
|
|
56
|
+
<p v-else-if="state === 'suppressed'" class="text-sm text-gray-500 dark:text-gray-400 inline-flex items-center gap-1.5"><EyeOff class="w-4 h-4 text-amber-500" />{{ t('hrMetrics.suppressed', { min: minGroupSize }) }}</p>
|
|
57
|
+
<p v-else-if="state === 'missing'" class="text-sm text-gray-500 dark:text-gray-400 inline-flex items-center gap-1.5"><CalendarOff class="w-4 h-4 text-gray-400" />{{ t('hrMetrics.snapshotMissing') }}</p>
|
|
58
|
+
<p v-else class="text-sm text-gray-400">—</p>
|
|
59
|
+
<p class="text-[11px] text-gray-400 dark:text-gray-500 font-mono">{{ sampleText }}</p>
|
|
60
|
+
</component>
|
|
61
|
+
</template>
|