@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,724 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, watch, useSlots } from 'vue'
|
|
3
|
+
import { useI18n } from 'vue-i18n'
|
|
4
|
+
import { useDataImportExport } from '@initsolusidigital/worksuite-core/composables/useDataImportExport.js'
|
|
5
|
+
import { useToast } from '@initsolusidigital/worksuite-core/composables/useToast.js'
|
|
6
|
+
import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
|
|
7
|
+
import AppModal from '@initsolusidigital/worksuite-ui/AppModal.vue'
|
|
8
|
+
import AppAlert from '@initsolusidigital/worksuite-ui/AppAlert.vue'
|
|
9
|
+
import AppCombobox from '@initsolusidigital/worksuite-ui/AppCombobox.vue'
|
|
10
|
+
import AppToggle from '@initsolusidigital/worksuite-ui/AppToggle.vue'
|
|
11
|
+
import AppImportSchemaGuide from './AppImportSchemaGuide.vue'
|
|
12
|
+
import { Upload, Download, FileDown, Printer, CheckCircle2, XCircle, Loader2, ChevronDown, ChevronRight, Info } from '@lucide/vue'
|
|
13
|
+
|
|
14
|
+
// Komponen generik Import/Export lintas-modul. App tinggal memasangnya di toolbar
|
|
15
|
+
// halaman list dgn `module` + gate `can_create`/`can_export`. Tombol Impor + Ekspor (dropdown
|
|
16
|
+
// CSV/XLSX) + Cetak Laporan. Kontrak: track-dp2-pc2-hx.md §3 (mesin lama) dan
|
|
17
|
+
// data-import-export.md (mesin wizard).
|
|
18
|
+
//
|
|
19
|
+
// Dua mesin impor, dipilih lewat prop `engine`:
|
|
20
|
+
// legacy — berkas harus mengikuti template; dry-run hanya memeriksa valid/tidak. Baris TIDAK
|
|
21
|
+
// dicocokkan ke data yang sudah ada, jadi mengimpor ulang berkas hasil ekspor
|
|
22
|
+
// menggandakan seluruh isinya tanpa satu peringatan pun.
|
|
23
|
+
// wizard — peta kolom mengikuti berkas, baris dicocokkan lewat kunci (bawaan `ref` = ID asli dari
|
|
24
|
+
// ekspor), dan dry-run menampilkan RENCANA (baru/diperbarui/dilewati/konflik) sebelum
|
|
25
|
+
// apa pun ditulis.
|
|
26
|
+
// Bawaan tetap `legacy` supaya layar yang sudah memakai komponen ini tak berubah perilaku sampai
|
|
27
|
+
// dipindahkan satu per satu.
|
|
28
|
+
const props = defineProps({
|
|
29
|
+
module: { type: String, required: true },
|
|
30
|
+
companyId: { type: [String, Number], required: true },
|
|
31
|
+
// Filter/search aktif tabel — dikirim apa adanya ke export (backend hormati filter yang sama)
|
|
32
|
+
exportParams: { type: Object, default: () => ({}) },
|
|
33
|
+
canImport: { type: Boolean, default: false },
|
|
34
|
+
canExport: { type: Boolean, default: false },
|
|
35
|
+
engine: {
|
|
36
|
+
type: String,
|
|
37
|
+
default: 'legacy',
|
|
38
|
+
validator: (v) => ['legacy', 'wizard'].includes(v),
|
|
39
|
+
},
|
|
40
|
+
// Tombol Cetak Laporan. Dimatikan di layar yang tujuannya memindah data, bukan membaca laporan.
|
|
41
|
+
report: { type: Boolean, default: true },
|
|
42
|
+
})
|
|
43
|
+
const emit = defineEmits(['imported'])
|
|
44
|
+
|
|
45
|
+
const { t, locale, getLocaleMessage } = useI18n()
|
|
46
|
+
const slots = useSlots()
|
|
47
|
+
const { pushToast } = useToast()
|
|
48
|
+
const io = useDataImportExport(props.companyId, props.module)
|
|
49
|
+
const isWizard = computed(() => props.engine === 'wizard')
|
|
50
|
+
// Mesin lama melaporkan baris ber-ref yang sudah ada sebagai `skipped_existing` (di akar respons
|
|
51
|
+
// dry-run, juga di `session.plan`).
|
|
52
|
+
const legacySkipped = computed(() => Number(io.dryRunResult.value?.skipped_existing ?? io.session.value?.plan?.skipped_existing ?? 0))
|
|
53
|
+
|
|
54
|
+
// ── Import modal ──────────────────────────────────────────────────────────────
|
|
55
|
+
const showImport = ref(false)
|
|
56
|
+
const fileInput = ref(null)
|
|
57
|
+
|
|
58
|
+
function openImport() {
|
|
59
|
+
if (isWizard.value) {
|
|
60
|
+
io.wizardReset()
|
|
61
|
+
showImport.value = true
|
|
62
|
+
// Wizard selalu butuh skema (mode, kunci, daftar field target) — bukan hanya untuk panduan.
|
|
63
|
+
io.loadSchema().then(() => { if (!io.wKeyColumn.value) io.wKeyColumn.value = io.defaultKeyColumn() })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
io.reset()
|
|
67
|
+
showImport.value = true
|
|
68
|
+
// Skema hanya diambil bila halaman tak memasok panduannya sendiri lewat slot `guide`.
|
|
69
|
+
if (!slots.guide) io.loadSchema()
|
|
70
|
+
}
|
|
71
|
+
function closeImport() {
|
|
72
|
+
showImport.value = false
|
|
73
|
+
if (isWizard.value) {
|
|
74
|
+
if (io.wStep.value !== 'processing') io.wizardReset()
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
// biarkan polling lanjut bila sedang processing (emit 'imported' tetap terpicu saat selesai)
|
|
78
|
+
if (io.step.value !== 'processing') io.reset()
|
|
79
|
+
}
|
|
80
|
+
function onFileChange(e) {
|
|
81
|
+
const f = e.target.files?.[0]
|
|
82
|
+
if (f) io.setFile(f)
|
|
83
|
+
}
|
|
84
|
+
function doCommit() {
|
|
85
|
+
io.commit((s) => emit('imported', s))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const importErrText = (code) => ({
|
|
89
|
+
template: t('dataTools.import.errTemplate'),
|
|
90
|
+
dryrun: t('dataTools.import.errDryRun'),
|
|
91
|
+
commit: t('dataTools.import.errCommit'),
|
|
92
|
+
errors: t('dataTools.import.errErrors'),
|
|
93
|
+
}[code] || code)
|
|
94
|
+
|
|
95
|
+
// ── Export dropdown ───────────────────────────────────────────────────────────
|
|
96
|
+
const exportMenu = ref(false)
|
|
97
|
+
function doExport(format) {
|
|
98
|
+
exportMenu.value = false
|
|
99
|
+
io.exportData(format, props.exportParams)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Cetak laporan ─────────────────────────────────────────────────────────────
|
|
103
|
+
// Filter yang sama dengan export: laporan yang mencetak SELURUH data sementara layarnya
|
|
104
|
+
// menampilkan hasil terfilter membuat user menyimpulkan filternya tidak bekerja.
|
|
105
|
+
function doPrint() {
|
|
106
|
+
io.printReport(props.exportParams)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Wizard ────────────────────────────────────────────────────────────────────
|
|
110
|
+
const WIZARD_STEPS = ['upload', 'configure', 'plan', 'result']
|
|
111
|
+
const wizardStepIndex = computed(() => {
|
|
112
|
+
const s = io.wStep.value
|
|
113
|
+
return WIZARD_STEPS.indexOf(['processing', 'done', 'failed'].includes(s) ? 'result' : s)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
function onWizardFileChange(e) {
|
|
117
|
+
const f = e.target.files?.[0]
|
|
118
|
+
if (f) io.wizardSetFile(f)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const schemaFields = computed(() => io.schema.value?.fields ?? [])
|
|
122
|
+
const headers = computed(() => io.wInspect.value?.source_headers?.headers ?? [])
|
|
123
|
+
const firstSample = computed(() => io.wInspect.value?.source_headers?.samples?.[0] ?? {})
|
|
124
|
+
const customFieldKeys = computed(() => new Set((io.schema.value?.custom_fields ?? []).map((f) => f.key)))
|
|
125
|
+
|
|
126
|
+
// Opsi "abaikan" memakai nilai penanda, bukan string kosong: pustaka combobox memperlakukan ''
|
|
127
|
+
// sebagai "belum memilih" dan menampilkan placeholder, sehingga kolom yang sengaja diabaikan
|
|
128
|
+
// terlihat seperti kolom yang lupa diisi.
|
|
129
|
+
const IGNORE = '__ignore__'
|
|
130
|
+
const targetOptions = computed(() => [
|
|
131
|
+
{ value: IGNORE, label: t('dataTools.wizard.ignoreColumn') },
|
|
132
|
+
...schemaFields.value.map((f) => ({ value: f.key, label: f.key })),
|
|
133
|
+
])
|
|
134
|
+
function targetOf(h) {
|
|
135
|
+
return io.wMapping.value[h] || IGNORE
|
|
136
|
+
}
|
|
137
|
+
function setTarget(h, v) {
|
|
138
|
+
io.wMapping.value[h] = !v || v === IGNORE ? '' : v
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Kolom bidang kustom diabaikan mesin wizard (skema sengaja memisahkannya dari `fields`), jadi
|
|
142
|
+
// tidak dihitung "belum dikenali" — kalau dihitung, setiap berkas ekspor dari tenant ber-bidang
|
|
143
|
+
// kustom akan selalu membuka tabel pemetaan padahal tak ada yang bisa dipetakan di sana.
|
|
144
|
+
const unmappedHeaders = computed(() => headers.value.filter((h) => !io.wMapping.value[h] && !customFieldKeys.value.has(h)))
|
|
145
|
+
const customFieldHeaders = computed(() => headers.value.filter((h) => !io.wMapping.value[h] && customFieldKeys.value.has(h)))
|
|
146
|
+
const mappedTargets = computed(() => Object.values(io.wMapping.value).filter(Boolean))
|
|
147
|
+
const duplicateTargets = computed(() => {
|
|
148
|
+
const seen = new Set()
|
|
149
|
+
const dup = new Set()
|
|
150
|
+
for (const f of mappedTargets.value) (seen.has(f) ? dup : seen).add(f)
|
|
151
|
+
return [...dup]
|
|
152
|
+
})
|
|
153
|
+
const missingRequired = computed(() => schemaFields.value
|
|
154
|
+
.filter((f) => f.required && !mappedTargets.value.includes(f.key))
|
|
155
|
+
.map((f) => f.key))
|
|
156
|
+
|
|
157
|
+
// Tabel pemetaan terbuka sendiri hanya bila ada kolom yang tak dikenali — berkas hasil ekspor
|
|
158
|
+
// Init Worksuite selalu terpetakan penuh, dan memaksa orang memeriksa 20 baris yang sudah benar
|
|
159
|
+
// hanya mengajarinya mengabaikan tabel itu pada saat ia benar-benar perlu dibaca.
|
|
160
|
+
const mappingOpen = ref(false)
|
|
161
|
+
watch(() => io.wInspect.value?.id, (id) => {
|
|
162
|
+
if (id) mappingOpen.value = unmappedHeaders.value.length > 0
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const modeOptions = computed(() => io.schema.value?.modes ?? ['insert', 'complete', 'overwrite'])
|
|
166
|
+
const keyColumns = computed(() => io.schema.value?.key_columns ?? {})
|
|
167
|
+
const hasKeyColumns = computed(() => Object.keys(keyColumns.value).length > 0)
|
|
168
|
+
|
|
169
|
+
// Label kunci: kamus dulu, cadangannya kalimat backend (English). Dibaca dari kamus locale AKTIF
|
|
170
|
+
// (bukan `te()`, yang di vue-i18n v11 ikut menghitung locale cadangan).
|
|
171
|
+
function keyLabel(key, backendLabel) {
|
|
172
|
+
const msg = getLocaleMessage(locale.value)?.dataTools?.wizard?.keyColumn?.[key]
|
|
173
|
+
return typeof msg === 'string' ? msg : (backendLabel || key)
|
|
174
|
+
}
|
|
175
|
+
const keyOptions = computed(() => Object.entries(keyColumns.value)
|
|
176
|
+
.map(([k, label]) => ({ value: k, label: keyLabel(k, label) })))
|
|
177
|
+
|
|
178
|
+
// `ref`/`stage_ref` punya cadangan pencocokan di backend (email/nama) bila kolomnya tak dipetakan;
|
|
179
|
+
// kunci lain tidak, sehingga tanpa kolomnya setiap baris diam-diam dianggap baru.
|
|
180
|
+
const keyColumnUnmapped = computed(() => {
|
|
181
|
+
const k = io.wKeyColumn.value
|
|
182
|
+
if (!k || ['ref', 'stage_ref'].includes(k)) return false
|
|
183
|
+
return schemaFields.value.some((f) => f.key === k) && !mappedTargets.value.includes(k)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const showDeactivate = computed(() => !!io.schema.value?.supports_deactivate && io.wMode.value !== 'complete')
|
|
187
|
+
|
|
188
|
+
const canRunWizardDryRun = computed(() =>
|
|
189
|
+
mappedTargets.value.length > 0
|
|
190
|
+
&& duplicateTargets.value.length === 0
|
|
191
|
+
&& (!hasKeyColumns.value || !!io.wKeyColumn.value),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const plan = computed(() => io.wPlan.value?.plan ?? null)
|
|
195
|
+
const planTiles = computed(() => {
|
|
196
|
+
const p = plan.value ?? {}
|
|
197
|
+
const tiles = [
|
|
198
|
+
{ key: 'new', value: p.new ?? 0, cls: 'text-green-600 dark:text-green-400' },
|
|
199
|
+
{ key: 'updated', value: p.updated ?? 0, cls: 'text-blue-600 dark:text-blue-400' },
|
|
200
|
+
{ key: 'skipped', value: p.skipped_existing ?? 0, cls: 'text-gray-700 dark:text-gray-300' },
|
|
201
|
+
]
|
|
202
|
+
if (io.wPlan.value?.mode && io.wPlan.value.mode !== 'insert') {
|
|
203
|
+
tiles.push({ key: 'fieldsToFill', value: p.fields_to_fill ?? 0, cls: 'text-gray-700 dark:text-gray-300' })
|
|
204
|
+
}
|
|
205
|
+
if (io.wPlan.value?.deactivate_missing) {
|
|
206
|
+
tiles.push({ key: 'deactivate', value: p.deactivate ?? 0, cls: 'text-amber-600 dark:text-amber-400' })
|
|
207
|
+
}
|
|
208
|
+
return tiles
|
|
209
|
+
})
|
|
210
|
+
const conflicts = computed(() => plan.value?.conflicts ?? [])
|
|
211
|
+
const nothingToDo = computed(() => {
|
|
212
|
+
const p = plan.value
|
|
213
|
+
return !!p && (io.wPlan.value?.valid_rows ?? 0) > 0 && !p.new && !p.updated && !p.deactivate
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const doneCounts = computed(() => {
|
|
217
|
+
const p = io.wSession.value?.plan ?? plan.value ?? {}
|
|
218
|
+
return { new: p.new ?? 0, updated: p.updated ?? 0, skipped: p.skipped_existing ?? 0 }
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// Galat wizard → kalimat. 422 per-field (`key_column`/`deactivate_missing`/`mapping`) dirangkai
|
|
222
|
+
// apa adanya karena kalimatnya datang dari validasi backend yang tak punya padanan field di layar.
|
|
223
|
+
function wizardErrText(e) {
|
|
224
|
+
if (!e) return ''
|
|
225
|
+
switch (e.kind) {
|
|
226
|
+
case 'sensitive': return t('dataTools.wizard.error.sensitive', { fields: (e.fields ?? []).join(', ') })
|
|
227
|
+
case 'notReady': return t('dataTools.wizard.error.notReady')
|
|
228
|
+
case 'tooLarge': return t('dataTools.wizard.error.tooLarge')
|
|
229
|
+
case 'notFound': return t('dataTools.wizard.error.notFound')
|
|
230
|
+
case 'forbidden': return t('common.forbidden')
|
|
231
|
+
case 'network': return t('common.networkError')
|
|
232
|
+
case 'server': return e.errorId ? t('common.serverErrorWithId', { id: e.errorId }) : t('common.serverError')
|
|
233
|
+
case 'validation': {
|
|
234
|
+
const msgs = Object.values(e.fieldErrors ?? {}).filter(Boolean)
|
|
235
|
+
return msgs.length ? msgs.join(' ') : t('common.validationFailed')
|
|
236
|
+
}
|
|
237
|
+
default: return t(`dataTools.wizard.error.${e.kind}`)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Validasi (422) cukup tampil di dalam wizard; selain itu toast ikut menyala supaya kegagalannya
|
|
242
|
+
// tetap terlihat walau modal sudah digulung jauh dari tombolnya.
|
|
243
|
+
watch(() => io.wError.value, (e) => {
|
|
244
|
+
if (e && e.kind !== 'validation') {
|
|
245
|
+
pushToast({ title: t('dataTools.wizard.toast.failedTitle'), body: wizardErrText(e), variant: 'error' })
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
function doWizardCommit() {
|
|
250
|
+
io.wizardCommit(
|
|
251
|
+
(s) => {
|
|
252
|
+
const p = s.plan ?? {}
|
|
253
|
+
pushToast({
|
|
254
|
+
title: t('dataTools.wizard.toast.doneTitle'),
|
|
255
|
+
body: t('dataTools.wizard.done.text', { new: p.new ?? 0, updated: p.updated ?? 0, skipped: p.skipped_existing ?? 0 }),
|
|
256
|
+
variant: 'success',
|
|
257
|
+
})
|
|
258
|
+
emit('imported', s)
|
|
259
|
+
},
|
|
260
|
+
() => pushToast({ title: t('dataTools.wizard.toast.failedTitle'), body: t('dataTools.wizard.toast.failedBody'), variant: 'error' }),
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
</script>
|
|
264
|
+
|
|
265
|
+
<template>
|
|
266
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
267
|
+
<!-- ── Impor ── -->
|
|
268
|
+
<AppButton v-if="canImport" variant="secondary" @click="openImport">
|
|
269
|
+
<Upload class="w-4 h-4" />{{ t('dataTools.import.button') }}
|
|
270
|
+
</AppButton>
|
|
271
|
+
|
|
272
|
+
<!-- ── Ekspor (dropdown CSV/XLSX) ── -->
|
|
273
|
+
<div v-if="canExport" class="relative">
|
|
274
|
+
<AppButton variant="secondary" :loading="!!io.exporting.value" @click="exportMenu = !exportMenu">
|
|
275
|
+
<Download class="w-4 h-4" />{{ t('dataTools.export.button') }}
|
|
276
|
+
</AppButton>
|
|
277
|
+
<template v-if="exportMenu">
|
|
278
|
+
<div class="fixed inset-0 z-40" @click="exportMenu = false"></div>
|
|
279
|
+
<div class="absolute right-0 mt-1 z-50 w-44 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
|
|
280
|
+
<button class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" @click="doExport('csv')">{{ t('dataTools.export.csv') }}</button>
|
|
281
|
+
<button class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" @click="doExport('xlsx')">{{ t('dataTools.export.xlsx') }}</button>
|
|
282
|
+
</div>
|
|
283
|
+
</template>
|
|
284
|
+
</div>
|
|
285
|
+
<!-- ── Cetak Laporan (PDF) ──
|
|
286
|
+
Digerbang `canExport` yang SAMA, bukan flag baru: kontrak §3 menetapkan Export & Report
|
|
287
|
+
sama-sama `can_export`. Menambah prop sendiri hanya melahirkan pilihan yang bisa
|
|
288
|
+
menyimpang dari gerbang backend tanpa ada yang menyadarinya. -->
|
|
289
|
+
<AppButton v-if="canExport && report" variant="secondary" :loading="io.printing.value" @click="doPrint">
|
|
290
|
+
<Printer class="w-4 h-4" />{{ t('dataTools.report.button') }}
|
|
291
|
+
</AppButton>
|
|
292
|
+
|
|
293
|
+
<span v-if="io.exportError.value" class="text-xs text-red-600 dark:text-red-400">{{ t('dataTools.export.err') }}</span>
|
|
294
|
+
<span v-if="io.printError.value" class="text-xs text-red-600 dark:text-red-400">{{ t('dataTools.report.err') }}</span>
|
|
295
|
+
|
|
296
|
+
<!-- ── Modal Impor (mesin lama) ── -->
|
|
297
|
+
<AppModal
|
|
298
|
+
v-if="showImport && !isWizard"
|
|
299
|
+
:title="t('dataTools.import.modalTitle')"
|
|
300
|
+
:subtitle="t('dataTools.import.modalSubtitle')"
|
|
301
|
+
size="lg"
|
|
302
|
+
@close="closeImport"
|
|
303
|
+
>
|
|
304
|
+
<!-- Step: idle (unduh template + pilih berkas) -->
|
|
305
|
+
<div v-if="io.step.value === 'idle'" class="space-y-4">
|
|
306
|
+
<p class="text-sm text-gray-600 dark:text-gray-400">{{ t('dataTools.import.step1Intro') }}</p>
|
|
307
|
+
<!-- Panduan pengisian: bawaannya diturunkan dari skema importer backend (kolom, wajib-isi,
|
|
308
|
+
daftar nilai sah tenant), sehingga tak bisa basi terhadap importernya. Slot `guide`
|
|
309
|
+
menggantikannya untuk modul yang skemanya belum membawa petunjuk format — panduan
|
|
310
|
+
tulisan tangan di sana harus dicabut begitu skema modul itu lengkap. Tanpa panduan
|
|
311
|
+
sama sekali, kesalahan baru ketahuan saat dry-run, satu kolom per putaran. -->
|
|
312
|
+
<slot name="guide">
|
|
313
|
+
<AppImportSchemaGuide
|
|
314
|
+
:schema="io.schema.value"
|
|
315
|
+
:loading="io.schemaLoading.value"
|
|
316
|
+
:error="io.schemaError.value"
|
|
317
|
+
/>
|
|
318
|
+
</slot>
|
|
319
|
+
<AppButton variant="ghost" size="sm" :loading="io.busyTemplate.value" @click="io.downloadTemplate">
|
|
320
|
+
<FileDown class="w-4 h-4" />{{ t('dataTools.import.downloadTemplate') }}
|
|
321
|
+
</AppButton>
|
|
322
|
+
|
|
323
|
+
<div>
|
|
324
|
+
<input
|
|
325
|
+
ref="fileInput"
|
|
326
|
+
type="file"
|
|
327
|
+
accept=".csv,.txt,text/csv"
|
|
328
|
+
class="block w-full text-sm text-gray-600 dark:text-gray-400 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 dark:file:bg-blue-900/30 dark:file:text-blue-300 hover:file:bg-blue-100 dark:hover:file:bg-blue-900/50"
|
|
329
|
+
@change="onFileChange"
|
|
330
|
+
/>
|
|
331
|
+
<p class="mt-1 text-xs text-gray-400">{{ t('dataTools.import.fileHint') }}</p>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
<p v-if="io.importError.value" class="text-xs text-red-600 dark:text-red-400">{{ importErrText(io.importError.value) }}</p>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<!-- Step: dryrun (ringkasan + preview error) -->
|
|
338
|
+
<div v-else-if="io.step.value === 'dryrun'" class="space-y-4">
|
|
339
|
+
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 text-sm text-gray-700 dark:text-gray-300">
|
|
340
|
+
{{ t('dataTools.import.summary', {
|
|
341
|
+
total: io.session.value?.total_rows ?? 0,
|
|
342
|
+
valid: io.session.value?.valid_rows ?? 0,
|
|
343
|
+
invalid: io.session.value?.invalid_rows ?? 0,
|
|
344
|
+
}) }}
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
<!-- Baris yang ref-nya sama dengan ID data di tenant ini dilewati backend dan TIDAK dihitung
|
|
348
|
+
valid; tanpa keterangan ini jumlah valid tampak lebih kecil dari isi berkas tanpa sebab. -->
|
|
349
|
+
<p v-if="legacySkipped" class="flex items-start gap-1.5 text-sm text-blue-700 dark:text-blue-300">
|
|
350
|
+
<Info class="w-4 h-4 mt-0.5 shrink-0" />{{ t('dataTools.import.skippedExisting', { n: legacySkipped }, legacySkipped) }}
|
|
351
|
+
</p>
|
|
352
|
+
|
|
353
|
+
<p v-if="legacySkipped && !(io.session.value?.valid_rows) && !(io.session.value?.invalid_rows)" class="text-sm text-gray-600 dark:text-gray-300">
|
|
354
|
+
{{ t('dataTools.import.nothingNew') }}
|
|
355
|
+
</p>
|
|
356
|
+
<p v-else-if="!(io.session.value?.invalid_rows)" class="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
|
|
357
|
+
<CheckCircle2 class="w-4 h-4" />{{ t('dataTools.import.allValid') }}
|
|
358
|
+
</p>
|
|
359
|
+
|
|
360
|
+
<div v-else class="space-y-2">
|
|
361
|
+
<div class="flex items-center justify-between gap-3">
|
|
362
|
+
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dataTools.import.previewErrorsTitle') }}</p>
|
|
363
|
+
<AppButton variant="ghost" size="sm" :loading="io.busyErrors.value" @click="io.downloadErrors">
|
|
364
|
+
<FileDown class="w-3.5 h-3.5" />{{ t('dataTools.import.downloadErrors') }}
|
|
365
|
+
</AppButton>
|
|
366
|
+
</div>
|
|
367
|
+
<div class="max-h-56 overflow-y-auto scrollbar-rounded rounded-lg border border-gray-200 dark:border-gray-700">
|
|
368
|
+
<table class="w-full text-sm">
|
|
369
|
+
<thead class="bg-gray-50 dark:bg-gray-900/40 sticky top-0">
|
|
370
|
+
<tr class="text-left text-xs text-gray-500 dark:text-gray-400">
|
|
371
|
+
<th class="py-2 px-3 font-medium w-16">{{ t('dataTools.import.colLine') }}</th>
|
|
372
|
+
<th class="py-2 px-3 font-medium">{{ t('dataTools.import.colErrors') }}</th>
|
|
373
|
+
</tr>
|
|
374
|
+
</thead>
|
|
375
|
+
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
376
|
+
<tr v-for="(e, i) in (io.dryRunResult.value?.preview_errors ?? [])" :key="i">
|
|
377
|
+
<td class="py-2 px-3 text-gray-500 dark:text-gray-400 align-top">{{ e.line }}</td>
|
|
378
|
+
<td class="py-2 px-3 text-red-600 dark:text-red-400">
|
|
379
|
+
<div v-for="(m, j) in e.errors" :key="j">{{ m }}</div>
|
|
380
|
+
</td>
|
|
381
|
+
</tr>
|
|
382
|
+
</tbody>
|
|
383
|
+
</table>
|
|
384
|
+
</div>
|
|
385
|
+
<p class="text-xs text-amber-600 dark:text-amber-400">
|
|
386
|
+
{{ t('dataTools.import.confirmProceed', {
|
|
387
|
+
valid: io.session.value?.valid_rows ?? 0,
|
|
388
|
+
invalid: io.session.value?.invalid_rows ?? 0,
|
|
389
|
+
}) }}
|
|
390
|
+
</p>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<p v-if="io.importError.value" class="text-xs text-red-600 dark:text-red-400">{{ importErrText(io.importError.value) }}</p>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<!-- Step: processing -->
|
|
397
|
+
<div v-else-if="io.step.value === 'processing'" class="py-6 text-center space-y-2">
|
|
398
|
+
<Loader2 class="w-8 h-8 mx-auto text-blue-500 animate-spin" />
|
|
399
|
+
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dataTools.import.processing') }}</p>
|
|
400
|
+
<p class="text-xs text-gray-400">{{ t('dataTools.import.processingNote') }}</p>
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
<!-- Step: done -->
|
|
404
|
+
<div v-else-if="io.step.value === 'done'" class="py-6 text-center space-y-2">
|
|
405
|
+
<CheckCircle2 class="w-8 h-8 mx-auto text-green-500" />
|
|
406
|
+
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dataTools.import.doneTitle') }}</p>
|
|
407
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('dataTools.import.doneText', { imported: io.session.value?.imported_rows ?? 0 }) }}</p>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
<!-- Step: failed -->
|
|
411
|
+
<div v-else-if="io.step.value === 'failed'" class="py-6 text-center space-y-2">
|
|
412
|
+
<XCircle class="w-8 h-8 mx-auto text-red-500" />
|
|
413
|
+
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dataTools.import.failedTitle') }}</p>
|
|
414
|
+
<p v-if="io.session.value?.failure_reason" class="text-xs text-red-600 dark:text-red-400">{{ io.session.value.failure_reason }}</p>
|
|
415
|
+
</div>
|
|
416
|
+
|
|
417
|
+
<template #footer>
|
|
418
|
+
<!-- idle -->
|
|
419
|
+
<template v-if="io.step.value === 'idle'">
|
|
420
|
+
<AppButton variant="secondary" @click="closeImport">{{ t('dataTools.import.close') }}</AppButton>
|
|
421
|
+
<AppButton variant="primary" :disabled="!io.file.value" :loading="io.dryRunning.value" @click="io.runDryRun">{{ t('dataTools.import.runDryRun') }}</AppButton>
|
|
422
|
+
</template>
|
|
423
|
+
<!-- dryrun -->
|
|
424
|
+
<template v-else-if="io.step.value === 'dryrun'">
|
|
425
|
+
<AppButton variant="secondary" @click="io.reset">{{ t('dataTools.import.chooseAnother') }}</AppButton>
|
|
426
|
+
<AppButton variant="primary" :disabled="!(io.session.value?.valid_rows)" :loading="io.committing.value" @click="doCommit">{{ t('dataTools.import.proceed') }}</AppButton>
|
|
427
|
+
</template>
|
|
428
|
+
<!-- processing / done / failed -->
|
|
429
|
+
<template v-else>
|
|
430
|
+
<AppButton variant="primary" @click="closeImport">{{ t('dataTools.import.close') }}</AppButton>
|
|
431
|
+
</template>
|
|
432
|
+
</template>
|
|
433
|
+
</AppModal>
|
|
434
|
+
|
|
435
|
+
<!-- ── Modal Impor (mesin wizard) ── -->
|
|
436
|
+
<AppModal
|
|
437
|
+
v-if="showImport && isWizard"
|
|
438
|
+
:title="t('dataTools.import.modalTitle')"
|
|
439
|
+
:subtitle="t('dataTools.wizard.modalSubtitle')"
|
|
440
|
+
size="xl"
|
|
441
|
+
@close="closeImport"
|
|
442
|
+
>
|
|
443
|
+
<div class="space-y-4">
|
|
444
|
+
<!-- Penanda langkah -->
|
|
445
|
+
<ol class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
|
446
|
+
<li v-for="(s, i) in WIZARD_STEPS" :key="s" class="flex items-center gap-2">
|
|
447
|
+
<span
|
|
448
|
+
:class="[
|
|
449
|
+
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full',
|
|
450
|
+
i === wizardStepIndex
|
|
451
|
+
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 font-medium'
|
|
452
|
+
: i < wizardStepIndex
|
|
453
|
+
? 'text-gray-600 dark:text-gray-300'
|
|
454
|
+
: 'text-gray-400 dark:text-gray-500',
|
|
455
|
+
]"
|
|
456
|
+
:aria-current="i === wizardStepIndex ? 'step' : undefined"
|
|
457
|
+
>
|
|
458
|
+
<span class="tabular-nums">{{ i + 1 }}.</span>{{ t(`dataTools.wizard.steps.${s}`) }}
|
|
459
|
+
</span>
|
|
460
|
+
<ChevronRight v-if="i < WIZARD_STEPS.length - 1" class="w-3 h-3 text-gray-300 dark:text-gray-600" aria-hidden="true" />
|
|
461
|
+
</li>
|
|
462
|
+
</ol>
|
|
463
|
+
|
|
464
|
+
<AppAlert v-if="io.wError.value" variant="error" :message="wizardErrText(io.wError.value)" />
|
|
465
|
+
|
|
466
|
+
<!-- 1. Berkas -->
|
|
467
|
+
<template v-if="io.wStep.value === 'upload'">
|
|
468
|
+
<p class="text-sm text-gray-600 dark:text-gray-400">{{ t('dataTools.wizard.uploadIntro') }}</p>
|
|
469
|
+
<slot name="guide">
|
|
470
|
+
<AppImportSchemaGuide
|
|
471
|
+
:schema="io.schema.value"
|
|
472
|
+
:loading="io.schemaLoading.value"
|
|
473
|
+
:error="io.schemaError.value"
|
|
474
|
+
/>
|
|
475
|
+
</slot>
|
|
476
|
+
<AppButton variant="ghost" size="sm" :loading="io.busyTemplate.value" @click="io.downloadTemplate">
|
|
477
|
+
<FileDown class="w-4 h-4" />{{ t('dataTools.import.downloadTemplate') }}
|
|
478
|
+
</AppButton>
|
|
479
|
+
<div>
|
|
480
|
+
<input
|
|
481
|
+
type="file"
|
|
482
|
+
accept=".csv,.txt,.xlsx,.xls,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
|
|
483
|
+
:aria-label="t('dataTools.import.chooseFile')"
|
|
484
|
+
class="block w-full text-sm text-gray-600 dark:text-gray-400 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 dark:file:bg-blue-900/30 dark:file:text-blue-300 hover:file:bg-blue-100 dark:hover:file:bg-blue-900/50"
|
|
485
|
+
@change="onWizardFileChange"
|
|
486
|
+
/>
|
|
487
|
+
<p class="mt-1 text-xs text-gray-400">{{ t('dataTools.wizard.fileHint') }}</p>
|
|
488
|
+
</div>
|
|
489
|
+
<p v-if="io.importError.value" class="text-xs text-red-600 dark:text-red-400">{{ importErrText(io.importError.value) }}</p>
|
|
490
|
+
</template>
|
|
491
|
+
|
|
492
|
+
<!-- 2. Pengaturan: pemetaan kolom + mode + kunci -->
|
|
493
|
+
<template v-else-if="io.wStep.value === 'configure'">
|
|
494
|
+
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 text-sm text-gray-700 dark:text-gray-300 space-y-1">
|
|
495
|
+
<p>{{ t('dataTools.wizard.readSummary', { rows: io.wInspect.value?.total_rows ?? 0, file: io.wFile.value?.name ?? '' }) }}</p>
|
|
496
|
+
<p v-if="!unmappedHeaders.length" class="flex items-center gap-1.5 text-green-600 dark:text-green-400">
|
|
497
|
+
<CheckCircle2 class="w-4 h-4 shrink-0" />{{ t('dataTools.wizard.allMapped', { count: headers.length - customFieldHeaders.length }) }}
|
|
498
|
+
</p>
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
<AppAlert v-if="unmappedHeaders.length" variant="warning" :message="t('dataTools.wizard.unmapped', { count: unmappedHeaders.length })" />
|
|
502
|
+
<AppAlert v-if="customFieldHeaders.length" variant="info" :message="t('dataTools.wizard.customFieldColumns', { cols: customFieldHeaders.join(', ') })" />
|
|
503
|
+
<AppAlert v-if="duplicateTargets.length" variant="error" :message="t('dataTools.wizard.duplicateTarget', { fields: duplicateTargets.join(', ') })" />
|
|
504
|
+
<AppAlert v-if="missingRequired.length" variant="warning" :message="t('dataTools.wizard.missingRequired', { fields: missingRequired.join(', ') })" />
|
|
505
|
+
|
|
506
|
+
<div>
|
|
507
|
+
<button
|
|
508
|
+
type="button"
|
|
509
|
+
class="inline-flex items-center gap-1 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
|
510
|
+
:aria-expanded="mappingOpen ? 'true' : 'false'"
|
|
511
|
+
@click="mappingOpen = !mappingOpen"
|
|
512
|
+
>
|
|
513
|
+
<ChevronDown :class="['w-4 h-4 transition-transform', mappingOpen ? '' : '-rotate-90']" aria-hidden="true" />
|
|
514
|
+
{{ mappingOpen ? t('dataTools.wizard.hideMapping') : t('dataTools.wizard.showMapping') }}
|
|
515
|
+
</button>
|
|
516
|
+
<div v-if="mappingOpen" class="mt-2 max-h-72 overflow-y-auto scrollbar-rounded rounded-lg border border-gray-200 dark:border-gray-700">
|
|
517
|
+
<table class="w-full text-sm">
|
|
518
|
+
<thead class="bg-gray-50 dark:bg-gray-900/40 sticky top-0 z-10">
|
|
519
|
+
<tr class="text-left text-xs text-gray-500 dark:text-gray-400">
|
|
520
|
+
<th class="py-2 px-3 font-medium">{{ t('dataTools.wizard.colSource') }}</th>
|
|
521
|
+
<th class="py-2 px-3 font-medium hidden sm:table-cell">{{ t('dataTools.wizard.colSample') }}</th>
|
|
522
|
+
<th class="py-2 px-3 font-medium w-64">{{ t('dataTools.wizard.colTarget') }}</th>
|
|
523
|
+
</tr>
|
|
524
|
+
</thead>
|
|
525
|
+
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
526
|
+
<tr v-for="h in headers" :key="h" :class="!io.wMapping.value[h] && !customFieldKeys.has(h) ? 'bg-amber-50/60 dark:bg-amber-900/10' : ''">
|
|
527
|
+
<td class="py-1.5 px-3 align-middle"><code class="text-gray-800 dark:text-gray-100">{{ h }}</code></td>
|
|
528
|
+
<td class="py-1.5 px-3 align-middle text-gray-500 dark:text-gray-400 truncate max-w-[12rem] hidden sm:table-cell">{{ firstSample[h] ?? '' }}</td>
|
|
529
|
+
<td class="py-1.5 px-3 align-middle">
|
|
530
|
+
<AppCombobox
|
|
531
|
+
:model-value="targetOf(h)"
|
|
532
|
+
:options="targetOptions"
|
|
533
|
+
:aria-label="t('dataTools.wizard.targetAria', { column: h })"
|
|
534
|
+
dense
|
|
535
|
+
hide-clear
|
|
536
|
+
@update:model-value="(v) => setTarget(h, v)"
|
|
537
|
+
/>
|
|
538
|
+
</td>
|
|
539
|
+
</tr>
|
|
540
|
+
</tbody>
|
|
541
|
+
</table>
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
|
|
545
|
+
<!-- Sumbu (b): perlakuan baris yang cocok -->
|
|
546
|
+
<fieldset class="space-y-2">
|
|
547
|
+
<legend class="text-sm font-medium text-gray-800 dark:text-gray-200 mb-1">{{ t('dataTools.wizard.modeTitle') }}</legend>
|
|
548
|
+
<label
|
|
549
|
+
v-for="m in modeOptions"
|
|
550
|
+
:key="m"
|
|
551
|
+
:class="[
|
|
552
|
+
'flex items-start gap-3 p-3 rounded-lg border cursor-pointer',
|
|
553
|
+
io.wMode.value === m
|
|
554
|
+
? 'border-blue-300 bg-blue-50/60 dark:border-blue-700 dark:bg-blue-900/20'
|
|
555
|
+
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/60',
|
|
556
|
+
]"
|
|
557
|
+
>
|
|
558
|
+
<input v-model="io.wMode.value" type="radio" name="import-mode" :value="m" class="mt-0.5 text-blue-600 focus:ring-blue-500" />
|
|
559
|
+
<span>
|
|
560
|
+
<span class="block text-sm font-medium text-gray-800 dark:text-gray-200">{{ t(`dataTools.wizard.mode.${m}.label`) }}</span>
|
|
561
|
+
<span class="block text-xs text-gray-500 dark:text-gray-400">{{ t(`dataTools.wizard.mode.${m}.desc`) }}</span>
|
|
562
|
+
</span>
|
|
563
|
+
</label>
|
|
564
|
+
</fieldset>
|
|
565
|
+
|
|
566
|
+
<!-- Sumbu (a): kunci pencocokan -->
|
|
567
|
+
<div v-if="hasKeyColumns" class="space-y-1">
|
|
568
|
+
<AppCombobox
|
|
569
|
+
:model-value="io.wKeyColumn.value || null"
|
|
570
|
+
:options="keyOptions"
|
|
571
|
+
:label="t('dataTools.wizard.keyTitle')"
|
|
572
|
+
:hint="t('dataTools.wizard.keyHint')"
|
|
573
|
+
:error="!io.wKeyColumn.value ? t('dataTools.wizard.keyRequired') : ''"
|
|
574
|
+
required
|
|
575
|
+
hide-clear
|
|
576
|
+
@update:model-value="(v) => io.wKeyColumn.value = v || ''"
|
|
577
|
+
/>
|
|
578
|
+
<AppAlert v-if="keyColumnUnmapped" variant="warning" :message="t('dataTools.wizard.keyMissingColumn', { key: io.wKeyColumn.value })" />
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
<!-- Sumbu (c): terpisah dari mode; tak tersedia untuk `complete` & modul tanpa penanda aktif -->
|
|
582
|
+
<div v-if="showDeactivate" class="flex items-start gap-3">
|
|
583
|
+
<AppToggle
|
|
584
|
+
:model-value="io.wDeactivate.value"
|
|
585
|
+
:aria-label="t('dataTools.wizard.deactivateLabel')"
|
|
586
|
+
color="blue"
|
|
587
|
+
@toggle="io.wDeactivate.value = !io.wDeactivate.value"
|
|
588
|
+
/>
|
|
589
|
+
<div>
|
|
590
|
+
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">{{ t('dataTools.wizard.deactivateLabel') }}</p>
|
|
591
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('dataTools.wizard.deactivateDesc') }}</p>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
|
|
595
|
+
<div class="p-3 rounded-lg border border-gray-200 dark:border-gray-700 text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
|
596
|
+
<p class="font-medium text-gray-800 dark:text-gray-200">{{ t('dataTools.wizard.rulesTitle') }}</p>
|
|
597
|
+
<ul class="list-disc pl-4 space-y-0.5">
|
|
598
|
+
<li>{{ t('dataTools.wizard.ruleEmptyCell') }}</li>
|
|
599
|
+
<li>{{ t('dataTools.wizard.ruleUnmapped') }}</li>
|
|
600
|
+
<li>{{ t('dataTools.wizard.ruleNoDelete') }}</li>
|
|
601
|
+
</ul>
|
|
602
|
+
</div>
|
|
603
|
+
</template>
|
|
604
|
+
|
|
605
|
+
<!-- 3. Rencana -->
|
|
606
|
+
<template v-else-if="io.wStep.value === 'plan'">
|
|
607
|
+
<p class="text-sm text-gray-700 dark:text-gray-300">
|
|
608
|
+
{{ t('dataTools.wizard.plan.summary', {
|
|
609
|
+
total: io.wPlan.value?.total_rows ?? 0,
|
|
610
|
+
valid: io.wPlan.value?.valid_rows ?? 0,
|
|
611
|
+
invalid: io.wPlan.value?.invalid_rows ?? 0,
|
|
612
|
+
}) }}
|
|
613
|
+
</p>
|
|
614
|
+
|
|
615
|
+
<!-- Konflik paling atas: inilah satu-satunya tanda bahwa mode Lengkapi diam-diam
|
|
616
|
+
mempertahankan nilai lama yang berbeda dari berkas. -->
|
|
617
|
+
<div
|
|
618
|
+
v-if="conflicts.length"
|
|
619
|
+
class="rounded-lg border-2 border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 space-y-2"
|
|
620
|
+
role="alert"
|
|
621
|
+
>
|
|
622
|
+
<p class="text-sm font-semibold text-amber-800 dark:text-amber-300">
|
|
623
|
+
{{ t('dataTools.wizard.plan.conflictsTitle', { count: conflicts.length }) }}
|
|
624
|
+
</p>
|
|
625
|
+
<p class="text-xs text-amber-700 dark:text-amber-400">{{ t('dataTools.wizard.plan.conflictsBody') }}</p>
|
|
626
|
+
<div class="max-h-56 overflow-y-auto scrollbar-rounded rounded border border-amber-200 dark:border-amber-800 bg-white dark:bg-gray-900">
|
|
627
|
+
<table class="w-full text-xs">
|
|
628
|
+
<thead class="bg-amber-50 dark:bg-gray-800 sticky top-0">
|
|
629
|
+
<tr class="text-left text-gray-500 dark:text-gray-400">
|
|
630
|
+
<th class="py-1.5 px-2 font-medium w-14">{{ t('dataTools.wizard.plan.colLine') }}</th>
|
|
631
|
+
<th class="py-1.5 px-2 font-medium">{{ t('dataTools.wizard.plan.colField') }}</th>
|
|
632
|
+
<th class="py-1.5 px-2 font-medium">{{ t('dataTools.wizard.plan.colDb') }}</th>
|
|
633
|
+
<th class="py-1.5 px-2 font-medium">{{ t('dataTools.wizard.plan.colFile') }}</th>
|
|
634
|
+
</tr>
|
|
635
|
+
</thead>
|
|
636
|
+
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
637
|
+
<tr v-for="(c, i) in conflicts" :key="i">
|
|
638
|
+
<td class="py-1.5 px-2 text-gray-500 dark:text-gray-400">{{ c.line }}</td>
|
|
639
|
+
<td class="py-1.5 px-2"><code class="text-gray-800 dark:text-gray-100">{{ c.field }}</code></td>
|
|
640
|
+
<td class="py-1.5 px-2 text-gray-700 dark:text-gray-300 break-all">{{ c.db }}</td>
|
|
641
|
+
<td class="py-1.5 px-2 text-amber-700 dark:text-amber-300 break-all">{{ c.file }}</td>
|
|
642
|
+
</tr>
|
|
643
|
+
</tbody>
|
|
644
|
+
</table>
|
|
645
|
+
</div>
|
|
646
|
+
<p v-if="conflicts.length >= 100" class="text-xs italic text-amber-700 dark:text-amber-400">{{ t('dataTools.wizard.plan.conflictsTruncated') }}</p>
|
|
647
|
+
</div>
|
|
648
|
+
|
|
649
|
+
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
650
|
+
<div
|
|
651
|
+
v-for="tile in planTiles"
|
|
652
|
+
:key="tile.key"
|
|
653
|
+
:data-plan="tile.key"
|
|
654
|
+
class="rounded-lg border border-gray-200 dark:border-gray-700 p-3"
|
|
655
|
+
>
|
|
656
|
+
<p :class="['text-2xl font-semibold tabular-nums', tile.cls]">{{ tile.value }}</p>
|
|
657
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t(`dataTools.wizard.plan.${tile.key}`) }}</p>
|
|
658
|
+
</div>
|
|
659
|
+
</div>
|
|
660
|
+
|
|
661
|
+
<div v-if="io.wPlan.value?.invalid_rows" class="space-y-2">
|
|
662
|
+
<AppAlert
|
|
663
|
+
variant="error"
|
|
664
|
+
:title="t('dataTools.wizard.plan.invalidTitle', { count: io.wPlan.value.invalid_rows })"
|
|
665
|
+
:message="t('dataTools.wizard.plan.invalidBody')"
|
|
666
|
+
/>
|
|
667
|
+
<AppButton variant="secondary" size="sm" :loading="io.wBusyErrors.value" data-download-errors @click="io.wizardDownloadErrors">
|
|
668
|
+
<FileDown class="w-3.5 h-3.5" />{{ t('dataTools.wizard.downloadErrors') }}
|
|
669
|
+
</AppButton>
|
|
670
|
+
</div>
|
|
671
|
+
<AppAlert
|
|
672
|
+
v-if="plan?.deactivate"
|
|
673
|
+
variant="warning"
|
|
674
|
+
:message="t('dataTools.wizard.plan.deactivateWarn', { count: plan.deactivate })"
|
|
675
|
+
/>
|
|
676
|
+
<AppAlert v-if="nothingToDo" variant="info" :message="t('dataTools.wizard.plan.nothingToDo')" />
|
|
677
|
+
</template>
|
|
678
|
+
|
|
679
|
+
<!-- 4. Hasil -->
|
|
680
|
+
<div v-else-if="io.wStep.value === 'processing'" class="py-6 text-center space-y-2">
|
|
681
|
+
<Loader2 class="w-8 h-8 mx-auto text-blue-500 animate-spin" />
|
|
682
|
+
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dataTools.import.processing') }}</p>
|
|
683
|
+
<p class="text-xs text-gray-400">{{ t('dataTools.import.processingNote') }}</p>
|
|
684
|
+
</div>
|
|
685
|
+
<div v-else-if="io.wStep.value === 'done'" class="py-6 text-center space-y-2">
|
|
686
|
+
<CheckCircle2 class="w-8 h-8 mx-auto text-green-500" />
|
|
687
|
+
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dataTools.import.doneTitle') }}</p>
|
|
688
|
+
<p class="text-xs text-gray-500 dark:text-gray-400" data-import-result>{{ t('dataTools.wizard.done.text', doneCounts) }}</p>
|
|
689
|
+
<!-- Commit hanya menulis baris valid; baris yang tertinggal baru terasa penting sesudah
|
|
690
|
+
hasilnya terlihat, jadi laporannya tetap bisa diunduh di sini. -->
|
|
691
|
+
<template v-if="io.wPlan.value?.invalid_rows">
|
|
692
|
+
<p class="text-xs text-red-600 dark:text-red-400">{{ t('dataTools.wizard.done.invalidLeft', { count: io.wPlan.value.invalid_rows }) }}</p>
|
|
693
|
+
<AppButton variant="secondary" size="sm" :loading="io.wBusyErrors.value" data-download-errors @click="io.wizardDownloadErrors">
|
|
694
|
+
<FileDown class="w-3.5 h-3.5" />{{ t('dataTools.wizard.downloadErrors') }}
|
|
695
|
+
</AppButton>
|
|
696
|
+
</template>
|
|
697
|
+
</div>
|
|
698
|
+
<div v-else-if="io.wStep.value === 'failed'" class="py-6 text-center space-y-2">
|
|
699
|
+
<XCircle class="w-8 h-8 mx-auto text-red-500" />
|
|
700
|
+
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dataTools.import.failedTitle') }}</p>
|
|
701
|
+
<p v-if="io.wSession.value?.failure_reason" class="text-xs text-red-600 dark:text-red-400">{{ io.wSession.value.failure_reason }}</p>
|
|
702
|
+
</div>
|
|
703
|
+
</div>
|
|
704
|
+
|
|
705
|
+
<template #footer>
|
|
706
|
+
<template v-if="io.wStep.value === 'upload'">
|
|
707
|
+
<AppButton variant="secondary" @click="closeImport">{{ t('dataTools.import.close') }}</AppButton>
|
|
708
|
+
<AppButton variant="primary" :disabled="!io.wFile.value" :loading="io.wInspecting.value" @click="io.wizardInspect">{{ t('dataTools.wizard.inspect') }}</AppButton>
|
|
709
|
+
</template>
|
|
710
|
+
<template v-else-if="io.wStep.value === 'configure'">
|
|
711
|
+
<AppButton variant="secondary" @click="io.wizardBackToUpload">{{ t('dataTools.wizard.chooseAnother') }}</AppButton>
|
|
712
|
+
<AppButton variant="primary" :disabled="!canRunWizardDryRun" :loading="io.wDryRunning.value" @click="io.wizardDryRun">{{ t('dataTools.wizard.runDryRun') }}</AppButton>
|
|
713
|
+
</template>
|
|
714
|
+
<template v-else-if="io.wStep.value === 'plan'">
|
|
715
|
+
<AppButton variant="secondary" @click="io.wizardBackToConfigure">{{ t('dataTools.wizard.plan.backToConfigure') }}</AppButton>
|
|
716
|
+
<AppButton variant="primary" :disabled="!(io.wPlan.value?.valid_rows)" :loading="io.wCommitting.value" @click="doWizardCommit">{{ t('dataTools.wizard.plan.confirm') }}</AppButton>
|
|
717
|
+
</template>
|
|
718
|
+
<template v-else>
|
|
719
|
+
<AppButton variant="primary" @click="closeImport">{{ t('dataTools.import.close') }}</AppButton>
|
|
720
|
+
</template>
|
|
721
|
+
</template>
|
|
722
|
+
</AppModal>
|
|
723
|
+
</div>
|
|
724
|
+
</template>
|