Teren-app/resources/js/Pages/Imports/Import.vue
2025-09-30 00:06:47 +02:00

1097 lines
37 KiB
Vue

<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { ref, computed, onMounted, watch } from "vue";
import Multiselect from "vue-multiselect";
import axios from "axios";
const props = defineProps({
import: Object,
templates: Array,
clients: Array,
client: Object,
});
const importId = ref(props.import?.id || null);
const hasHeader = ref(Boolean(props.import?.meta?.has_header ?? true));
const detected = ref({
columns: props.import?.meta?.columns || [],
delimiter: props.import?.meta?.detected_delimiter || ",",
has_header: hasHeader.value,
});
const templateApplied = ref(false);
const processing = ref(false);
const processResult = ref(null);
const mappingRows = ref([]);
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 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
case "comma":
return ",";
case "semicolon":
return ";";
case "tab":
return "\t";
case "pipe":
return "|";
case "space":
return " ";
case "custom":
return delimiterState.value.custom || null;
default:
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" };
const mode = map[initForced] || "custom";
delimiterState.value.mode = mode;
if (mode === "custom") delimiterState.value.custom = initForced;
}
// Logs
const events = ref([]);
const eventsLimit = ref(200);
const loadingEvents = ref(false);
// Completed status helper
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(
() =>
!!importId.value &&
!processing.value &&
hasPersistedMappings.value &&
!isCompleted.value
);
// Dynamic entity definitions and options fetched from API
const entityDefs = ref([]); // [{ key, label, canonical_root, fields: [] }]
const usingEntityFallback = ref(false);
const entityOptions = computed(() =>
entityDefs.value.map((e) => ({ value: e.key, label: e.label || e.key }))
);
const fieldOptionsByEntity = computed(() =>
Object.fromEntries(
entityDefs.value.map((e) => [e.key, Array.isArray(e.fields) ? e.fields : []])
)
);
const canonicalRootByKey = computed(() =>
Object.fromEntries(entityDefs.value.map((e) => [e.key, e.canonical_root || e.key]))
);
const keyByCanonicalRoot = computed(() => {
const m = {};
for (const e of entityDefs.value) {
if (e.canonical_root) {
m[e.canonical_root] = e.key;
}
}
return m;
});
// Provide fields for either a UI key (e.g. "contracts") or a canonical root (e.g. "contract")
function fieldsForEntity(entityOrCanonical) {
if (!entityOrCanonical) return [];
// Try direct key first
const direct = fieldOptionsByEntity.value[entityOrCanonical];
if (Array.isArray(direct)) return direct;
// If a canonical root was stored before entityDefs loaded, map it now
const byKey = keyByCanonicalRoot.value[entityOrCanonical];
if (byKey) {
const via = fieldOptionsByEntity.value[byKey];
if (Array.isArray(via)) return via;
}
return [];
}
function defaultEntityDefs() {
return [
{
key: "person",
label: "Person",
canonical_root: "person",
fields: [
"first_name",
"last_name",
"full_name",
"gender",
"birthday",
"tax_number",
"social_security_number",
"description",
],
},
{
key: "person_addresses",
label: "Person Addresses",
canonical_root: "address",
fields: ["address", "country", "type_id", "description"],
},
{
key: "person_phones",
label: "Person Phones",
canonical_root: "phone",
fields: ["nu", "country_code", "type_id", "description"],
},
{
key: "emails",
label: "Emails",
canonical_root: "email",
fields: ["value", "is_primary", "label"],
},
{
key: "contracts",
label: "Contracts",
canonical_root: "contract",
fields: [
"reference",
"start_date",
"end_date",
"description",
"type_id",
"client_case_id",
],
},
{
key: "accounts",
label: "Accounts",
canonical_root: "account",
fields: [
"reference",
"balance_amount",
"contract_id",
"contract_reference",
"type_id",
"active",
"description",
],
},
];
}
async function loadEntityDefs() {
try {
const { data } = await axios.get("/api/import-entities");
const items = Array.isArray(data?.entities) ? data.entities : [];
// Normalize fields to arrays and ensure labels
const normalized = items.map((e) => ({
key: e.key,
label: e.label || e.key,
canonical_root: e.canonical_root || e.key,
fields: Array.isArray(e.fields) ? e.fields : [],
}));
if (!normalized.length) {
usingEntityFallback.value = true;
entityDefs.value = defaultEntityDefs();
} else {
usingEntityFallback.value = false;
entityDefs.value = normalized;
}
// Normalize any existing mapping row entity values to UI keys if they are canonical roots
if (Array.isArray(mappingRows.value) && mappingRows.value.length) {
const mapCanonToKey = keyByCanonicalRoot.value;
mappingRows.value = mappingRows.value.map((r) => {
const current = r.entity;
if (current && !entityDefs.value.find((e) => e.key === current)) {
const maybeKey = mapCanonToKey[current];
if (maybeKey) {
return { ...r, entity: maybeKey };
}
}
return r;
});
}
} catch (e) {
console.error("Failed to load import entity definitions", e);
usingEntityFallback.value = true;
entityDefs.value = defaultEntityDefs();
// Also normalize with fallback
if (Array.isArray(mappingRows.value) && mappingRows.value.length) {
const mapCanonToKey = keyByCanonicalRoot.value;
mappingRows.value = mappingRows.value.map((r) => {
const current = r.entity;
if (current && !entityDefs.value.find((e) => e.key === current)) {
const maybeKey = mapCanonToKey[current];
if (maybeKey) {
return { ...r, entity: maybeKey };
}
}
return r;
});
}
}
}
// Display rows used by the table: prefer mappingRows if present; otherwise fall back to detected columns
const displayRows = computed(() => {
if (Array.isArray(mappingRows.value) && mappingRows.value.length > 0) {
return mappingRows.value;
}
const cols = detected.value?.columns || [];
return cols.map((c, idx) => ({
source_column: c,
entity: "",
field: "",
skip: false,
transform: "trim",
apply_mode: "both",
position: idx,
}));
});
// Header normalization and guess mapping for auto-assigning sensible defaults
function stripDiacritics(s) {
if (!s) return "";
return String(s)
.replace(/[čć]/gi, "c")
.replace(/[š]/gi, "s")
.replace(/[ž]/gi, "z")
.replace(/[đ]/gi, "d")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
function normalizeHeader(h) {
if (!h) return "";
const base = stripDiacritics(String(h)).toLowerCase();
return base
.replace(/[^a-z0-9]+/g, " ")
.trim()
.replace(/\s+/g, "");
}
// Frontend auto-guessing disabled: entity/field will remain blank unless loaded from persisted mappings or set by the user
function guessMappingForHeader(h) {
return null;
}
// Normalize source column to match persisted mappings in a case/space/diacritic-insensitive way
function normalizeSource(s) {
return normalizeHeader(s);
}
// Entity and field options are provided dynamically by API via entityOptions and fieldOptionsByEntity
// Local state for selects
const form = ref({
client_uuid: props.client.uuid,
import_template_id: props.import?.import_template_id || null,
});
// Initialize client_uuid from import.client_uuid (preferred) using provided clients list
if (props.import?.client_uuid) {
const found = (props.clients || []).find((c) => c.uuid === props.import.client_uuid);
form.value.client_uuid = found ? found.uuid : null;
}
const selectedClientOption = computed({
get() {
const cuuid = form.value.client_uuid;
if (!cuuid) return null;
return (props.clients || []).find((c) => c.uuid === cuuid) || null;
},
set(val) {
form.value.client_uuid = val ? val.uuid : null;
},
});
const selectedTemplateOption = computed({
get() {
const tid = form.value.import_template_id;
if (tid == null) return null;
return (props.templates || []).find((t) => t.id === tid) || null;
},
set(val) {
form.value.import_template_id = val ? val.id : null;
},
});
// Show only global templates when no client is selected.
// When a client is selected, show only that client's templates (strict match by client_uuid, no globals).
const filteredTemplates = computed(() => {
const cuuid = form.value.client_uuid;
const list = props.templates || [];
if (!cuuid) {
return list.filter((t) => t.client_id == null);
}
return list.filter((t) => t.client_uuid && t.client_uuid === cuuid);
});
const selectedMappingsCount = computed(
() => mappingRows.value.filter((r) => !r.skip && r.entity && r.field).length
);
async function fetchColumns() {
if (!importId.value) return;
const url = route("imports.columns", { import: importId.value });
const params = { has_header: hasHeader.value ? 1 : 0 };
if (effectiveDelimiter.value) {
params.delimiter = effectiveDelimiter.value;
}
const { data } = await axios.get(url, { params });
// Normalize columns to strings for consistent rendering
const colsRaw = Array.isArray(data.columns) ? data.columns : [];
const normCols = colsRaw
.map((c) => {
if (typeof c === "string" || typeof c === "number") return String(c);
if (c && typeof c === "object") {
return String(c.name ?? c.header ?? c.label ?? Object.values(c)[0] ?? "");
}
return "";
})
.filter(Boolean);
detected.value = {
columns: normCols,
delimiter: data.detected_delimiter || ",",
has_header: !!data.has_header,
};
console.table(colsRaw, normCols);
detectedNote.value = data.note || "";
// initialize mapping rows if empty
if (!mappingRows.value.length && detected.value.columns.length) {
mappingRows.value = detected.value.columns.map((c, idx) => ({
source_column: c,
entity: "",
field: "",
skip: false,
transform: "trim",
apply_mode: "both",
position: idx,
}));
}
await loadImportMappings();
// Fallback: if no detected columns were found, but persisted mappings exist, use them to render the grid
if (
(!detected.value.columns || detected.value.columns.length === 0) &&
mappingRows.value.length === 0 &&
persistedMappings.value.length > 0
) {
mappingRows.value = persistedMappings.value.map((m, idx) => {
const tf = String(m.target_field || "");
const [record, field] = tf ? tf.split(".", 2) : ["", ""];
return {
source_column: m.source_column,
entity: recordToEntityKey(record),
field: field || "",
skip: false,
transform: m.transform || "trim",
apply_mode: m.apply_mode || "both",
position: idx,
};
});
}
}
async function applyTemplateToImport() {
if (!importId.value || !form.value.import_template_id) return;
try {
if (templateApplied.value) {
const ok = window.confirm(
'Re-apply this template? This will overwrite current mappings for this import.'
);
if (!ok) {
return;
}
}
await axios.post(
route("importTemplates.apply", {
template: form.value.import_template_id,
import: importId.value,
}),
{},
{
headers: { Accept: "application/json" },
withCredentials: true,
}
);
templateApplied.value = true;
// If template has a default delimiter, adopt it and refetch columns
const tpl = selectedTemplateOption.value;
const tplDelim = tpl?.delimiter || tpl?.meta?.delimiter || null;
if (tplDelim) {
// map to known mode if possible, else set custom
const map = {
",": "comma",
";": "semicolon",
"\t": "tab",
"|": "pipe",
" ": "space",
};
const mode = map[tplDelim] || "custom";
delimiterState.value.mode = mode;
if (mode === "custom") delimiterState.value.custom = tplDelim;
await fetchColumns();
}
await loadImportMappings();
} catch (e) {
templateApplied.value = false;
console.error(
"Apply template error",
e.response?.status || "",
e.response?.data || e
);
}
}
async function loadImportMappings() {
if (!importId.value) return;
try {
const { data } = await axios.get(
route("imports.mappings.get", { import: importId.value }),
{
headers: { Accept: "application/json" },
withCredentials: true,
}
);
console.log(data);
const rows = Array.isArray(data?.mappings) ? data.mappings : [];
// Store raw persisted mappings for display regardless of detected columns
persistedMappings.value = rows.slice();
if (!rows.length) return;
const bySource = new Map(rows.map((r) => [normalizeSource(r.source_column), r]));
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
const m = bySource.get(normalizeSource(r.source_column));
if (!m) return r;
const tf = String(m.target_field || "");
let entity = m.entity || "";
let field = r.field || "";
if (tf) {
const [record, fld] = tf.split(".", 2);
const inferred = recordToEntityKey(record);
if (!entity) entity = inferred;
if (fld) field = fld;
}
return {
...r,
entity,
field,
transform: m.transform || "",
apply_mode: m.apply_mode || "both",
skip: false,
position: idx,
};
});
} catch (e) {
console.error(
"Load import mappings error",
e.response?.status || "",
e.response?.data || e
);
}
}
async function saveMappings() {
if (!importId.value) return;
mappingError.value = "";
const mappings = mappingRows.value
.filter((r) => !r.skip && r.entity && r.field)
.map((r) => ({
source_column: r.source_column,
entity: r.entity || null,
target_field: `${entityKeyToRecord(r.entity)}.${r.field}`,
transform: r.transform || null,
apply_mode: r.apply_mode || "both",
options: null,
}));
if (!mappings.length) {
mappingSaved.value = false;
mappingError.value =
"Select entity and field for at least one column (or uncheck Skip) before saving.";
return;
}
try {
savingMappings.value = true;
const url = route("imports.mappings.save", { import: importId.value });
const { data } = await axios.post(
url,
{ mappings },
{
headers: { Accept: "application/json" },
withCredentials: true,
}
);
mappingSaved.value = true;
mappingSavedCount.value = Number(data?.saved || mappings.length);
mappingError.value = "";
// Refresh persisted mappings so Process gating reflects the actual DB state
await loadImportMappings();
} catch (e) {
mappingSaved.value = false;
console.error("Save mappings error", e.response?.status || "", e.response?.data || e);
} finally {
savingMappings.value = false;
}
}
async function processImport() {
if (!importId.value) return;
processing.value = true;
processResult.value = null;
try {
const { data } = await axios.post(
route("imports.process", { import: importId.value }),
{},
{
headers: { Accept: "application/json" },
withCredentials: true,
}
);
processResult.value = data;
} catch (e) {
console.error(
"Process import error",
e.response?.status || "",
e.response?.data || e
);
processResult.value = { error: "Processing failed" };
} finally {
processing.value = false;
}
}
// Helpers using canonical roots from API
function entityKeyToRecord(key) {
return canonicalRootByKey.value[key] || key;
}
function recordToEntityKey(record) {
return keyByCanonicalRoot.value[record] || record;
}
// Initial load
onMounted(async () => {
await loadEntityDefs();
// Build mapping grid from existing meta columns if present
if (detected.value.columns?.length) {
mappingRows.value = (detected.value.columns || [])
// keep only defined, non-empty values (after trimming)
.filter((c) => c !== undefined && c !== null && String(c).trim() !== "")
.map((c, idx) => {
return {
source_column: c,
entity: "",
field: "",
skip: false,
transform: "trim",
apply_mode: "both",
position: idx,
};
});
await loadImportMappings();
console.log(mappingRows.value);
} else {
await fetchColumns();
}
// Auto-apply template mapping once if a template is selected and not already applied
console.log(templateApplied.value);
try {
if (!templateApplied.value && form.value.import_template_id && importId.value) {
await applyTemplateToImport();
}
} catch (e) {
console.warn('Auto apply template failed', e);
}
// Load recent events (logs)
await fetchEvents();
});
// Reset saved flag whenever user edits mappings
watch(
mappingRows,
() => {
mappingSaved.value = false;
mappingSavedCount.value = 0;
mappingError.value = "";
},
{ deep: true }
);
// If detected columns are loaded after mount, initialize mapping rows once
watch(
() => detected.value.columns,
(cols) => {
if (Array.isArray(cols) && cols.length > 0 && mappingRows.value.length === 0) {
mappingRows.value = cols.map((c, idx) => {
return {
source_column: c,
entity: "",
field: "",
skip: false,
transform: "trim",
apply_mode: "both",
position: idx,
};
});
}
}
);
// If user changes delimiter selection, refresh detected columns
watch(
() => delimiterState.value,
async () => {
if (importId.value) {
await fetchColumns();
}
},
{ deep: true }
);
async function fetchEvents() {
if (!importId.value) return;
loadingEvents.value = true;
try {
const { data } = await axios.get(
route("imports.events", { import: importId.value }),
{
params: { limit: eventsLimit.value },
headers: { Accept: "application/json" },
withCredentials: true,
}
);
events.value = Array.isArray(data?.events) ? data.events : [];
} catch (e) {
console.error(
"Load import events error",
e.response?.status || "",
e.response?.data || e
);
} finally {
loadingEvents.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>
</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>
</div>
</div>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
<div v-if="isCompleted" class="p-3 border rounded bg-gray-50 text-sm">
<div class="flex flex-wrap gap-x-6 gap-y-1">
<div>
<span class="text-gray-600">Status:</span>
<span class="font-medium text-emerald-700">Completed</span>
</div>
<div>
<span class="text-gray-600">Finished:</span>
<span class="font-medium">{{
props.import?.finished_at
? new Date(props.import.finished_at).toLocaleString()
: "—"
}}</span>
</div>
<div>
<span class="text-gray-600">Total:</span>
<span class="font-medium">{{ props.import?.total_rows ?? "—" }}</span>
</div>
<div>
<span class="text-gray-600">Imported:</span>
<span class="font-medium">{{ props.import?.imported_rows ?? "—" }}</span>
</div>
<div>
<span class="text-gray-600">Invalid:</span>
<span class="font-medium">{{ props.import?.invalid_rows ?? "—" }}</span>
</div>
<div>
<span class="text-gray-600">Valid:</span>
<span class="font-medium">{{ props.import?.valid_rows ?? "—" }}</span>
</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>
<!-- 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>
<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"
v-else-if="importId && !hasPersistedMappings && !isCompleted"
>
Apply a template or select Entity and Field for one or more columns, then
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>
<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>
<div v-else-if="!isCompleted" class="pt-4">
<h3 class="font-semibold mb-2">Detected Columns</h3>
<p class="text-sm text-gray-600">
No columns detected.
{{
detectedNote ||
"Preview is available for CSV/TXT files. You can still apply a template or use the saved mappings below."
}}
</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>
<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>
</div>
</div>
</div>
</AppLayout>
</template>
<style scoped></style>