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