Mass changes
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user