Teren-app/resources/js/Pages/Imports/Import.vue
2025-09-29 17:35:54 +02:00

879 lines
36 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(Boolean(props.import?.import_template_id));
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);
// 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) => {
const guess = guessMappingForHeader(c);
return {
source_column: c,
entity: guess?.entity || '',
field: guess?.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, '');
}
function guessMappingForHeader(h) {
const key = normalizeHeader(h);
// Confident matches only; avoid overly generic keys like 'name'
const dict = {
// Person
firstname: { entity: 'person', field: 'first_name' },
givenname: { entity: 'person', field: 'first_name' },
forename: { entity: 'person', field: 'first_name' },
lastname: { entity: 'person', field: 'last_name' },
surname: { entity: 'person', field: 'last_name' },
familyname: { entity: 'person', field: 'last_name' },
fullname: { entity: 'person', field: 'full_name' },
taxnumber: { entity: 'person', field: 'tax_number' },
taxid: { entity: 'person', field: 'tax_number' },
tin: { entity: 'person', field: 'tax_number' },
socialsecuritynumber: { entity: 'person', field: 'social_security_number' },
ssn: { entity: 'person', field: 'social_security_number' },
birthday: { entity: 'person', field: 'birthday' },
birthdate: { entity: 'person', field: 'birthday' },
dob: { entity: 'person', field: 'birthday' },
gender: { entity: 'person', field: 'gender' },
description: { entity: 'person', field: 'description' },
// Email
email: { entity: 'emails', field: 'value' },
emailaddress: { entity: 'emails', field: 'value' },
// Phone
phone: { entity: 'person_phones', field: 'nu' },
phonenumber: { entity: 'person_phones', field: 'nu' },
mobile: { entity: 'person_phones', field: 'nu' },
gsm: { entity: 'person_phones', field: 'nu' },
telephone: { entity: 'person_phones', field: 'nu' },
countrycode: { entity: 'person_phones', field: 'country_code' },
// Address
address: { entity: 'person_addresses', field: 'address' },
street: { entity: 'person_addresses', field: 'address' },
country: { entity: 'person_addresses', field: 'country' },
// Accounts
accountreference: { entity: 'accounts', field: 'reference' },
accountref: { entity: 'accounts', field: 'reference' },
balancedue: { entity: 'accounts', field: 'balance_amount' },
balance: { entity: 'accounts', field: 'balance_amount' },
amount: { entity: 'accounts', field: 'balance_amount' },
// Contracts
reference: { entity: 'contracts', field: 'reference' },
ref: { entity: 'contracts', field: 'reference' },
contractreference: { entity: 'contracts', field: 'reference' },
contractref: { entity: 'contracts', field: 'reference' },
contract: { entity: 'contracts', field: 'reference' },
contractno: { entity: 'contracts', field: 'reference' },
contractnum: { entity: 'contracts', field: 'reference' },
contractnumber: { entity: 'contracts', field: 'reference' },
agreement: { entity: 'contracts', field: 'reference' },
agreementno: { entity: 'contracts', field: 'reference' },
agreementnum: { entity: 'contracts', field: 'reference' },
agreementnumber: { entity: 'contracts', field: 'reference' },
startdate: { entity: 'contracts', field: 'start_date' },
enddate: { entity: 'contracts', field: 'end_date' },
// Slovenian/common localized headers (normalized)
ime: { entity: 'person', field: 'first_name' },
priimek: { entity: 'person', field: 'last_name' },
imeinpriimek: { entity: 'person', field: 'full_name' },
polnoime: { entity: 'person', field: 'full_name' },
davcna: { entity: 'person', field: 'tax_number' },
davcnastevilka: { entity: 'person', field: 'tax_number' },
emso: { entity: 'person', field: 'social_security_number' },
rojstnidatum: { entity: 'person', field: 'birthday' },
spol: { entity: 'person', field: 'gender' },
opis: { entity: 'person', field: 'description' },
eposta: { entity: 'emails', field: 'value' },
elektronskaposta: { entity: 'emails', field: 'value' },
telefon: { entity: 'person_phones', field: 'nu' },
mobilni: { entity: 'person_phones', field: 'nu' },
gsm: { entity: 'person_phones', field: 'nu' },
klicna: { entity: 'person_phones', field: 'country_code' },
drzava: { entity: 'person_addresses', field: 'country' },
naslov: { entity: 'person_addresses', field: 'address' },
ulica: { entity: 'person_addresses', field: 'address' },
sklic: { entity: 'accounts', field: 'reference' },
referenca: { entity: 'accounts', field: 'reference' },
saldo: { entity: 'accounts', field: 'balance_amount' },
znesek: { entity: 'accounts', field: 'balance_amount' },
pogodbasklic: { entity: 'contracts', field: 'reference' },
pogodbastevilka: { entity: 'contracts', field: 'reference' },
pogodba: { entity: 'contracts', field: 'reference' },
pogodbast: { entity: 'contracts', field: 'reference' },
zacetek: { entity: 'contracts', field: 'start_date' },
konec: { entity: 'contracts', field: 'end_date' },
};
return dict[key] || 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 used by the mapping grid
const entityOptions = [
{ value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Address' },
{ value: 'person_phones', label: 'Person Phone' },
{ value: 'emails', label: 'Email' },
{ value: 'accounts', label: 'Account' },
{ value: 'contracts', label: 'Contract' },
];
const fieldOptionsByEntity = {
person: [
{ value: 'first_name', label: 'First name' },
{ value: 'last_name', label: 'Last name' },
{ value: 'full_name', label: 'Full name' },
{ value: 'tax_number', label: 'Tax number' },
{ value: 'social_security_number', label: 'SSN' },
{ value: 'birthday', label: 'Birthday' },
{ value: 'gender', label: 'Gender' },
{ value: 'description', label: 'Description' },
],
person_addresses: [
{ value: 'address', label: 'Address' },
{ value: 'country', label: 'Country' },
{ value: 'type_id', label: 'Address Type Id' },
{ value: 'description', label: 'Description' },
],
person_phones: [
{ value: 'nu', label: 'Phone number' },
{ value: 'country_code', label: 'Country code' },
{ value: 'type_id', label: 'Phone Type Id' },
{ value: 'description', label: 'Description' },
],
emails: [
{ value: 'value', label: 'Email address' },
{ value: 'label', label: 'Label' },
{ value: 'is_primary', label: 'Is primary' },
],
accounts: [
{ value: 'reference', label: 'Reference' },
{ value: 'description', label: 'Description' },
{ value: 'contract_id', label: 'Contract Id' },
{ value: 'contract_reference', label: 'Contract Reference' },
{ value: 'type_id', label: 'Account Type Id' },
{ value: 'active', label: 'Active' },
{ value: 'balance_amount', label: 'Balance Amount' },
],
contracts: [
{ value: 'reference', label: 'Reference' },
{ value: 'start_date', label: 'Start Date' },
{ value: 'end_date', label: 'End Date' },
{ value: 'description', label: 'Description' },
{ value: 'client_case_id', label: 'Client Case Id' },
{ value: 'type_id', label: 'Contract Type Id' },
],
};
// 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,
};
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) => {
const guess = guessMappingForHeader(c);
return {
source_column: c,
entity: guess?.entity || '',
field: guess?.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 {
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,
});
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
function entityKeyToRecord(key) {
if (key === 'person_addresses') return 'address';
if (key === 'person_phones') return 'phone';
if (key === 'emails') return 'email';
if (key === 'accounts') return 'account';
if (key === 'contracts') return 'contract';
return 'person';
}
function recordToEntityKey(record) {
switch (record) {
case 'person':
return 'person';
case 'address':
case 'person_address':
case 'person_addresses':
return 'person_addresses';
case 'phone':
case 'person_phone':
case 'person_phones':
return 'person_phones';
case 'email':
case 'emails':
return 'emails';
case 'account':
case 'accounts':
return 'accounts';
case 'contract':
case 'contracts':
return 'contracts';
default:
return '';
}
}
// Initial load
onMounted(async () => {
// Build mapping grid from existing meta columns if present
if (detected.value.columns?.length) {
mappingRows.value = (detected.value.columns || []).map((c, idx) => {
const guess = guessMappingForHeader(c);
return {
source_column: c,
entity: guess?.entity || '',
field: guess?.field || '',
skip: false,
transform: 'trim',
apply_mode: 'both',
position: idx,
};
});
await loadImportMappings();
} else {
await fetchColumns();
}
// 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) => {
const guess = guessMappingForHeader(c);
return {
source_column: c,
entity: guess?.entity || '',
field: guess?.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>
Template:
<strong>{{ selectedTemplateOption?.name || '—' }}</strong>
<span v-if="templateApplied" class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 align-middle">locked</span>
</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="templateApplied"
>
<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 || templateApplied"
class="px-4 py-2 bg-emerald-600 disabled:bg-gray-300 text-white rounded"
>
{{ templateApplied ? 'Template Applied' : '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 fieldOptionsByEntity[row.entity] || []" :key="f.value" :value="f.value">{{ f.label }}</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>