Mass changes

This commit is contained in:
Simon Pocrnjič
2025-10-04 23:36:18 +02:00
parent ab50336e97
commit fe91c7e4bc
46 changed files with 5738 additions and 1873 deletions
+433 -358
View File
@@ -1,9 +1,21 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import TemplateControls from "./Partials/TemplateControls.vue";
import ChecklistSteps from "./Partials/ChecklistSteps.vue";
import MappingTable from "./Partials/MappingTable.vue";
import ActionsBar from "./Partials/ActionsBar.vue";
import SavedMappingsTable from "./Partials/SavedMappingsTable.vue";
import LogsTable from "./Partials/LogsTable.vue";
import ProcessResult from "./Partials/ProcessResult.vue";
import { ref, computed, onMounted, watch } from "vue";
import Multiselect from "vue-multiselect";
import axios from "axios";
import Modal from "@/Components/Modal.vue"; // still potentially used elsewhere
import CsvPreviewModal from "./Partials/CsvPreviewModal.vue";
import SimulationModal from "./Partials/SimulationModal.vue";
import { useCurrencyFormat } from "./useCurrencyFormat.js";
// Reintroduce props definition lost during earlier edits
const props = defineProps({
import: Object,
templates: Array,
@@ -11,6 +23,7 @@ const props = defineProps({
client: Object,
});
// Core reactive state (restored)
const importId = ref(props.import?.id || null);
const hasHeader = ref(Boolean(props.import?.meta?.has_header ?? true));
const detected = ref({
@@ -26,15 +39,16 @@ const mappingSaved = ref(false);
const mappingSavedCount = ref(0);
const mappingError = ref("");
const savingMappings = ref(false);
// Persisted mappings from backend (raw view regardless of detected columns)
const persistedMappings = ref([]);
const persistedMappings = ref([]); // raw persisted
let suppressMappingWatch = false; // guard to avoid resetting saved flag on programmatic updates
const persistedSignature = ref(""); // signature of last persisted mapping set
// (Reverted) We no longer fetch template-specific source columns; coverage uses detected columns
const detectedNote = ref("");
// Delimiter selection (auto by default, can be overridden by template or user)
const delimiterState = ref({ mode: "auto", custom: "" });
const effectiveDelimiter = computed(() => {
switch (delimiterState.value.mode) {
case "auto":
return null; // let backend detect
return null;
case "comma":
return ",";
case "semicolon":
@@ -51,7 +65,6 @@ const effectiveDelimiter = computed(() => {
return null;
}
});
// Initialize delimiter from import meta if previously chosen
const initForced = props.import?.meta?.forced_delimiter || null;
if (initForced) {
const map = { ",": "comma", ";": "semicolon", "\t": "tab", "|": "pipe", " ": "space" };
@@ -59,15 +72,82 @@ if (initForced) {
delimiterState.value.mode = mode;
if (mode === "custom") delimiterState.value.custom = initForced;
}
// Logs
const events = ref([]);
const eventsLimit = ref(200);
const loadingEvents = ref(false);
const showPreview = ref(false);
const previewLoading = ref(false);
const previewRows = ref([]);
const previewColumns = ref([]);
const previewTruncated = ref(false);
const previewLimit = ref(200);
// Completed status helper
// Determine if all detected columns are mapped with entity+field
function evaluateMappingSaved() {
console.log("here the evaluation happen of mapping save!");
const hasTemplate =
!!props.import?.import_template_id || !!form.value.import_template_id;
if (!hasTemplate) return;
// We only require coverage of template-defined source columns when a template is present.
// Template source columns are derived from persistedMappings (these reflect the template's mapping set)
// NOT every detected column (there may be extra columns in the uploaded file that the template intentionally ignores).
const detectedColsNorm = Array.isArray(detected.value.columns)
? detected.value.columns.map((c) => normalizeSource(c)).filter(Boolean)
: [];
// Determine required source columns:
// - If we have any persisted mappings (template applied or saved), use their source columns as the required set.
// - Otherwise (edge case: template id present but no persisted mappings yet), fall back to detected columns.
const templateSourceCols = Array.from(
new Set(
persistedMappings.value.map((m) => normalizeSource(m.source_column)).filter(Boolean)
)
);
const requiredSources = templateSourceCols.length
? templateSourceCols
: detectedColsNorm;
if (!requiredSources.length) return;
// A source column is considered covered if there exists a persisted mapping for it.
const mappedSources = new Set(
persistedMappings.value.map((m) => normalizeSource(m.source_column)).filter(Boolean)
);
if (!requiredSources.every((c) => mappedSources.has(c))) return; // incomplete coverage
// Now ensure that every required source column has an entity+field selected (unless the row is explicitly skipped).
const allHaveTargets = mappingRows.value.every((r) => {
const src = normalizeSource(r.source_column || "");
if (!src || !requiredSources.includes(src)) return true; // ignore non-required / extra columns
if (r.skip) return true; // skipped rows do not block completion
return !!(r.entity && r.field);
});
if (!allHaveTargets) return;
mappingSaved.value = true;
mappingSavedCount.value = mappingRows.value.filter(
(r) => r.entity && r.field && !r.skip
).length;
persistedSignature.value = computeMappingSignature(mappingRows.value);
}
function computeMappingSignature(rows) {
return rows
.filter((r) => r && r.source_column)
.map((r) => {
const src = normalizeSource(r.source_column || "");
const tgt = r.entity && r.field ? `${entityKeyToRecord(r.entity)}.${r.field}` : "";
return `${src}=>${tgt}`;
})
.sort()
.join("|");
}
// Entity definitions & state (restored)
const entityDefs = ref([]); // [{ key, label, canonical_root, fields: [] }]
const usingEntityFallback = ref(false);
// Completion & gating
const isCompleted = computed(() => (props.import?.status || "") === "completed");
// Whether backend has any saved mappings for this import
const hasPersistedMappings = computed(() => (persistedMappings.value?.length || 0) > 0);
const canProcess = computed(
() =>
@@ -77,9 +157,37 @@ const canProcess = computed(
!isCompleted.value
);
// Dynamic entity definitions and options fetched from API
const entityDefs = ref([]); // [{ key, label, canonical_root, fields: [] }]
const usingEntityFallback = ref(false);
// Preview helpers
async function openPreview() {
if (!importId.value) return;
showPreview.value = true;
await fetchPreview();
}
async function fetchPreview() {
if (!importId.value) return;
previewLoading.value = true;
try {
const { data } = await axios.get(
route("imports.preview", { import: importId.value }),
{
params: { limit: previewLimit.value },
headers: { Accept: "application/json" },
withCredentials: true,
}
);
previewColumns.value = Array.isArray(data?.columns) ? data.columns : [];
previewRows.value = Array.isArray(data?.rows) ? data.rows : [];
previewTruncated.value = !!data?.truncated;
} catch (e) {
console.error(
"Preview fetch failed",
e.response?.status || "",
e.response?.data || e
);
} finally {
previewLoading.value = false;
}
}
const entityOptions = computed(() =>
entityDefs.value.map((e) => ({ value: e.key, label: e.label || e.key }))
);
@@ -201,6 +309,7 @@ async function loadEntityDefs() {
}
// Normalize any existing mapping row entity values to UI keys if they are canonical roots
if (Array.isArray(mappingRows.value) && mappingRows.value.length) {
suppressMappingWatch = true;
const mapCanonToKey = keyByCanonicalRoot.value;
mappingRows.value = mappingRows.value.map((r) => {
const current = r.entity;
@@ -212,6 +321,7 @@ async function loadEntityDefs() {
}
return r;
});
suppressMappingWatch = false;
}
} catch (e) {
console.error("Failed to load import entity definitions", e);
@@ -219,6 +329,7 @@ async function loadEntityDefs() {
entityDefs.value = defaultEntityDefs();
// Also normalize with fallback
if (Array.isArray(mappingRows.value) && mappingRows.value.length) {
suppressMappingWatch = true;
const mapCanonToKey = keyByCanonicalRoot.value;
mappingRows.value = mappingRows.value.map((r) => {
const current = r.entity;
@@ -230,6 +341,7 @@ async function loadEntityDefs() {
}
return r;
});
suppressMappingWatch = false;
}
}
}
@@ -332,6 +444,95 @@ const selectedMappingsCount = computed(
() => mappingRows.value.filter((r) => !r.skip && r.entity && r.field).length
);
// --- UI Enhancements: Status badge, inline validation, checklist ---
const statusInfo = computed(() => {
const raw = (props.import?.status || "").toLowerCase();
const map = {
completed: {
label: "Zaključeno",
classes: "bg-emerald-100 text-emerald-700 border border-emerald-300",
},
processing: {
label: "Obdelava",
classes: "bg-indigo-100 text-indigo-700 border border-indigo-300",
},
validating: {
label: "Preverjanje",
classes: "bg-indigo-100 text-indigo-700 border border-indigo-300",
},
failed: {
label: "Neuspešno",
classes: "bg-red-100 text-red-700 border border-red-300",
},
parsed: {
label: "Razčlenjeno",
classes: "bg-slate-100 text-slate-700 border border-slate-300",
},
uploaded: {
label: "Naloženo",
classes: "bg-slate-100 text-slate-700 border border-slate-300",
},
};
return (
map[raw] || {
label: raw || "Status",
classes: "bg-gray-100 text-gray-700 border border-gray-300",
}
);
});
// Duplicate target (entity+field) detection for inline validation
const duplicateTargets = computed(() => {
const counts = new Map();
for (const r of mappingRows.value) {
if (!r.skip && r.entity && r.field) {
const key = entityKeyToRecord(r.entity) + "." + r.field;
counts.set(key, (counts.get(key) || 0) + 1);
}
}
const dups = new Set();
counts.forEach((v, k) => {
if (v > 1) dups.add(k);
});
return dups;
});
function duplicateTarget(row) {
if (!row || !row.entity || !row.field) return false;
const key = entityKeyToRecord(row.entity) + "." + row.field;
return duplicateTargets.value.has(key);
}
// Critical fields heuristic (extend as needed)
const criticalFields = computed(() => {
const base = ["contract.reference"];
const paymentsImport = !!selectedTemplateOption.value?.meta?.payments_import;
if (paymentsImport) {
base.push("payment.amount", "payment.payment_date");
}
return base;
});
const providedTargets = computed(() => {
const set = new Set();
for (const r of mappingRows.value) {
if (!r.skip && r.entity && r.field) {
set.add(entityKeyToRecord(r.entity) + "." + r.field);
}
}
return set;
});
const missingCritical = computed(() =>
criticalFields.value.filter((f) => !providedTargets.value.has(f))
);
// Checklist steps
const stepStates = computed(() => [
{ label: "1) Izberi predlogo", done: !!form.value.import_template_id },
{ label: "2) Preglej stolpce", done: (detected.value.columns || []).length > 0 },
{ label: "3) Preslikaj", done: selectedMappingsCount.value > 0 },
{ label: "4) Shrani", done: mappingSaved.value },
{ label: "5) Obdelaj", done: isCompleted.value || !!processResult.value },
]);
async function fetchColumns() {
if (!importId.value) return;
const url = route("imports.columns", { import: importId.value });
@@ -361,6 +562,7 @@ async function fetchColumns() {
detectedNote.value = data.note || "";
// initialize mapping rows if empty
if (!mappingRows.value.length && detected.value.columns.length) {
suppressMappingWatch = true;
mappingRows.value = detected.value.columns.map((c, idx) => ({
source_column: c,
entity: "",
@@ -370,6 +572,8 @@ async function fetchColumns() {
apply_mode: "both",
position: idx,
}));
suppressMappingWatch = false;
evaluateMappingSaved();
}
await loadImportMappings();
// Fallback: if no detected columns were found, but persisted mappings exist, use them to render the grid
@@ -399,7 +603,7 @@ async function applyTemplateToImport() {
try {
if (templateApplied.value) {
const ok = window.confirm(
'Re-apply this template? This will overwrite current mappings for this import.'
"Re-apply this template? This will overwrite current mappings for this import."
);
if (!ok) {
return;
@@ -462,6 +666,7 @@ async function loadImportMappings() {
persistedMappings.value = rows.slice();
if (!rows.length) return;
const bySource = new Map(rows.map((r) => [normalizeSource(r.source_column), r]));
suppressMappingWatch = true;
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
const m = bySource.get(normalizeSource(r.source_column));
if (!m) return r;
@@ -484,12 +689,41 @@ async function loadImportMappings() {
position: idx,
};
});
// Auto-evaluate mappingSaved when a template is already bound to the import.
// Previous logic required ALL detected columns. Updated: if a template is bound, only require template (persisted) source columns.
if (props.import?.import_template_id) {
const templateSources = Array.from(
new Set(
persistedMappings.value
.map((m) => normalizeSource(m.source_column))
.filter(Boolean)
)
);
if (templateSources.length) {
const allHaveTargets = mappingRows.value.every((r) => {
const src = normalizeSource(r.source_column || "");
if (!src || !templateSources.includes(src)) return true; // ignore extras
if (r.skip) return true;
return !!(r.entity && r.field);
});
if (allHaveTargets) {
mappingSaved.value = true;
mappingSavedCount.value = mappingRows.value.filter(
(r) => r.entity && r.field && !r.skip
).length;
}
}
}
suppressMappingWatch = false;
evaluateMappingSaved();
} catch (e) {
console.error(
"Load import mappings error",
e.response?.status || "",
e.response?.data || e
);
suppressMappingWatch = false;
}
}
@@ -602,18 +836,28 @@ onMounted(async () => {
await applyTemplateToImport();
}
} catch (e) {
console.warn('Auto apply template failed', e);
console.warn("Auto apply template failed", e);
}
// Load recent events (logs)
await fetchEvents();
// If template already bound when opening page, load template mapping columns
});
// Reset saved flag whenever user edits mappings
// Detect user changes (vs programmatic) using signature diff
watch(
mappingRows,
() => {
if (suppressMappingWatch) return;
const currentSig = computeMappingSignature(mappingRows.value);
if (persistedSignature.value && currentSig === persistedSignature.value) {
// No semantic change compared to persisted state
return;
}
// Real change -> unsaved
mappingSaved.value = false;
mappingSavedCount.value = 0;
mappingSavedCount.value = mappingRows.value.filter(
(r) => r.entity && r.field && !r.skip
).length;
mappingError.value = "";
},
{ deep: true }
@@ -624,6 +868,7 @@ watch(
() => detected.value.columns,
(cols) => {
if (Array.isArray(cols) && cols.length > 0 && mappingRows.value.length === 0) {
suppressMappingWatch = true;
mappingRows.value = cols.map((c, idx) => {
return {
source_column: c,
@@ -635,6 +880,8 @@ watch(
position: idx,
};
});
suppressMappingWatch = false;
evaluateMappingSaved();
}
}
);
@@ -673,21 +920,83 @@ async function fetchEvents() {
loadingEvents.value = false;
}
}
// Simulation (generic or payments) state
const showPaymentSim = ref(false);
const paymentSimLoading = ref(false);
const paymentSimLimit = ref(100);
const paymentSimRows = ref([]);
// summary (raw machine) + localized (povzetki.payment or others)
const paymentSimSummary = ref(null); // machine summary (if needed)
const paymentSimSummarySl = ref(null); // localized Slovenian summary
const paymentSimEntities = ref([]);
const paymentSimVerbose = ref(false); // "Podrobni pogled" toggle
const paymentsImport = computed(
() => !!selectedTemplateOption.value?.meta?.payments_import
);
// Currency formatter with fallback (client currency -> EUR)
const clientCurrency = props.client?.currency || "EUR";
const { formatMoney } = useCurrencyFormat({
primary: clientCurrency,
fallbacks: ["EUR"],
});
async function openSimulation() {
if (!importId.value) return;
showPaymentSim.value = true;
await fetchSimulation();
}
async function fetchSimulation() {
if (!importId.value) return;
paymentSimLoading.value = true;
try {
const routeName = paymentsImport.value
? "imports.simulatePayments" // legacy payments specific name
: "imports.simulate"; // new generic simulation
const { data } = await axios.get(route(routeName, { import: importId.value }), {
params: {
limit: paymentSimLimit.value,
verbose: paymentSimVerbose.value ? 1 : 0,
},
headers: { Accept: "application/json" },
withCredentials: true,
});
paymentSimRows.value = Array.isArray(data?.rows) ? data.rows : [];
paymentSimEntities.value = Array.isArray(data?.entities) ? data.entities : [];
// Summaries keys vary (payment, contract, account, etc.). Keep existing behaviour for payment summary exposure.
paymentSimSummary.value = data?.summaries?.payment || null;
paymentSimSummarySl.value = data?.povzetki?.payment || null;
} catch (e) {
console.error("Simulation failed", e.response?.status || "", e.response?.data || e);
} finally {
paymentSimLoading.value = false;
}
}
</script>
<template>
<AppLayout :title="`Import ${props.import?.uuid || ''}`">
<template #header>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-1">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Continue Import</h2>
<div class="text-sm text-gray-600">
<span class="mr-4">Client:
<strong>{{ selectedClientOption?.name || selectedClientOption?.uuid || "—" }}</strong>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Nadaljuj uvoz</h2>
<div class="text-sm text-gray-600 flex flex-wrap items-center gap-2">
<span class="mr-2"
>Stranka:
<strong>{{
selectedClientOption?.name || selectedClientOption?.uuid || "—"
}}</strong>
</span>
<span
v-if="templateApplied"
class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 align-middle"
>applied</span>
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 align-middle"
>uporabljena</span
>
<span
v-if="props.import?.status"
:class="['px-2 py-0.5 rounded-full text-xs font-medium', statusInfo.classes]"
>{{ statusInfo.label }}</span
>
</div>
</div>
</template>
@@ -726,145 +1035,51 @@ async function fetchEvents() {
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Client</label>
<Multiselect
v-model="selectedClientOption"
:options="clients"
track-by="uuid"
label="name"
placeholder="Search clients..."
:searchable="true"
:allow-empty="true"
class="mt-1"
disabled
/>
<p class="text-xs text-gray-500 mt-1">Client is set during upload.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Template</label>
<Multiselect
v-model="selectedTemplateOption"
:options="filteredTemplates"
track-by="id"
label="name"
placeholder="Search templates..."
:searchable="true"
:allow-empty="true"
class="mt-1"
:disabled="false"
>
<template #option="{ option }">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<span>{{ option.name }}</span>
<span class="ml-2 text-xs text-gray-500"
>({{ option.source_type }})</span
>
</div>
<span
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
>{{ option.client_id ? "Client" : "Global" }}</span
>
</div>
</template>
<template #singleLabel="{ option }">
<div class="flex items-center gap-2">
<span>{{ option.name }}</span>
<span class="ml-1 text-xs text-gray-500"
>({{ option.source_type }})</span
>
<span
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
>{{ option.client_id ? "Client" : "Global" }}</span
>
</div>
</template>
</Multiselect>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<TemplateControls
:is-completed="isCompleted"
:has-header="hasHeader"
:delimiter-state="delimiterState"
:selected-template-option="selectedTemplateOption"
:filtered-templates="filteredTemplates"
:template-applied="templateApplied"
:form="form"
@preview="openPreview"
@update:hasHeader="
(val) => {
hasHeader = val;
fetchColumns();
}
"
@update:delimiterMode="
(val) => {
delimiterState.mode = val;
fetchColumns();
}
"
@update:delimiterCustom="
(val) => {
delimiterState.custom = val;
fetchColumns();
}
"
@apply-template="applyTemplateToImport"
/>
<ChecklistSteps :steps="stepStates" :missing-critical="missingCritical" />
</div>
<!-- Parsing options -->
<div
class="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end"
v-if="!isCompleted"
>
<div>
<label class="block text-sm font-medium text-gray-700">Header row</label>
<select
v-model="hasHeader"
class="mt-1 block w-full border rounded p-2"
@change="fetchColumns"
>
<option :value="true">Has header</option>
<option :value="false">No header (positional)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Delimiter</label>
<select
v-model="delimiterState.mode"
class="mt-1 block w-full border rounded p-2"
>
<option value="auto">Auto-detect</option>
<option value="comma">Comma ,</option>
<option value="semicolon">Semicolon ;</option>
<option value="tab">Tab \t</option>
<option value="pipe">Pipe |</option>
<option value="space">Space </option>
<option value="custom">Custom</option>
</select>
<p class="text-xs text-gray-500 mt-1">
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
</p>
</div>
<div v-if="delimiterState.mode === 'custom'">
<label class="block text-sm font-medium text-gray-700"
>Custom delimiter</label
>
<input
v-model="delimiterState.custom"
maxlength="4"
placeholder=","
class="mt-1 block w-full border rounded p-2"
/>
</div>
</div>
<div class="flex gap-3" v-if="!isCompleted">
<button
@click.prevent="applyTemplateToImport"
:disabled="!importId || !form.import_template_id"
class="px-4 py-2 bg-emerald-600 disabled:bg-gray-300 text-white rounded"
>
{{ templateApplied ? 'Re-apply Template' : 'Apply Template' }}
</button>
<button
@click.prevent="saveMappings"
:disabled="!importId || processing || savingMappings || isCompleted"
class="px-4 py-2 bg-orange-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
title="Save ad-hoc mappings for this import"
>
<span
v-if="savingMappings"
class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
></span>
<span>Save Mappings</span>
<span
v-if="selectedMappingsCount"
class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded"
>{{ selectedMappingsCount }}</span
>
</button>
<button
@click.prevent="processImport"
:disabled="!canProcess"
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded"
>
{{ processing ? "Processing…" : "Process Import" }}
</button>
</div>
<ActionsBar
:import-id="importId"
:is-completed="isCompleted"
:processing="processing"
:saving-mappings="savingMappings"
:can-process="canProcess"
:selected-mappings-count="selectedMappingsCount"
@preview="openPreview"
@save-mappings="saveMappings"
@process-import="processImport"
@simulate="openSimulation"
/>
<div class="mt-2 text-xs text-gray-600" v-if="!importId">Import not found.</div>
<div
class="mt-2 text-xs text-gray-600"
@@ -874,129 +1089,22 @@ async function fetchEvents() {
click Save Mappings to enable processing.
</div>
<div v-if="persistedMappings.length" class="pt-4">
<h3 class="font-semibold mb-2">Current Saved Mappings</h3>
<div class="overflow-x-auto">
<table class="min-w-full border bg-white text-sm">
<thead>
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
<th class="p-2 border">Source column</th>
<th class="p-2 border">Target field</th>
<th class="p-2 border">Transform</th>
<th class="p-2 border">Mode</th>
</tr>
</thead>
<tbody>
<tr v-for="m in persistedMappings" :key="m.id" class="border-t">
<td class="p-2 border">{{ m.source_column }}</td>
<td class="p-2 border">{{ m.target_field }}</td>
<td class="p-2 border">{{ m.transform || "—" }}</td>
<td class="p-2 border">{{ m.apply_mode || "both" }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<SavedMappingsTable :mappings="persistedMappings" />
<div v-if="!isCompleted && displayRows.length" class="pt-4">
<h3 class="font-semibold mb-2">
<template v-if="!isCompleted"
>Detected Columns ({{ detected.has_header ? "header" : "positional" }})
<span class="ml-2 text-xs text-gray-500"
>detected: {{ detected.columns.length }}, rows:
{{ displayRows.length }}, delimiter:
{{ detected.delimiter || "auto" }}</span
>
</template>
<template v-else>Detected Columns</template>
</h3>
<p v-if="detectedNote" class="text-xs text-gray-500 mb-2">
{{ detectedNote }}
</p>
<div class="overflow-x-auto">
<table class="min-w-full border bg-white">
<thead>
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
<th class="p-2 border">Source column</th>
<th class="p-2 border">Entity</th>
<th class="p-2 border">Field</th>
<th class="p-2 border">Transform</th>
<th class="p-2 border">Apply mode</th>
<th class="p-2 border">Skip</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in displayRows" :key="idx" class="border-t">
<td class="p-2 border text-sm">{{ row.source_column }}</td>
<td class="p-2 border">
<select
v-model="row.entity"
class="border rounded p-1 w-full"
:disabled="isCompleted"
>
<option value=""></option>
<option
v-for="opt in entityOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</td>
<td class="p-2 border">
<select
v-model="row.field"
class="border rounded p-1 w-full"
:disabled="isCompleted"
>
<option value=""></option>
<option
v-for="f in fieldsForEntity(row.entity)"
:key="f"
:value="f"
>
{{ f }}
</option>
</select>
</td>
<td class="p-2 border">
<select
v-model="row.transform"
class="border rounded p-1 w-full"
:disabled="isCompleted"
>
<option value="">None</option>
<option value="trim">Trim</option>
<option value="upper">Uppercase</option>
<option value="lower">Lowercase</option>
</select>
</td>
<td class="p-2 border">
<select
v-model="row.apply_mode"
class="border rounded p-1 w-full"
:disabled="isCompleted"
>
<option value="both">Both</option>
<option value="insert">Insert only</option>
<option value="update">Update only</option>
</select>
</td>
<td class="p-2 border text-center">
<input type="checkbox" v-model="row.skip" :disabled="isCompleted" />
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2">
Mappings saved ({{ mappingSavedCount }}).
</div>
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">
{{ mappingError }}
</div>
</div>
<MappingTable
v-if="!isCompleted && displayRows.length"
:rows="displayRows"
:entity-options="entityOptions"
:is-completed="isCompleted"
:detected="detected"
:detected-note="detectedNote"
:duplicate-targets="duplicateTargets"
:missing-critical="missingCritical"
:mapping-saved="mappingSaved"
:mapping-saved-count="mappingSavedCount"
:mapping-error="mappingError"
:fields-for-entity="fieldsForEntity"
/>
<div v-else-if="!isCompleted" class="pt-4">
<h3 class="font-semibold mb-2">Detected Columns</h3>
@@ -1009,88 +1117,55 @@ async function fetchEvents() {
</p>
</div>
<div v-if="processResult" class="pt-4">
<h3 class="font-semibold mb-2">Import Result</h3>
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{
processResult
}}</pre>
</div>
<ProcessResult :result="processResult" />
<div class="pt-4">
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">Logs</h3>
<div class="flex items-center gap-2 text-sm">
<label class="text-gray-600">Show</label>
<select
v-model.number="eventsLimit"
class="border rounded p-1"
@change="fetchEvents"
>
<option :value="50">50</option>
<option :value="100">100</option>
<option :value="200">200</option>
<option :value="500">500</option>
</select>
<button
@click.prevent="fetchEvents"
class="px-2 py-1 border rounded text-sm"
:disabled="loadingEvents"
>
{{ loadingEvents ? "Refreshing…" : "Refresh" }}
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full border bg-white text-sm">
<thead>
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
<th class="p-2 border">Time</th>
<th class="p-2 border">Level</th>
<th class="p-2 border">Event</th>
<th class="p-2 border">Message</th>
<th class="p-2 border">Row</th>
</tr>
</thead>
<tbody>
<tr v-for="ev in events" :key="ev.id" class="border-t">
<td class="p-2 border whitespace-nowrap">
{{ new Date(ev.created_at).toLocaleString() }}
</td>
<td class="p-2 border">
<span
:class="[
'px-2 py-0.5 rounded text-xs',
ev.level === 'error'
? 'bg-red-100 text-red-800'
: ev.level === 'warning'
? 'bg-amber-100 text-amber-800'
: 'bg-gray-100 text-gray-700',
]"
>{{ ev.level }}</span
>
</td>
<td class="p-2 border">{{ ev.event }}</td>
<td class="p-2 border">
<div>{{ ev.message }}</div>
<div v-if="ev.context" class="text-xs text-gray-500">
{{ ev.context }}
</div>
</td>
<td class="p-2 border">{{ ev.import_row_id ?? "—" }}</td>
</tr>
<tr v-if="!events.length">
<td class="p-3 text-center text-gray-500" colspan="5">
No events yet
</td>
</tr>
</tbody>
</table>
</div>
</div>
<LogsTable
:events="events"
:loading="loadingEvents"
:limit="eventsLimit"
@update:limit="(val) => (eventsLimit = val)"
@refresh="fetchEvents"
/>
</div>
</div>
</div>
</AppLayout>
<CsvPreviewModal
:show="showPreview"
:columns="previewColumns"
:rows="previewRows"
:limit="previewLimit"
:loading="previewLoading"
:truncated="previewTruncated"
:has-header="detected.has_header"
@close="showPreview = false"
@change-limit="(val) => (previewLimit = val)"
@refresh="fetchPreview"
/>
<SimulationModal
:show="showPaymentSim"
:rows="paymentSimRows"
:limit="paymentSimLimit"
:loading="paymentSimLoading"
:summary="paymentSimSummary"
:summary-sl="paymentSimSummarySl"
:verbose="paymentSimVerbose"
:entities="paymentSimEntities"
:money-formatter="formatMoney"
@close="showPaymentSim = false"
@change-limit="
(val) => {
paymentSimLimit = val;
}
"
@toggle-verbose="
async () => {
paymentSimVerbose = !paymentSimVerbose;
await fetchSimulation();
}
"
@refresh="fetchSimulation"
/>
</template>
<style scoped></style>