@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,265 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, onMounted } from 'vue'
|
|
3
|
+
import { useI18n } from 'vue-i18n'
|
|
4
|
+
import { FileCheck2, UserCheck, Loader2, Check, X, ExternalLink } from '@lucide/vue'
|
|
5
|
+
import { useApprovals } from '@initsolusidigital/worksuite-core/composables/useApprovals.js'
|
|
6
|
+
import { useCrossAppLink } from '@initsolusidigital/worksuite-core/composables/useCrossAppLink.js'
|
|
7
|
+
import { useToast } from '@initsolusidigital/worksuite-core/composables/useToast.js'
|
|
8
|
+
import AppDataTable from '@initsolusidigital/worksuite-ui/AppDataTable.vue'
|
|
9
|
+
import AppBadge from '@initsolusidigital/worksuite-ui/AppBadge.vue'
|
|
10
|
+
import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
|
|
11
|
+
import AppTooltip from '@initsolusidigital/worksuite-ui/AppTooltip.vue'
|
|
12
|
+
import AppModal from '@initsolusidigital/worksuite-ui/AppModal.vue'
|
|
13
|
+
import AppTextarea from '@initsolusidigital/worksuite-ui/AppTextarea.vue'
|
|
14
|
+
import AppApprovalSubjectModal from './AppApprovalSubjectModal.vue'
|
|
15
|
+
|
|
16
|
+
// Approval Inbox. Daftar "Menunggu persetujuan saya" LINTAS-MODUL: satu tabel
|
|
17
|
+
// memuat `entity_type` apa pun. Registry disuplai app pemanggil lewat prop `entities` (pola sama
|
|
18
|
+
// `useDashboardLayout({ widgets })`), jadi komponen ini tak pernah membaca bentuk `approvable`.
|
|
19
|
+
//
|
|
20
|
+
// ⚠️ JANGAN pasangi `useModulePermission`. Hak approver datang dari PENUGASAN LANGKAH, bukan izin
|
|
21
|
+
// modul (kontrak backend) — approver bisa saja tak punya izin modul apa pun.
|
|
22
|
+
const props = defineProps({
|
|
23
|
+
// { [entity_type]: { labelKey, icon, resolver } } — lihat `data/approvalEntities.js`
|
|
24
|
+
entities: { type: Object, default: () => ({}) },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const { t, te, locale } = useI18n()
|
|
28
|
+
const { pushToast } = useToast()
|
|
29
|
+
const {
|
|
30
|
+
rows, loading, acting, loadError, hasMore,
|
|
31
|
+
refresh, fetchMore, approve, reject,
|
|
32
|
+
entityLabelKey, entityIcon, entitySubject, entityTarget,
|
|
33
|
+
canAct, isDelegated, delegatorName, requesterName, currentApprover,
|
|
34
|
+
} = useApprovals({ entities: props.entities })
|
|
35
|
+
|
|
36
|
+
const { isAppUsable, fetchApps } = useCrossAppLink()
|
|
37
|
+
|
|
38
|
+
onMounted(() => {
|
|
39
|
+
refresh()
|
|
40
|
+
// Katalog app untuk pagar N-A tombol "Buka"; singleton, no-op bila sudah termuat.
|
|
41
|
+
fetchApps()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Tujuan resolver registry (layar app pemilik) — hanya bila app tujuannya dilanggan tenant. Kini jadi
|
|
45
|
+
// tombol KEDUA di panel isi: layar itu bergerbang seat, sedangkan penyetuju belum tentu punya seat.
|
|
46
|
+
function targetOf(row) {
|
|
47
|
+
const target = entityTarget(row)
|
|
48
|
+
return target && isAppUsable(target.target_app) ? target : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// "Buka" untuk SEMUA baris: tiap baris di kotak ini menugaskan user login pada langkah aktifnya, jadi
|
|
52
|
+
// ia selalu peserta yang berhak membaca isinya lewat `approval_requests/{id}/subject`.
|
|
53
|
+
const subjectRow = ref(null)
|
|
54
|
+
function openRow(row) { subjectRow.value = row }
|
|
55
|
+
|
|
56
|
+
const columns = computed(() => [
|
|
57
|
+
{ key: 'subject', label: t('approvals.column.subject') },
|
|
58
|
+
{ key: 'type', label: t('approvals.column.type') },
|
|
59
|
+
{ key: 'requester', label: t('approvals.column.requester') },
|
|
60
|
+
{ key: 'step', label: t('approvals.column.step') },
|
|
61
|
+
{ key: 'submitted', label: t('approvals.column.submittedAt') },
|
|
62
|
+
{ key: 'actions', label: '', align: 'right' },
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
// Nama jenis pengajuan, TIGA lapis: kunci dari registry app → kamus paket `approvals.entity.{type}`
|
|
66
|
+
// → label generik. Tipe baru lahir di backend kapan saja, dan app yang registry-nya belum menyusul
|
|
67
|
+
// harus tetap menampilkan sesuatu yang bermakna.
|
|
68
|
+
//
|
|
69
|
+
// ⚠️ Jangan jatuh ke `entity_type` MENTAH: itu KODE tabel, bukan label, dan di kolom yang paling
|
|
70
|
+
// menentukan ia terbaca seperti kebocoran, bukan seperti data. Kamus paket memuat seluruh tipe
|
|
71
|
+
// konsumen Approval Engine, jadi tiap app mendapat label yang benar TANPA mendaftarkan ulang tipe
|
|
72
|
+
// milik app lain di registrinya — registry cukup mengurus `resolver` (subjek) & ikon, yaitu bagian
|
|
73
|
+
// yang memang khas app.
|
|
74
|
+
function typeLabel(row) {
|
|
75
|
+
const key = entityLabelKey(row)
|
|
76
|
+
if (key && te(key)) return t(key)
|
|
77
|
+
const kamus = `approvals.entity.${row.entity_type}`
|
|
78
|
+
if (row.entity_type && te(kamus)) return t(kamus)
|
|
79
|
+
return t('approvals.entity.unknown')
|
|
80
|
+
}
|
|
81
|
+
// "Penyetuju: Budi (atasan langsung)". Asal penugasan hanya ditulis untuk langkah dinamis —
|
|
82
|
+
// langkah role/user biasa tak membawa `resolved_via`, dan menebaknya berarti mengarang keterangan.
|
|
83
|
+
function approverText(row) {
|
|
84
|
+
const ca = currentApprover(row)
|
|
85
|
+
if (!ca) return ''
|
|
86
|
+
const viaKey = ca.via ? `approvals.resolvedVia.${ca.via}` : null
|
|
87
|
+
const via = viaKey && te(viaKey) ? t(viaKey) : ''
|
|
88
|
+
return via ? t('approvals.currentApproverVia', { name: ca.name, via }) : t('approvals.currentApprover', { name: ca.name })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Subjek dari resolver sub-app → fallback ke pemohon → fallback generik. Tak pernah kosong.
|
|
92
|
+
const subjectOf = (row) => entitySubject(row) || requesterName(row) || t('approvals.entity.unknown')
|
|
93
|
+
|
|
94
|
+
function formatDate(iso) {
|
|
95
|
+
if (!iso) return '—'
|
|
96
|
+
return new Date(iso).toLocaleString(locale.value === 'id' ? 'id-ID' : 'en-US',
|
|
97
|
+
{ dateStyle: 'medium', timeStyle: 'short' })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Konfirmasi + catatan ────────────────────────────────────────────────────────────────────
|
|
101
|
+
const dialog = ref(null) // { row, mode: 'approve' | 'reject' }
|
|
102
|
+
const comment = ref('')
|
|
103
|
+
const commentError = ref('')
|
|
104
|
+
|
|
105
|
+
function openDialog(row, mode) {
|
|
106
|
+
dialog.value = { row, mode }
|
|
107
|
+
comment.value = ''
|
|
108
|
+
commentError.value = ''
|
|
109
|
+
}
|
|
110
|
+
function closeDialog() { dialog.value = null }
|
|
111
|
+
|
|
112
|
+
async function submitDialog() {
|
|
113
|
+
const { row, mode } = dialog.value
|
|
114
|
+
// Menolak tanpa alasan tak berguna bagi pemohon — dia tak tahu apa yang harus diperbaiki.
|
|
115
|
+
if (mode === 'reject' && !comment.value.trim()) {
|
|
116
|
+
commentError.value = t('approvals.comment.requiredOnReject')
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const fn = mode === 'approve' ? approve : reject
|
|
120
|
+
const { ok, reason } = await fn(row.id, comment.value.trim())
|
|
121
|
+
closeDialog()
|
|
122
|
+
if (ok) {
|
|
123
|
+
pushToast({ title: t(`approvals.result.${mode === 'approve' ? 'approved' : 'rejected'}`), variant: 'success' })
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (reason === 'busy') return
|
|
127
|
+
// Yang diajukan sudah dibatalkan/dihapus di luar alur: backend menutup pengajuannya, dan pembatalan
|
|
128
|
+
// yang sama bisa menutup baris lain — daftar dimuat ulang, bukan hanya satu baris dibuang.
|
|
129
|
+
if (reason === 'subject_not_pending' || reason === 'subject_unavailable') {
|
|
130
|
+
pushToast({ title: t('approvals.result.subjectWithdrawn'), variant: 'info' })
|
|
131
|
+
refresh()
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
// Tiap sebab dapat kalimatnya sendiri — `step_advanced` & `already_resolved` BUKAN kesalahan
|
|
135
|
+
// user (approver lain lebih dulu / sudah selesai), jadi ditampilkan sbg info, bukan error merah.
|
|
136
|
+
const soft = reason === 'step_advanced' || reason === 'already_acted' || reason === 'already_resolved'
|
|
137
|
+
const key = `approvals.result.${reason}`
|
|
138
|
+
pushToast({
|
|
139
|
+
title: te(key) ? t(key) : t('approvals.result.failed'),
|
|
140
|
+
variant: soft ? 'info' : 'error',
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<template>
|
|
146
|
+
<div>
|
|
147
|
+
<AppDataTable
|
|
148
|
+
:columns="columns"
|
|
149
|
+
:rows="rows"
|
|
150
|
+
:loading="loading"
|
|
151
|
+
:searchable="false"
|
|
152
|
+
:loading-text="t('approvals.loading')"
|
|
153
|
+
>
|
|
154
|
+
<!-- Kolom subjek: ikon dari registry (fallback generik) + penanda DELEGASI.
|
|
155
|
+
Penanda ini wajib terlihat — menyetujui atas nama orang lain tanpa sadar itu mahal. -->
|
|
156
|
+
<template #cell-subject="{ row }">
|
|
157
|
+
<div class="flex items-start gap-2.5">
|
|
158
|
+
<component :is="entityIcon(row) || FileCheck2" class="w-4 h-4 mt-0.5 shrink-0 text-gray-400 dark:text-gray-500" />
|
|
159
|
+
<div class="min-w-0">
|
|
160
|
+
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ subjectOf(row) }}</p>
|
|
161
|
+
<AppTooltip v-if="isDelegated(row)" :text="t('approvals.viaDelegationHint')">
|
|
162
|
+
<span class="inline-flex items-center gap-1 mt-0.5 text-xs text-amber-700 dark:text-amber-300">
|
|
163
|
+
<UserCheck class="w-3 h-3 shrink-0" />
|
|
164
|
+
{{ t('approvals.viaDelegation', { name: delegatorName(row) || '—' }) }}
|
|
165
|
+
</span>
|
|
166
|
+
</AppTooltip>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</template>
|
|
170
|
+
|
|
171
|
+
<template #cell-type="{ row }">
|
|
172
|
+
<span class="text-sm text-gray-600 dark:text-gray-300">{{ typeLabel(row) }}</span>
|
|
173
|
+
</template>
|
|
174
|
+
|
|
175
|
+
<!-- ⚠️ `requested_by` NULL bukan data hilang — ia penanda "karyawan mengajukan SENDIRI"
|
|
176
|
+
lewat pintu employee, lawan dari "HR membuatkan atas nama".
|
|
177
|
+
Merendernya sebagai '—' membuat baris yang justru punya pemohon paling jelas terlihat
|
|
178
|
+
seperti data rusak. Namanya sendiri sudah tampil di kolom Pengajuan (`subjectOf` →
|
|
179
|
+
`entitySubject`), jadi mengulanginya di sini tak menambah informasi; yang berharga
|
|
180
|
+
adalah membedakan CARA pengajuannya. -->
|
|
181
|
+
<template #cell-requester="{ row }">
|
|
182
|
+
<span
|
|
183
|
+
class="text-sm"
|
|
184
|
+
:class="requesterName(row) ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500 italic'"
|
|
185
|
+
>
|
|
186
|
+
{{ requesterName(row) || t('approvals.selfSubmitted') }}
|
|
187
|
+
</span>
|
|
188
|
+
</template>
|
|
189
|
+
|
|
190
|
+
<!-- Langkah ke berapa dari berapa — `total_steps` dari backend, tidak dihitung frontend -->
|
|
191
|
+
<template #cell-step="{ row }">
|
|
192
|
+
<AppBadge color="gray">
|
|
193
|
+
{{ t('approvals.stepOf', { current: row.current_step ?? 1, total: row.total_steps ?? 1 }) }}
|
|
194
|
+
</AppBadge>
|
|
195
|
+
<p v-if="approverText(row)" class="mt-1 text-xs text-gray-500 dark:text-gray-400" data-current-approver>
|
|
196
|
+
{{ approverText(row) }}
|
|
197
|
+
</p>
|
|
198
|
+
</template>
|
|
199
|
+
|
|
200
|
+
<template #cell-submitted="{ row }">
|
|
201
|
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ formatDate(row.created_at) }}</span>
|
|
202
|
+
</template>
|
|
203
|
+
|
|
204
|
+
<!-- Tombol digerbang `can_act` dari backend — tak ditebak frontend. -->
|
|
205
|
+
<template #cell-actions="{ row }">
|
|
206
|
+
<div class="flex items-center justify-end gap-2">
|
|
207
|
+
<AppButton size="sm" variant="ghost" @click="openRow(row)">
|
|
208
|
+
<ExternalLink class="w-3.5 h-3.5" /> {{ t('approvals.action.open') }}
|
|
209
|
+
</AppButton>
|
|
210
|
+
<template v-if="canAct(row)">
|
|
211
|
+
<AppButton size="sm" variant="secondary" :disabled="acting === row.id" @click="openDialog(row, 'reject')">
|
|
212
|
+
<X class="w-3.5 h-3.5" /> {{ t('approvals.action.reject') }}
|
|
213
|
+
</AppButton>
|
|
214
|
+
<AppButton size="sm" :disabled="acting === row.id" @click="openDialog(row, 'approve')">
|
|
215
|
+
<Loader2 v-if="acting === row.id" class="w-3.5 h-3.5 animate-spin" />
|
|
216
|
+
<Check v-else class="w-3.5 h-3.5" /> {{ t('approvals.action.approve') }}
|
|
217
|
+
</AppButton>
|
|
218
|
+
</template>
|
|
219
|
+
</div>
|
|
220
|
+
</template>
|
|
221
|
+
|
|
222
|
+
<template #empty>
|
|
223
|
+
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
224
|
+
{{ loadError ? t('approvals.loadError') : t('approvals.empty') }}
|
|
225
|
+
</p>
|
|
226
|
+
</template>
|
|
227
|
+
</AppDataTable>
|
|
228
|
+
|
|
229
|
+
<div v-if="hasMore" class="flex justify-center mt-3">
|
|
230
|
+
<AppButton variant="secondary" size="sm" :disabled="loading" @click="fetchMore">
|
|
231
|
+
{{ t('approvals.loadMore') }}
|
|
232
|
+
</AppButton>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<AppApprovalSubjectModal
|
|
236
|
+
v-if="subjectRow"
|
|
237
|
+
:request-id="subjectRow.id"
|
|
238
|
+
:title="subjectOf(subjectRow)"
|
|
239
|
+
:app-target="targetOf(subjectRow)"
|
|
240
|
+
@close="subjectRow = null"
|
|
241
|
+
/>
|
|
242
|
+
|
|
243
|
+
<!-- Konfirmasi + catatan. Catatan WAJIB saat menolak (lihat submitDialog). -->
|
|
244
|
+
<AppModal
|
|
245
|
+
v-if="dialog"
|
|
246
|
+
:title="dialog.mode === 'approve' ? t('approvals.confirm.approveTitle') : t('approvals.confirm.rejectTitle')"
|
|
247
|
+
:subtitle="subjectOf(dialog.row)"
|
|
248
|
+
@close="closeDialog"
|
|
249
|
+
>
|
|
250
|
+
<AppTextarea
|
|
251
|
+
v-model="comment"
|
|
252
|
+
:label="t('approvals.comment.label')"
|
|
253
|
+
:placeholder="t('approvals.comment.placeholder')"
|
|
254
|
+
:error="commentError"
|
|
255
|
+
:rows="3"
|
|
256
|
+
/>
|
|
257
|
+
<template #footer>
|
|
258
|
+
<AppButton variant="secondary" @click="closeDialog">{{ t('common.cancel') }}</AppButton>
|
|
259
|
+
<AppButton :variant="dialog.mode === 'reject' ? 'danger' : 'primary'" :disabled="!!acting" @click="submitDialog">
|
|
260
|
+
{{ dialog.mode === 'approve' ? t('approvals.action.approve') : t('approvals.action.reject') }}
|
|
261
|
+
</AppButton>
|
|
262
|
+
</template>
|
|
263
|
+
</AppModal>
|
|
264
|
+
</div>
|
|
265
|
+
</template>
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, onMounted } from 'vue'
|
|
3
|
+
import { useI18n } from 'vue-i18n'
|
|
4
|
+
import { FileText, ExternalLink, FileDown } from '@lucide/vue'
|
|
5
|
+
import { approvalsApi } from '@initsolusidigital/worksuite-core/api/endpoints/approvals.js'
|
|
6
|
+
import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
|
|
7
|
+
import { useCrossAppLink } from '@initsolusidigital/worksuite-core/composables/useCrossAppLink.js'
|
|
8
|
+
import AppModal from '@initsolusidigital/worksuite-ui/AppModal.vue'
|
|
9
|
+
import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
|
|
10
|
+
import AppAlert from '@initsolusidigital/worksuite-ui/AppAlert.vue'
|
|
11
|
+
import AppSpinner from '@initsolusidigital/worksuite-ui/AppSpinner.vue'
|
|
12
|
+
|
|
13
|
+
// Panel "isi yang disetujui" dari `GET approval_requests/{id}/subject`.
|
|
14
|
+
// Peserta permintaan berhak MEMBACA entitasnya tanpa seat app pemiliknya (kontrak
|
|
15
|
+
// `approval-flows.md` §8), jadi panel ini tak pernah melompat ke layar app pemilik lebih dulu —
|
|
16
|
+
// layar itu bergerbang seat dan memantulkan penyetuju yang justru dilayani rute ini.
|
|
17
|
+
//
|
|
18
|
+
// ⚠️ Dokumen pun dibuka lewat panel ini, bukan langsung: `window.open` sesudah `await` dianggap
|
|
19
|
+
// popup liar oleh peramban dan diblokir tanpa pesan apa pun. Tombol "Buka dokumen" di panel adalah
|
|
20
|
+
// klik baru, jadi tab penampil selalu terbuka.
|
|
21
|
+
const props = defineProps({
|
|
22
|
+
requestId: { type: String, required: true },
|
|
23
|
+
title: { type: String, default: '' },
|
|
24
|
+
// Tujuan lama dari resolver registry app (layar app pemilik). Tetap ditawarkan sebagai tombol
|
|
25
|
+
// kedua untuk pemegang seat, karena di sana mereka bisa berbuat lebih dari sekadar membaca.
|
|
26
|
+
appTarget: { type: Object, default: null },
|
|
27
|
+
})
|
|
28
|
+
const emit = defineEmits(['close'])
|
|
29
|
+
|
|
30
|
+
const { t, te, locale } = useI18n()
|
|
31
|
+
const { openTarget } = useCrossAppLink()
|
|
32
|
+
|
|
33
|
+
const loading = ref(true)
|
|
34
|
+
const error = ref('')
|
|
35
|
+
const subject = ref(null)
|
|
36
|
+
const openingPdf = ref(false)
|
|
37
|
+
const pdfError = ref('')
|
|
38
|
+
|
|
39
|
+
onMounted(async () => {
|
|
40
|
+
try {
|
|
41
|
+
const { data } = await approvalsApi.subject(props.requestId)
|
|
42
|
+
subject.value = data.data ?? null
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const e = normalizeApiError(err)
|
|
45
|
+
error.value = e.reason === 'not_approval_participant'
|
|
46
|
+
? t('approvals.subject.notParticipant')
|
|
47
|
+
: e.status === 404 ? t('approvals.subject.notFound') : t('approvals.subject.loadError')
|
|
48
|
+
} finally {
|
|
49
|
+
loading.value = false
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// `summary` entitas non-dokumen adalah atribut model apa adanya, jadi field teknis (kunci asing,
|
|
54
|
+
// stempel waktu, id) disaring di sini; relasi tampil lewat namanya, bukan sebagai objek.
|
|
55
|
+
const HIDDEN = new Set(['id', 'company_id', 'updated_at', 'deleted_at', 'approvable_type', 'approvable_id', 'custom_fields'])
|
|
56
|
+
const NAME_KEYS = ['name', 'title', 'customer_company_name', 'document_number', 'full_name', 'code', 'number']
|
|
57
|
+
|
|
58
|
+
function isHidden(key) {
|
|
59
|
+
return HIDDEN.has(key) || key.endsWith('_id') || key.endsWith('_ids') || key.startsWith('_')
|
|
60
|
+
}
|
|
61
|
+
function nameOf(obj) {
|
|
62
|
+
for (const k of NAME_KEYS) if (typeof obj?.[k] === 'string' && obj[k]) return obj[k]
|
|
63
|
+
const person = [obj?.first_name, obj?.last_name].filter(Boolean).join(' ')
|
|
64
|
+
return person || null
|
|
65
|
+
}
|
|
66
|
+
function snake(key) {
|
|
67
|
+
return key.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase()
|
|
68
|
+
}
|
|
69
|
+
function label(key) {
|
|
70
|
+
const k = `approvals.subject.field.${snake(key)}`
|
|
71
|
+
if (te(k)) return t(k)
|
|
72
|
+
const words = snake(key).replace(/_/g, ' ')
|
|
73
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
74
|
+
}
|
|
75
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?/
|
|
76
|
+
function format(value) {
|
|
77
|
+
if (value === true) return t('approvals.subject.yes')
|
|
78
|
+
if (value === false) return t('approvals.subject.no')
|
|
79
|
+
if (typeof value === 'number') return value.toLocaleString(locale.value === 'id' ? 'id-ID' : 'en-US')
|
|
80
|
+
if (typeof value === 'string' && DATE_RE.test(value)) {
|
|
81
|
+
const d = new Date(value.length === 10 ? `${value}T00:00:00` : value)
|
|
82
|
+
if (!Number.isNaN(d.getTime())) {
|
|
83
|
+
return d.toLocaleString(locale.value === 'id' ? 'id-ID' : 'en-US',
|
|
84
|
+
value.length === 10 ? { dateStyle: 'medium' } : { dateStyle: 'medium', timeStyle: 'short' })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return String(value)
|
|
88
|
+
}
|
|
89
|
+
function isScalar(v) {
|
|
90
|
+
return v !== null && v !== '' && ['string', 'number', 'boolean'].includes(typeof v)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fields = computed(() => {
|
|
94
|
+
const s = subject.value?.summary ?? {}
|
|
95
|
+
const out = []
|
|
96
|
+
for (const [key, value] of Object.entries(s)) {
|
|
97
|
+
if (key === 'items' || isHidden(key)) continue
|
|
98
|
+
if (isScalar(value)) out.push({ key, label: label(key), value: format(value) })
|
|
99
|
+
else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
100
|
+
const name = nameOf(value)
|
|
101
|
+
if (name) out.push({ key, label: label(key), value: name })
|
|
102
|
+
} else if (Array.isArray(value) && value.length && value.every(isScalar)) {
|
|
103
|
+
out.push({ key, label: label(key), value: value.map(format).join(', ') })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const items = computed(() => {
|
|
110
|
+
const list = subject.value?.summary?.items
|
|
111
|
+
return Array.isArray(list) && list.length ? list : []
|
|
112
|
+
})
|
|
113
|
+
const itemColumns = computed(() => {
|
|
114
|
+
const first = items.value[0] ?? {}
|
|
115
|
+
return Object.keys(first).filter((k) => !isHidden(k) && k !== 'created_at' && isScalar(first[k])).slice(0, 6)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const isDocument = computed(() => subject.value?.kind === 'document')
|
|
119
|
+
|
|
120
|
+
function openDocument() {
|
|
121
|
+
if (!openTarget(subject.value?.open)) error.value = t('approvals.openUnavailable')
|
|
122
|
+
else emit('close')
|
|
123
|
+
}
|
|
124
|
+
function openInApp() {
|
|
125
|
+
if (!openTarget(props.appTarget)) error.value = t('approvals.openUnavailable')
|
|
126
|
+
else emit('close')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Tab dibuka SINKRON di dalam gestur klik lalu diarahkan sesudah PDF siap; dibuka sesudah `await`
|
|
130
|
+
// ia diblokir popup blocker tanpa jejak (pola sama Cetak Laporan `useDataImportExport`).
|
|
131
|
+
async function openPdf() {
|
|
132
|
+
openingPdf.value = true
|
|
133
|
+
pdfError.value = ''
|
|
134
|
+
const win = window.open('', '_blank')
|
|
135
|
+
try {
|
|
136
|
+
const res = await approvalsApi.subjectPdf(props.requestId)
|
|
137
|
+
const url = URL.createObjectURL(res.data)
|
|
138
|
+
if (win) win.location = url
|
|
139
|
+
else window.open(url, '_blank')
|
|
140
|
+
} catch {
|
|
141
|
+
win?.close()
|
|
142
|
+
pdfError.value = t('approvals.subject.pdfError')
|
|
143
|
+
} finally {
|
|
144
|
+
openingPdf.value = false
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
</script>
|
|
148
|
+
|
|
149
|
+
<template>
|
|
150
|
+
<AppModal :title="t('approvals.subject.title')" :subtitle="title" size="lg" @close="emit('close')">
|
|
151
|
+
<div v-if="loading" class="py-10 flex justify-center"><AppSpinner /></div>
|
|
152
|
+
<AppAlert v-else-if="error && !subject" variant="error" :message="error" />
|
|
153
|
+
<div v-else-if="subject" class="space-y-4" data-approval-subject>
|
|
154
|
+
<AppAlert v-if="error" variant="warning" :message="error" />
|
|
155
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('approvals.subject.readOnlyNote') }}</p>
|
|
156
|
+
|
|
157
|
+
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
|
|
158
|
+
<div v-for="f in fields" :key="f.key" class="min-w-0">
|
|
159
|
+
<dt class="text-xs text-gray-500 dark:text-gray-400">{{ f.label }}</dt>
|
|
160
|
+
<dd class="text-sm text-gray-900 dark:text-white break-words whitespace-pre-line">{{ f.value }}</dd>
|
|
161
|
+
</div>
|
|
162
|
+
</dl>
|
|
163
|
+
|
|
164
|
+
<div v-if="items.length" class="overflow-x-auto scrollbar-rounded rounded-lg border border-gray-200 dark:border-gray-700">
|
|
165
|
+
<table class="w-full text-sm">
|
|
166
|
+
<thead class="bg-gray-50 dark:bg-gray-800">
|
|
167
|
+
<tr class="text-left text-xs text-gray-500 dark:text-gray-400">
|
|
168
|
+
<th v-for="c in itemColumns" :key="c" class="py-2 px-3 font-medium whitespace-nowrap">{{ label(c) }}</th>
|
|
169
|
+
</tr>
|
|
170
|
+
</thead>
|
|
171
|
+
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
172
|
+
<tr v-for="(it, i) in items" :key="it.id ?? i">
|
|
173
|
+
<td v-for="c in itemColumns" :key="c" class="py-2 px-3 text-gray-700 dark:text-gray-300">
|
|
174
|
+
{{ isScalar(it[c]) ? format(it[c]) : '—' }}
|
|
175
|
+
</td>
|
|
176
|
+
</tr>
|
|
177
|
+
</tbody>
|
|
178
|
+
</table>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<p v-if="pdfError" class="text-xs text-red-600 dark:text-red-400">{{ pdfError }}</p>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<template #footer>
|
|
185
|
+
<AppButton variant="secondary" @click="emit('close')">{{ t('approvals.subject.close') }}</AppButton>
|
|
186
|
+
<AppButton v-if="subject && appTarget && !isDocument" variant="secondary" @click="openInApp">
|
|
187
|
+
<ExternalLink class="w-4 h-4" />{{ t('approvals.subject.openInApp') }}
|
|
188
|
+
</AppButton>
|
|
189
|
+
<AppButton v-if="subject?.pdf_available" variant="secondary" :loading="openingPdf" @click="openPdf">
|
|
190
|
+
<FileDown class="w-4 h-4" />{{ t('approvals.subject.openPdf') }}
|
|
191
|
+
</AppButton>
|
|
192
|
+
<AppButton v-if="isDocument" variant="primary" @click="openDocument">
|
|
193
|
+
<FileText class="w-4 h-4" />{{ t('approvals.subject.openDocument') }}
|
|
194
|
+
</AppButton>
|
|
195
|
+
</template>
|
|
196
|
+
</AppModal>
|
|
197
|
+
</template>
|