Teren-app/resources/js/Pages/Imports/Import.vue

1361 lines
43 KiB
Vue

<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,
clients: Array,
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({
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);
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("");
const delimiterState = ref({ mode: "auto", custom: "" });
const effectiveDelimiter = computed(() => {
switch (delimiterState.value.mode) {
case "auto":
return null;
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;
}
});
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;
}
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);
// Import options (persisted on Import): show_missing and reactivate
const showMissingEnabled = ref(Boolean(props.import?.show_missing ?? false));
const reactivateEnabled = ref(Boolean(props.import?.reactivate ?? false));
async function saveImportOptions() {
if (!importId.value) return;
try {
await axios.post(
route("imports.options", { import: importId.value }),
{
show_missing: !!showMissingEnabled.value,
// keep existing reactivate value if UI doesn't expose it here
reactivate: !!reactivateEnabled.value,
},
{ headers: { Accept: "application/json" }, withCredentials: true }
);
} catch (e) {
console.error(
"Save import options failed",
e.response?.status || "",
e.response?.data || e
);
}
}
// Missing contracts (post-finish) UI state
const showMissingContracts = ref(false);
const missingContractsLoading = ref(false);
const missingContracts = ref([]);
const contractRefIsKeyref = computed(() => {
return (persistedMappings.value || []).some((m) => {
const tf = String(m?.target_field || "")
.toLowerCase()
.trim();
const am = String(m?.apply_mode || "")
.toLowerCase()
.trim();
return ["contract.reference", "contracts.reference"].includes(tf) && am === "keyref";
});
});
const canShowMissingButton = computed(() => {
return contractRefIsKeyref.value && !!showMissingEnabled.value;
});
async function openMissingContracts() {
if (!importId.value || !contractRefIsKeyref.value) return;
showMissingContracts.value = true;
missingContractsLoading.value = true;
try {
const { data } = await axios.get(
route("imports.missing-contracts", { import: importId.value }),
{
headers: { Accept: "application/json" },
withCredentials: true,
}
);
missingContracts.value = Array.isArray(data?.missing) ? data.missing : [];
} catch (e) {
console.error(
"Missing contracts fetch failed",
e.response?.status || "",
e.response?.data || e
);
missingContracts.value = [];
} finally {
missingContractsLoading.value = false;
}
}
// 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 normalizeOptions(val) {
if (!val) {
return {};
}
if (typeof val === "string") {
try {
const parsed = JSON.parse(val);
return parsed && typeof parsed === "object" ? parsed : {};
} catch (e) {
return {};
}
}
if (typeof val === "object") {
return val;
}
return {};
}
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");
const hasPersistedMappings = computed(() => (persistedMappings.value?.length || 0) > 0);
const canProcess = computed(
() =>
!!importId.value &&
!processing.value &&
hasPersistedMappings.value &&
!isCompleted.value
);
// 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 }))
);
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",
"meta",
],
},
{
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) {
suppressMappingWatch = true;
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;
});
suppressMappingWatch = false;
}
} 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) {
suppressMappingWatch = true;
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;
});
suppressMappingWatch = false;
}
}
}
// 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",
options: {},
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
);
// --- 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 });
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) {
suppressMappingWatch = true;
mappingRows.value = detected.value.columns.map((c, idx) => ({
source_column: c,
entity: "",
field: "",
skip: false,
transform: "trim",
apply_mode: "both",
options: {},
position: idx,
}));
suppressMappingWatch = false;
evaluateMappingSaved();
}
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",
options: normalizeOptions(m.options),
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]));
suppressMappingWatch = true;
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",
options: normalizeOptions(m.options) || r.options || {},
skip: false,
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;
}
}
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:
r.field === "meta"
? {
key: r.options?.key ?? null,
type: r.options?.type ?? null,
}
: 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",
options: {},
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();
// If template already bound when opening page, load template mapping columns
});
// 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 = mappingRows.value.filter(
(r) => r.entity && r.field && !r.skip
).length;
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) {
suppressMappingWatch = true;
mappingRows.value = cols.map((c, idx) => {
return {
source_column: c,
entity: "",
field: "",
skip: false,
transform: "trim",
apply_mode: "both",
options: {},
position: idx,
};
});
suppressMappingWatch = false;
evaluateMappingSaved();
}
}
);
// 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;
}
}
// 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">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="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
>
<span
v-if="showMissingEnabled"
class="text-[10px] px-1 py-0.5 rounded bg-amber-100 text-amber-700 align-middle"
>seznam manjkajočih</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 class="mt-3 flex items-center gap-2">
<button
class="px-3 py-1.5 bg-gray-700 text-white text-xs rounded"
@click.prevent="openPreview"
>
Ogled CSV
</button>
<button
v-if="canShowMissingButton"
class="px-3 py-1.5 bg-indigo-600 text-white text-xs rounded"
@click.prevent="openMissingContracts"
title="Prikaži aktivne pogodbe, ki niso bile prisotne v uvozu (samo keyref)"
>
Ogled manjkajoče
</button>
</div>
</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"
/>
<!-- Import options -->
<div v-if="!isCompleted" class="mt-2 p-3 rounded border bg-gray-50">
<div class="flex items-center gap-3">
<label class="inline-flex items-center text-sm text-gray-700">
<input
type="checkbox"
class="rounded mr-2"
v-model="showMissingEnabled"
@change="saveImportOptions"
/>
<span>Seznam manjkajočih (po končanem uvozu)</span>
</label>
</div>
<p class="mt-1 text-xs text-gray-500">
Ko je omogočeno in je "contract.reference" nastavljen na keyref, bo po
končanem uvozu na voljo gumb za ogled pogodb, ki jih ni v datoteki.
</p>
</div>
<ChecklistSteps :steps="stepStates" :missing-critical="missingCritical" />
</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"
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>
<SavedMappingsTable :mappings="persistedMappings" />
<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>
<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>
<ProcessResult :result="processResult" />
<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"
/>
<!-- Missing contracts modal -->
<Modal
:show="showMissingContracts"
max-width="2xl"
@close="showMissingContracts = false"
>
<div class="p-4 max-h-[70vh] overflow-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-lg">Manjkajoče pogodbe (aktivne, ne-arhivirane)</h3>
<button
class="text-gray-500 hover:text-gray-700"
@click.prevent="showMissingContracts = false"
>
Zapri
</button>
</div>
<div v-if="missingContractsLoading" class="py-8 text-center text-sm text-gray-500">
Nalagam …
</div>
<div v-else>
<div v-if="!missingContracts.length" class="py-6 text-sm text-gray-600">
Ni zadetkov.
</div>
<ul v-else class="divide-y divide-gray-200">
<li
v-for="row in missingContracts"
:key="row.uuid"
class="py-2 text-sm flex items-center justify-between"
>
<div class="min-w-0">
<div class="font-mono text-gray-800">{{ row.reference }}</div>
<div class="text-xs text-gray-500 truncate">
<span class="font-medium text-gray-600">Primer: </span>
<span>{{ row.full_name || "—" }}</span>
<span v-if="row.balance_amount != null" class="ml-2"
>• {{ formatMoney(row.balance_amount) }}</span
>
</div>
</div>
<div class="flex-shrink-0">
<a
:href="route('clientCase.show', { client_case: row.case_uuid })"
class="text-blue-600 hover:underline text-xs"
>Odpri primer</a
>
</div>
</li>
</ul>
</div>
</div>
</Modal>
<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>