1468 lines
47 KiB
Vue
1468 lines
47 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 { router } from "@inertiajs/vue3";
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Unresolved keyref rows (contracts not found) UI state
|
|
const showUnresolved = ref(false);
|
|
const unresolvedLoading = ref(false);
|
|
const unresolvedColumns = ref([]);
|
|
const unresolvedRows = ref([]); // [{id,row_number,values:[]}]
|
|
async function openUnresolved() {
|
|
if (!importId.value || !contractRefIsKeyref.value) return;
|
|
showUnresolved.value = true;
|
|
unresolvedLoading.value = true;
|
|
try {
|
|
const { data } = await axios.get(
|
|
route("imports.missing-keyref-rows", { import: importId.value }),
|
|
{ headers: { Accept: "application/json" }, withCredentials: true }
|
|
);
|
|
unresolvedColumns.value = Array.isArray(data?.columns) ? data.columns : [];
|
|
unresolvedRows.value = Array.isArray(data?.rows) ? data.rows : [];
|
|
} catch (e) {
|
|
console.error(
|
|
"Unresolved keyref rows fetch failed",
|
|
e.response?.status || "",
|
|
e.response?.data || e
|
|
);
|
|
unresolvedColumns.value = [];
|
|
unresolvedRows.value = [];
|
|
} finally {
|
|
unresolvedLoading.value = false;
|
|
}
|
|
}
|
|
function downloadUnresolvedCsv() {
|
|
if (!importId.value) return;
|
|
// Direct download
|
|
window.location.href = route("imports.missing-keyref-csv", { import: importId.value });
|
|
}
|
|
|
|
// 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;
|
|
// Immediately refresh the page props to reflect the completed state
|
|
// Reload only the 'import' prop to minimize payload; don't preserve state so UI reflects new status
|
|
router.reload({ only: ["import"], preserveScroll: true, preserveState: false });
|
|
} 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>
|
|
<button
|
|
v-if="isCompleted && contractRefIsKeyref"
|
|
class="px-3 py-1.5 bg-amber-600 text-white text-xs rounded"
|
|
@click.prevent="openUnresolved"
|
|
title="Prikaži vrstice, kjer pogodba (keyref) ni bila najdena"
|
|
>
|
|
Neobstoječi
|
|
</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>
|
|
|
|
<!-- Unresolved keyref rows modal -->
|
|
<Modal :show="showUnresolved" max-width="5xl" @close="showUnresolved = false">
|
|
<div class="p-4 max-h-[75vh] overflow-auto">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="font-semibold text-lg">
|
|
Vrstice z neobstoječim contract.reference (KEYREF)
|
|
</h3>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
class="px-3 py-1.5 bg-green-600 text-white text-xs rounded"
|
|
@click.prevent="downloadUnresolvedCsv"
|
|
>
|
|
Prenesi CSV
|
|
</button>
|
|
<button
|
|
class="text-gray-500 hover:text-gray-700"
|
|
@click.prevent="showUnresolved = false"
|
|
>
|
|
Zapri
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-if="unresolvedLoading" class="py-8 text-center text-sm text-gray-500">
|
|
Nalagam …
|
|
</div>
|
|
<div v-else>
|
|
<div v-if="!unresolvedRows.length" class="py-6 text-sm text-gray-600">
|
|
Ni zadetkov.
|
|
</div>
|
|
<div v-else class="overflow-auto border border-gray-200 rounded">
|
|
<table class="min-w-full text-sm">
|
|
<thead class="bg-gray-50 text-gray-700">
|
|
<tr>
|
|
<th class="px-3 py-2 text-left w-24"># vrstica</th>
|
|
<th
|
|
v-for="(c, i) in unresolvedColumns"
|
|
:key="i"
|
|
class="px-3 py-2 text-left"
|
|
>
|
|
{{ c }}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="r in unresolvedRows" :key="r.id" class="border-t">
|
|
<td class="px-3 py-2 text-gray-500">{{ r.row_number }}</td>
|
|
<td
|
|
v-for="(c, i) in unresolvedColumns"
|
|
:key="i"
|
|
class="px-3 py-2 whitespace-pre-wrap break-words"
|
|
>
|
|
{{ r.values?.[i] ?? "" }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</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>
|