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