@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,422 @@
1
+ <script setup>
2
+ // Input generik custom field — SATU komponen merender 11 tipe berdasarkan definisi field.
3
+ // Props murni (field definition + modelValue) tanpa coupling API: upload file disuntik
4
+ // via prop function `uploadFn` supaya komponen tetap netral (kontrak Fase D).
5
+ //
6
+ // mode 'form' → input penuh sesuai tipe, tanda wajib dari settings.required
7
+ // mode 'filter' → varian ringkas utk panel filter (kontrak: exact/contains, satu nilai):
8
+ // text_long → input 1 baris; boolean → select Semua/Ya/Tidak; radio & checkbox →
9
+ // select SATU opsi; currency → input angka (exact amount); datetime → date (per hari).
10
+ // Tipe file tidak difilter — parent tidak merender field file di mode ini.
11
+ import { useId, ref, computed } from 'vue'
12
+ import { useI18n } from 'vue-i18n'
13
+ import { loadCurrencyOptions, currencySymbol } from '@initsolusidigital/worksuite-core/composables/useCurrencies.js'
14
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
15
+ import { Upload, X, FileText, Loader2 } from '@lucide/vue'
16
+ import AppInput from '@initsolusidigital/worksuite-ui/AppInput.vue'
17
+ import AppTextarea from '@initsolusidigital/worksuite-ui/AppTextarea.vue'
18
+ import AppSelect from '@initsolusidigital/worksuite-ui/AppSelect.vue'
19
+ import AppCurrencySelect from '@initsolusidigital/worksuite-core/components/ui/AppCurrencySelect.vue'
20
+ import AppMoneyInput from '@initsolusidigital/worksuite-ui/AppMoneyInput.vue'
21
+ import AppDatePicker from '@initsolusidigital/worksuite-core/components/ui/AppDatePicker.vue'
22
+ import AppDateTimePicker from '@initsolusidigital/worksuite-core/components/ui/AppDateTimePicker.vue'
23
+ import AppToggle from '@initsolusidigital/worksuite-ui/AppToggle.vue'
24
+ const grupLabelId = useId()
25
+ const grupErrorId = useId()
26
+ // Tiga cabang di bawah (boolean/radio/checkbox) merender KELOMPOK kontrol, bukan satu kotak — jadi
27
+ // namanya tak bisa lewat `<label for>` (satu `for` hanya menunjuk satu elemen) dan pesan errornya
28
+ // tak punya kontrol tunggal untuk ditautkan. Bentuk yang benar: pembungkusnya diberi `role="group"`
29
+ // / `"radiogroup"` + `aria-labelledby` ke labelnya + `aria-describedby` ke pesannya. Cabang lain
30
+ // meneruskan `:error` ke komponen UI yang sudah menanganinya sendiri — tak disentuh di sini.
31
+
32
+
33
+ const props = defineProps({
34
+ // Definisi field (row custom_fields): { code, label, type, settings, options }
35
+ field: { type: Object, required: true },
36
+ modelValue: { type: [String, Number, Boolean, Array, Object], default: null },
37
+ mode: { type: String, default: 'form' }, // 'form' | 'filter'
38
+ disabled: { type: Boolean, default: false },
39
+ error: { type: String, default: '' },
40
+ // Tipe file: async (File) => { id, name, url, download_url } — parent yang tahu endpoint upload
41
+ uploadFn: { type: Function, default: null },
42
+ })
43
+
44
+ const emit = defineEmits(['update:modelValue'])
45
+
46
+ const { t, locale } = useI18n()
47
+
48
+ const isFilter = computed(() => props.mode === 'filter')
49
+ const required = computed(() => !isFilter.value && !!props.field.settings?.required)
50
+
51
+ // Opsi radio/checkbox urut order dari definisi
52
+ const options = computed(() =>
53
+ [...(props.field.options ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
54
+ )
55
+ // Varian select utk mode filter — '' = semua (tidak difilter)
56
+ const filterOptionItems = computed(() => [
57
+ { value: '', label: t('common.customFields.filterAll') },
58
+ ...options.value.map(o => ({ value: o.id, label: o.label })),
59
+ ])
60
+ const booleanFilterItems = computed(() => [
61
+ { value: '', label: t('common.customFields.filterAll') },
62
+ { value: 'true', label: t('common.yes') },
63
+ { value: 'false', label: t('common.no') },
64
+ ])
65
+
66
+ function set(val) {
67
+ emit('update:modelValue', val)
68
+ }
69
+
70
+ // Checkbox group: toggle satu option_id di array (array unik — duplikat ditolak backend)
71
+ function toggleCheckbox(optionId) {
72
+ const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
73
+ const idx = current.indexOf(optionId)
74
+ if (idx === -1) current.push(optionId)
75
+ else current.splice(idx, 1)
76
+ set(current)
77
+ }
78
+
79
+ // Currency: object { amount, currency_id } — update sebagian
80
+ function setCurrency(patch) {
81
+ set({ amount: '', currency_id: props.field.settings?.default_currency_id ?? null, ...(props.modelValue ?? {}), ...patch })
82
+ }
83
+
84
+ // Simbol mata uang terpilih sbg prefix nominal (IDR→"Rp") — daftar currency dari cache
85
+ // bersama useCurrencies (request yang sama dgn AppCurrencySelect, tidak fetch dobel)
86
+ const currencyList = ref([])
87
+ if (props.field.type === 'currency') {
88
+ loadCurrencyOptions().then(list => { currencyList.value = list })
89
+ }
90
+ const currencyPrefix = computed(() => {
91
+ const id = props.modelValue?.currency_id
92
+ if (!id) return ''
93
+ const code = currencyList.value.find(o => o.value === id)?.code
94
+ return currencySymbol(code, locale.value === 'id' ? 'id-ID' : 'en-US')
95
+ })
96
+
97
+ // ── Upload file (langsung saat dipilih — keputusan produk) ──────────────────────────────
98
+ // Batas dibaca DINAMIS dari `/auth/me` (`upload_max_kb`), bukan konstanta.
99
+ // ⚠️ JANGAN kembalikan ke angka tertulis-tangan. Konstanta ber-komentar "sinkron limit backend"
100
+ // menjanjikan sinkron yang tak pernah ada: begitu backend menaikkan gerbangnya, klien menolak
101
+ // berkas yang server justru TERIMA — dan penolakannya terjadi di browser sehingga tak
102
+ // meninggalkan jejak apa pun di log backend.
103
+ const authStore = useAuthStore()
104
+ const maxFileBytes = computed(() => authStore.uploadMaxBytes)
105
+ const maxFileMb = computed(() => Math.floor(maxFileBytes.value / 1024 / 1024))
106
+ const fileInput = ref(null)
107
+ const uploading = ref(false)
108
+ const uploadError = ref('')
109
+
110
+ async function onFilePicked(e) {
111
+ const file = e.target.files?.[0]
112
+ e.target.value = '' // reset supaya file yang sama bisa dipilih ulang
113
+ if (!file || !props.uploadFn) return
114
+ uploadError.value = ''
115
+ if (file.size > maxFileBytes.value) {
116
+ uploadError.value = t('common.customFields.fileTooLarge', { size: maxFileMb.value })
117
+ return
118
+ }
119
+ uploading.value = true
120
+ try {
121
+ const uploaded = await props.uploadFn(file) // { id, name, url, download_url }
122
+ set(uploaded)
123
+ } catch (err) {
124
+ uploadError.value = err?.response?.data?.message || t('common.customFields.uploadError')
125
+ } finally {
126
+ uploading.value = false
127
+ }
128
+ }
129
+ </script>
130
+
131
+ <template>
132
+ <!-- ── text_short ─────────────────────────────────────────────────────────────── -->
133
+ <AppInput
134
+ v-if="field.type === 'text_short'"
135
+ :model-value="modelValue ?? ''"
136
+ :label="field.label"
137
+ :required="required"
138
+ :disabled="disabled"
139
+ :error="error"
140
+ maxlength="255"
141
+ @update:model-value="set"
142
+ />
143
+
144
+ <!-- ── text_long — filter mode cukup input 1 baris (LIKE contains) ───────────── -->
145
+ <AppInput
146
+ v-else-if="field.type === 'text_long' && isFilter"
147
+ :model-value="modelValue ?? ''"
148
+ :label="field.label"
149
+ :disabled="disabled"
150
+ @update:model-value="set"
151
+ />
152
+ <AppTextarea
153
+ v-else-if="field.type === 'text_long'"
154
+ :model-value="modelValue ?? ''"
155
+ :label="field.label"
156
+ :required="required"
157
+ :disabled="disabled"
158
+ :error="error"
159
+ :rows="3"
160
+ @update:model-value="set"
161
+ />
162
+
163
+ <!-- ── number (max 4 desimal — divalidasi backend) ───────────────────────────── -->
164
+ <AppInput
165
+ v-else-if="field.type === 'number'"
166
+ :model-value="modelValue ?? ''"
167
+ :label="field.label"
168
+ type="number"
169
+ :required="required"
170
+ :disabled="disabled"
171
+ :error="error"
172
+ @update:model-value="set"
173
+ />
174
+
175
+ <!-- ── url (backend hanya terima http/https) ─────────────────────────────────── -->
176
+ <AppInput
177
+ v-else-if="field.type === 'url'"
178
+ :model-value="modelValue ?? ''"
179
+ :label="field.label"
180
+ type="url"
181
+ placeholder="https://"
182
+ :required="required"
183
+ :disabled="disabled"
184
+ :error="error"
185
+ @update:model-value="set"
186
+ />
187
+
188
+ <!-- ── date ───────────────────────────────────────────────────────────────────── -->
189
+ <AppDatePicker
190
+ v-else-if="field.type === 'date'"
191
+ :model-value="modelValue || null"
192
+ :label="field.label"
193
+ :required="required"
194
+ :disabled="disabled"
195
+ :error="error"
196
+ @update:model-value="set($event ?? '')"
197
+ />
198
+
199
+ <!-- ── datetime — filter mode pakai date saja (match per hari, kontrak backend) ── -->
200
+ <AppDatePicker
201
+ v-else-if="field.type === 'datetime' && isFilter"
202
+ :model-value="modelValue || null"
203
+ :label="field.label"
204
+ :disabled="disabled"
205
+ @update:model-value="set($event ?? '')"
206
+ />
207
+ <AppDateTimePicker
208
+ v-else-if="field.type === 'datetime'"
209
+ :model-value="modelValue ?? ''"
210
+ :label="field.label"
211
+ :required="required"
212
+ :disabled="disabled"
213
+ :error="error"
214
+ @update:model-value="set"
215
+ />
216
+
217
+ <!-- ── boolean — form: toggle; filter: select 3 state ────────────────────────── -->
218
+ <AppSelect
219
+ v-else-if="field.type === 'boolean' && isFilter"
220
+ :model-value="modelValue ?? ''"
221
+ :label="field.label"
222
+ :options="booleanFilterItems"
223
+ option-value="value"
224
+ option-label="label"
225
+ :disabled="disabled"
226
+ @update:model-value="set"
227
+ />
228
+ <div
229
+ v-else-if="field.type === 'boolean'"
230
+ role="group"
231
+ :aria-labelledby="grupLabelId"
232
+ :aria-describedby="error ? grupErrorId : undefined"
233
+ :aria-invalid="error ? 'true' : undefined"
234
+ >
235
+ <label :id="grupLabelId" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
236
+ {{ field.label }} <span v-if="required" class="text-red-500">*</span>
237
+ </label>
238
+ <AppToggle
239
+ :model-value="!!modelValue"
240
+ :disabled="disabled"
241
+ color="green"
242
+ :label="modelValue ? t('common.yes') : t('common.no')"
243
+ @toggle="set(!modelValue)"
244
+ />
245
+ <p v-if="error" :id="grupErrorId" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
246
+ </div>
247
+
248
+ <!-- ── radio — form: radio group; filter: select satu opsi ───────────────────── -->
249
+ <AppSelect
250
+ v-else-if="field.type === 'radio' && isFilter"
251
+ :model-value="modelValue ?? ''"
252
+ :label="field.label"
253
+ :options="filterOptionItems"
254
+ option-value="value"
255
+ option-label="label"
256
+ :disabled="disabled"
257
+ @update:model-value="set"
258
+ />
259
+ <div
260
+ v-else-if="field.type === 'radio'"
261
+ role="radiogroup"
262
+ :aria-labelledby="grupLabelId"
263
+ :aria-describedby="error ? grupErrorId : undefined"
264
+ :aria-invalid="error ? 'true' : undefined"
265
+ >
266
+ <div class="flex items-center justify-between mb-1.5">
267
+ <label :id="grupLabelId" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
268
+ {{ field.label }} <span v-if="required" class="text-red-500">*</span>
269
+ </label>
270
+ <!-- Radio tidak bisa di-unset native — sediakan jalur hapus nilai utk field opsional -->
271
+ <button
272
+ v-if="modelValue && !required && !disabled"
273
+ type="button"
274
+ class="text-xs text-gray-400 hover:text-red-500 transition-colors"
275
+ @click="set('')"
276
+ >{{ t('common.customFields.clearChoice') }}</button>
277
+ </div>
278
+ <div class="space-y-1.5">
279
+ <label
280
+ v-for="opt in options"
281
+ :key="opt.id"
282
+ class="flex items-center gap-2.5"
283
+ :class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
284
+ >
285
+ <input
286
+ type="radio"
287
+ :name="`cf-${field.id}`"
288
+ :checked="modelValue === opt.id"
289
+ :disabled="disabled"
290
+ class="w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
291
+ @change="set(opt.id)"
292
+ />
293
+ <span class="text-sm text-gray-700 dark:text-gray-300">{{ opt.label }}</span>
294
+ </label>
295
+ </div>
296
+ <p v-if="error" :id="grupErrorId" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
297
+ </div>
298
+
299
+ <!-- ── checkbox — form: checkbox group (array option_id); filter: select satu ── -->
300
+ <AppSelect
301
+ v-else-if="field.type === 'checkbox' && isFilter"
302
+ :model-value="modelValue ?? ''"
303
+ :label="field.label"
304
+ :options="filterOptionItems"
305
+ option-value="value"
306
+ option-label="label"
307
+ :disabled="disabled"
308
+ @update:model-value="set"
309
+ />
310
+ <div
311
+ v-else-if="field.type === 'checkbox'"
312
+ role="group"
313
+ :aria-labelledby="grupLabelId"
314
+ :aria-describedby="error ? grupErrorId : undefined"
315
+ :aria-invalid="error ? 'true' : undefined"
316
+ >
317
+ <label :id="grupLabelId" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
318
+ {{ field.label }} <span v-if="required" class="text-red-500">*</span>
319
+ </label>
320
+ <div class="space-y-1.5">
321
+ <label
322
+ v-for="opt in options"
323
+ :key="opt.id"
324
+ class="flex items-center gap-2.5"
325
+ :class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
326
+ >
327
+ <input
328
+ type="checkbox"
329
+ :checked="Array.isArray(modelValue) && modelValue.includes(opt.id)"
330
+ :disabled="disabled"
331
+ class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
332
+ @change="toggleCheckbox(opt.id)"
333
+ />
334
+ <span class="text-sm text-gray-700 dark:text-gray-300">{{ opt.label }}</span>
335
+ </label>
336
+ </div>
337
+ <p v-if="error" :id="grupErrorId" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
338
+ </div>
339
+
340
+ <!-- ── currency — form: amount + dropdown mata uang; filter: nominal exact ───── -->
341
+ <AppMoneyInput
342
+ v-else-if="field.type === 'currency' && isFilter"
343
+ :model-value="modelValue ?? ''"
344
+ :label="field.label"
345
+ :disabled="disabled"
346
+ @update:model-value="set"
347
+ />
348
+ <div v-else-if="field.type === 'currency'">
349
+ <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
350
+ {{ field.label }} <span v-if="required" class="text-red-500">*</span>
351
+ </label>
352
+ <!-- Dropdown mata uang DULU (kiri, fleksibel — nama currency panjang), baru field
353
+ nominal (kanan, 11rem cukup sampai belasan digit). Mobile: stack, dropdown di atas. -->
354
+ <div class="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_11rem] gap-2">
355
+ <!-- Dropdown mata uang reusable — bendera + Nama (KODE), fetch & cache sendiri -->
356
+ <AppCurrencySelect
357
+ :model-value="modelValue?.currency_id ?? null"
358
+ :disabled="disabled"
359
+ @update:model-value="setCurrency({ currency_id: $event })"
360
+ />
361
+ <!-- Nominal terformat uang live + prefix simbol mata uang terpilih (Rp 1.000.000,5) -->
362
+ <AppMoneyInput
363
+ :model-value="modelValue?.amount ?? ''"
364
+ :prefix="currencyPrefix"
365
+ :disabled="disabled"
366
+ :error="error"
367
+ @update:model-value="setCurrency({ amount: $event })"
368
+ />
369
+ </div>
370
+ </div>
371
+
372
+ <!-- ── file — upload langsung saat dipilih (via uploadFn), tampil nama + ganti/hapus ── -->
373
+ <div v-else-if="field.type === 'file'">
374
+ <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
375
+ {{ field.label }} <span v-if="required" class="text-red-500">*</span>
376
+ </label>
377
+ <input ref="fileInput" type="file" class="hidden" @change="onFilePicked" />
378
+ <div class="flex items-center gap-2 flex-wrap">
379
+ <!-- File terpasang: nama jadi link + tombol hapus. Tautannya `download_url` (rute ber-sesi;
380
+ ada di nilai tersimpan MAUPUN respons unggah), bukan `url` `/storage` yang 404 di produksi. -->
381
+ <template v-if="modelValue">
382
+ <a
383
+ v-if="modelValue.download_url"
384
+ :href="modelValue.download_url"
385
+ target="_blank"
386
+ rel="noopener"
387
+ class="inline-flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline min-w-0"
388
+ >
389
+ <FileText class="w-4 h-4 shrink-0" />
390
+ <span class="truncate max-w-[220px]">{{ modelValue.name }}</span>
391
+ </a>
392
+ <span v-else class="inline-flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 min-w-0">
393
+ <FileText class="w-4 h-4 shrink-0" />
394
+ <span class="truncate max-w-[220px]">{{ modelValue.name }}</span>
395
+ </span>
396
+ </template>
397
+ <button
398
+ type="button"
399
+ :disabled="disabled || uploading"
400
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
401
+ @click="fileInput.click()"
402
+ >
403
+ <Loader2 v-if="uploading" class="w-3.5 h-3.5 animate-spin" />
404
+ <Upload v-else class="w-3.5 h-3.5" />
405
+ {{ uploading
406
+ ? t('common.customFields.uploading')
407
+ : (modelValue ? t('common.customFields.changeFile') : t('common.customFields.uploadFile')) }}
408
+ </button>
409
+ <button
410
+ v-if="modelValue && !disabled"
411
+ type="button"
412
+ class="inline-flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-lg text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
413
+ @click="set(null)"
414
+ >
415
+ <X class="w-3.5 h-3.5" />
416
+ {{ t('common.delete') }}
417
+ </button>
418
+ </div>
419
+ <p v-if="uploadError || error" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ uploadError || error }}</p>
420
+ <p v-else class="mt-1 text-xs text-gray-400">{{ t('common.customFields.fileHint') }}</p>
421
+ </div>
422
+ </template>
@@ -0,0 +1,129 @@
1
+ <script setup>
2
+ // Tampilan nilai custom field per tipe (read-only) — dipakai modal detail & sel tabel.
3
+ // props.value = bentuk RESPONSE backend (number/currency amount = string desimal,
4
+ // radio/checkbox = option_id, file = { id, name, url } | null).
5
+ //
6
+ // Keamanan: nilai url hanya dirender sbg <a> bila berskema http/https (defense-in-depth
7
+ // di atas validasi backend — data lama/penyimpangan tetap aman); semua teks via
8
+ // interpolation Vue (auto-escape), tidak pernah v-html.
9
+ import { computed } from 'vue'
10
+ import { useI18n } from 'vue-i18n'
11
+ import { FileText, ExternalLink } from '@lucide/vue'
12
+ import AppBadge from '@initsolusidigital/worksuite-ui/AppBadge.vue'
13
+
14
+ const props = defineProps({
15
+ field: { type: Object, required: true },
16
+ value: { type: [String, Number, Boolean, Array, Object], default: null },
17
+ // compact: varian sel tabel — chips jadi teks gabung, truncate lebar (keputusan #6)
18
+ compact: { type: Boolean, default: false },
19
+ })
20
+
21
+ const { t, locale } = useI18n()
22
+
23
+ const isEmpty = computed(() =>
24
+ props.value === null || props.value === undefined || props.value === '' ||
25
+ (Array.isArray(props.value) && props.value.length === 0)
26
+ )
27
+
28
+ // Resolusi label opsi radio/checkbox dari definisi (nilai tersimpan = option_id)
29
+ const optionLabel = (id) =>
30
+ (props.field.options ?? []).find(o => o.id === id)?.label ?? '—'
31
+
32
+ const checkboxLabels = computed(() =>
33
+ Array.isArray(props.value) ? props.value.map(optionLabel) : []
34
+ )
35
+
36
+ const localeTag = computed(() => (locale.value === 'id' ? 'id-ID' : 'en-US'))
37
+
38
+ const displayText = computed(() => {
39
+ const v = props.value
40
+ switch (props.field.type) {
41
+ case 'number':
42
+ return Number(parseFloat(v)).toLocaleString(localeTag.value, { maximumFractionDigits: 4 })
43
+ case 'date':
44
+ return new Date(`${v}T00:00:00`).toLocaleDateString(localeTag.value, { dateStyle: 'medium' })
45
+ case 'datetime':
46
+ return new Date(v).toLocaleString(localeTag.value, { dateStyle: 'medium', timeStyle: 'short' })
47
+ case 'radio':
48
+ return optionLabel(v)
49
+ case 'currency': {
50
+ // decimal_digits dari relasi currency response; fallback format angka polos + code
51
+ const digits = v.currency?.decimal_digits ?? 2
52
+ const amount = Number(parseFloat(v.amount))
53
+ const code = v.currency?.code
54
+ if (code) {
55
+ try {
56
+ return new Intl.NumberFormat(localeTag.value, {
57
+ style: 'currency', currency: code,
58
+ minimumFractionDigits: digits, maximumFractionDigits: digits,
59
+ }).format(amount)
60
+ } catch {
61
+ // code non-ISO4217 (currency kustom tenant) — jangan crash, format manual
62
+ }
63
+ }
64
+ return `${code ?? ''} ${amount.toLocaleString(localeTag.value, { minimumFractionDigits: digits, maximumFractionDigits: digits })}`.trim()
65
+ }
66
+ default:
67
+ return String(v)
68
+ }
69
+ })
70
+
71
+ // url: hanya http/https yang boleh jadi link (selain itu tampil teks polos)
72
+ const safeUrl = computed(() => {
73
+ if (props.field.type !== 'url' || typeof props.value !== 'string') return null
74
+ return /^https?:\/\//i.test(props.value.trim()) ? props.value.trim() : null
75
+ })
76
+ </script>
77
+
78
+ <template>
79
+ <span v-if="isEmpty" class="text-gray-400 dark:text-gray-500 text-sm">—</span>
80
+
81
+ <!-- boolean → badge Ya/Tidak -->
82
+ <AppBadge v-else-if="field.type === 'boolean'" :color="value ? 'green' : 'gray'">
83
+ {{ value ? t('common.yes') : t('common.no') }}
84
+ </AppBadge>
85
+
86
+ <!-- url → link aman (http/https saja) -->
87
+ <a
88
+ v-else-if="field.type === 'url' && safeUrl"
89
+ :href="safeUrl"
90
+ target="_blank"
91
+ rel="noopener"
92
+ class="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline min-w-0"
93
+ >
94
+ <span class="truncate" :class="compact ? 'max-w-[200px]' : 'max-w-full'">{{ value }}</span>
95
+ <ExternalLink class="w-3 h-3 shrink-0" />
96
+ </a>
97
+
98
+ <!-- file → link nama file. `download_url` = rute unduh ber-sesi bergerbang modul entity-nya;
99
+ `url` menunjuk `/storage`, yang ditolak proxy produksi (tautannya 404). -->
100
+ <a
101
+ v-else-if="field.type === 'file' && value.download_url"
102
+ :href="value.download_url"
103
+ target="_blank"
104
+ rel="noopener"
105
+ class="inline-flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline min-w-0"
106
+ >
107
+ <FileText class="w-3.5 h-3.5 shrink-0" />
108
+ <span class="truncate" :class="compact ? 'max-w-[200px]' : 'max-w-full'">{{ value.name }}</span>
109
+ </a>
110
+ <span v-else-if="field.type === 'file'" class="text-sm text-gray-700 dark:text-gray-300">{{ value.name }}</span>
111
+
112
+ <!-- checkbox → chips label (compact: gabung teks + truncate) -->
113
+ <span
114
+ v-else-if="field.type === 'checkbox' && compact"
115
+ class="text-sm text-gray-700 dark:text-gray-300 truncate max-w-[200px] inline-block align-bottom"
116
+ :title="checkboxLabels.join(', ')"
117
+ >{{ checkboxLabels.join(', ') }}</span>
118
+ <span v-else-if="field.type === 'checkbox'" class="inline-flex flex-wrap gap-1.5">
119
+ <AppBadge v-for="(label, i) in checkboxLabels" :key="i" color="blue">{{ label }}</AppBadge>
120
+ </span>
121
+
122
+ <!-- tipe lain → teks terformat (compact: truncate + title) -->
123
+ <span
124
+ v-else
125
+ class="text-sm text-gray-700 dark:text-gray-300"
126
+ :class="compact ? 'truncate max-w-[200px] inline-block align-bottom' : ''"
127
+ :title="compact ? displayText : undefined"
128
+ >{{ displayText }}</span>
129
+ </template>
@@ -0,0 +1,65 @@
1
+ <script setup>
2
+ // Section "Informasi Tambahan" utk form Create/Edit entity — render semua custom field
3
+ // aktif urut `order` via CustomFieldInput, inline tanpa toggle pembuka (keputusan produk).
4
+ // Komponen glue: menyuntik uploadFn (endpoint upload custom fields); dropdown currency
5
+ // ditangani AppCurrencySelect (self-fetch + cache) di dalam CustomFieldInput.
6
+ // Kalau tidak ada field aktif → tidak merender apa pun (section hilang total).
7
+ import { useI18n } from 'vue-i18n'
8
+ import { customFieldsApi } from '@initsolusidigital/worksuite-core/api/endpoints/customFields.js'
9
+ import CustomFieldInput from './CustomFieldInput.vue'
10
+
11
+ const props = defineProps({
12
+ companyId: { type: String, required: true },
13
+ entityType: { type: String, required: true }, // 'users' | 'employees'
14
+ // Definisi aktif urut order (dimuat parent via useCustomFields — parent juga butuh
15
+ // definisi utk buildPayload/validate, jadi fetch-nya tidak di sini biar tidak dobel)
16
+ definitions: { type: Array, default: () => [] },
17
+ // Object nilai form { code: value } — v-model
18
+ modelValue: { type: Object, default: () => ({}) },
19
+ // Error per field { code: pesan } — gabungan validasi client + 422 backend
20
+ errors: { type: Object, default: () => ({}) },
21
+ disabled: { type: Boolean, default: false },
22
+ })
23
+
24
+ const emit = defineEmits(['update:modelValue'])
25
+
26
+ const { t } = useI18n()
27
+
28
+ function setField(code, val) {
29
+ emit('update:modelValue', { ...props.modelValue, [code]: val })
30
+ }
31
+
32
+ // Upload file custom field — langsung saat dipilih; response.id jadi nilai field
33
+ async function uploadFile(file) {
34
+ const fd = new FormData()
35
+ fd.append('entity_type', props.entityType)
36
+ fd.append('file', file)
37
+ const { data } = await customFieldsApi.upload(props.companyId, fd)
38
+ return data.data // { id, name, url, download_url }
39
+ }
40
+
41
+ </script>
42
+
43
+ <template>
44
+ <div v-if="definitions.length">
45
+ <!-- Header section — pola sama dgn section lain di form modal -->
46
+ <div class="flex items-center gap-2 mb-4">
47
+ <div class="w-1 h-3.5 rounded-full bg-blue-500 shrink-0"></div>
48
+ <h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
49
+ {{ t('common.customFields.sectionTitle') }}
50
+ </h4>
51
+ </div>
52
+ <div class="space-y-4">
53
+ <CustomFieldInput
54
+ v-for="field in definitions"
55
+ :key="field.id"
56
+ :field="field"
57
+ :model-value="modelValue[field.code]"
58
+ :disabled="disabled"
59
+ :error="errors[field.code] === true ? t('common.customFields.requiredError') : (errors[field.code] || '')"
60
+ :upload-fn="uploadFile"
61
+ @update:model-value="setField(field.code, $event)"
62
+ />
63
+ </div>
64
+ </div>
65
+ </template>
@@ -0,0 +1,23 @@
1
+ import {
2
+ Type, AlignLeft, Hash, Coins, ToggleLeft, Calendar, Clock, Link2,
3
+ CircleDot, ListChecks, Paperclip,
4
+ } from '@lucide/vue'
5
+
6
+ // Metadata 11 tipe custom field — icon utk type picker builder & tabel definisi.
7
+ // Nama & deskripsi via i18n: common.customFields.types.{type} / typeDesc.{type}.
8
+ // `hasOptions` = wajib punya opsi (radio/checkbox); `type` immutable setelah create.
9
+ export const FIELD_TYPES = [
10
+ { type: 'text_short', icon: Type },
11
+ { type: 'text_long', icon: AlignLeft },
12
+ { type: 'number', icon: Hash },
13
+ { type: 'currency', icon: Coins },
14
+ { type: 'boolean', icon: ToggleLeft },
15
+ { type: 'date', icon: Calendar },
16
+ { type: 'datetime', icon: Clock },
17
+ { type: 'url', icon: Link2 },
18
+ { type: 'radio', icon: CircleDot, hasOptions: true },
19
+ { type: 'checkbox', icon: ListChecks, hasOptions: true },
20
+ { type: 'file', icon: Paperclip },
21
+ ]
22
+
23
+ export const FIELD_TYPE_MAP = Object.fromEntries(FIELD_TYPES.map(ft => [ft.type, ft]))