Mass changes
This commit is contained in:
@@ -1,647 +1,268 @@
|
||||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { ref, watch, computed, onMounted } from "vue";
|
||||
import { useForm, router } from "@inertiajs/vue3";
|
||||
import { ref, computed } from "vue";
|
||||
import Multiselect from "vue-multiselect";
|
||||
import axios from "axios";
|
||||
|
||||
// Props: provided by controller (clients + templates collections)
|
||||
const props = defineProps({
|
||||
templates: Array,
|
||||
clients: Array,
|
||||
});
|
||||
|
||||
const hasHeader = ref(true);
|
||||
const detected = ref({ columns: [], delimiter: ",", has_header: true });
|
||||
const importId = ref(null);
|
||||
const templateApplied = ref(false);
|
||||
const processing = ref(false);
|
||||
const processResult = ref(null);
|
||||
const mappingRows = ref([]);
|
||||
const mappingSaved = ref(false);
|
||||
const mappingSavedCount = ref(0);
|
||||
const selectedMappingsCount = computed(
|
||||
() => mappingRows.value.filter((r) => !r.skip && r.entity && r.field).length
|
||||
);
|
||||
const mappingError = ref("");
|
||||
const savingMappings = ref(false);
|
||||
|
||||
// Dynamic entity definitions and suggestions from API
|
||||
const entityDefs = ref([]);
|
||||
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,
|
||||
(e.fields || []).map((f) => ({ value: f, label: f })),
|
||||
])
|
||||
)
|
||||
);
|
||||
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;
|
||||
});
|
||||
const suggestions = ref({});
|
||||
async function loadEntityDefs() {
|
||||
try {
|
||||
const { data } = await axios.get("/api/import-entities");
|
||||
entityDefs.value = data?.entities || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load import entity definitions", e);
|
||||
}
|
||||
}
|
||||
async function refreshSuggestions(columns) {
|
||||
const cols = Array.isArray(columns) ? columns : detected.value.columns || [];
|
||||
if (!cols || cols.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// When a template is chosen and provides meta.entities, limit suggestions to those entities
|
||||
const only = (selectedTemplate.value?.meta?.entities || []);
|
||||
const { data } = await axios.post("/api/import-entities/suggest", { columns: cols, only_entities: only });
|
||||
suggestions.value = { ...suggestions.value, ...(data?.suggestions || {}) };
|
||||
} catch (e) {
|
||||
console.error("Failed to load suggestions", e);
|
||||
}
|
||||
}
|
||||
|
||||
function applySuggestionToRow(row) {
|
||||
const s = suggestions.value[row.source_column];
|
||||
if (!s) return false;
|
||||
if (!fieldOptionsByEntity.value[s.entity]) return false;
|
||||
row.entity = s.entity;
|
||||
row.field = s.field;
|
||||
// default transform on if missing
|
||||
if (!row.transform) {
|
||||
row.transform = "trim";
|
||||
}
|
||||
if (!row.apply_mode) {
|
||||
row.apply_mode = "both";
|
||||
}
|
||||
row.skip = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Basic create form (rest of workflow handled on the Continue page)
|
||||
const form = useForm({
|
||||
client_uuid: null,
|
||||
import_template_id: null,
|
||||
source_type: null,
|
||||
sheet_name: null,
|
||||
has_header: true,
|
||||
file: null,
|
||||
});
|
||||
|
||||
// Bridge Multiselect (expects option objects) to our form (stores client_uuid as string)
|
||||
// Multiselect bridge: client
|
||||
const selectedClientOption = computed({
|
||||
get() {
|
||||
const cuuid = form.client_uuid;
|
||||
if (!cuuid) return null;
|
||||
return (props.clients || []).find((c) => c.uuid === cuuid) || null;
|
||||
if (!form.client_uuid) return null;
|
||||
return (props.clients || []).find((c) => c.uuid === form.client_uuid) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.client_uuid = val ? val.uuid : null;
|
||||
},
|
||||
});
|
||||
|
||||
// Bridge Template Multiselect to store only template id (number) in form
|
||||
// Multiselect bridge: template
|
||||
const selectedTemplateOption = computed({
|
||||
get() {
|
||||
const tid = form.import_template_id;
|
||||
if (tid == null) return null;
|
||||
return (props.templates || []).find((t) => t.id === tid) || null;
|
||||
if (form.import_template_id == null) return null;
|
||||
return (props.templates || []).find((t) => t.id === form.import_template_id) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.import_template_id = val ? val.id : null;
|
||||
},
|
||||
});
|
||||
|
||||
// Helper: selected client's numeric id (fallback)
|
||||
const selectedClientId = computed(() => {
|
||||
const cuuid = form.client_uuid;
|
||||
if (!cuuid) return null;
|
||||
const c = (props.clients || []).find((x) => x.uuid === cuuid);
|
||||
return c ? c.id : null;
|
||||
});
|
||||
|
||||
// Show only global templates when no client is selected.
|
||||
// When a client is selected, show only that client's templates (match by client_uuid).
|
||||
// Filter templates: show globals when no client; when client selected show only that client's templates (no mixing to avoid confusion)
|
||||
const filteredTemplates = computed(() => {
|
||||
const cuuid = form.client_uuid;
|
||||
const list = props.templates || [];
|
||||
if (!cuuid) {
|
||||
return list.filter((t) => t.client_id == null);
|
||||
}
|
||||
// When client is selected, only show that client's templates (no globals)
|
||||
return list.filter(
|
||||
(t) => (t.client_uuid && t.client_uuid === cuuid) || t.client_id == null
|
||||
);
|
||||
return list.filter((t) => t.client_uuid === cuuid);
|
||||
});
|
||||
|
||||
const uploading = ref(false);
|
||||
const dragActive = ref(false);
|
||||
const uploadError = ref(null);
|
||||
|
||||
function onFileChange(e) {
|
||||
const files = e.target.files;
|
||||
if (files && files.length) {
|
||||
form.file = files[0];
|
||||
uploadError.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpload() {
|
||||
await form.post(route("imports.store"), {
|
||||
forceFormData: true,
|
||||
onSuccess: (res) => {
|
||||
const data = res?.props || {};
|
||||
},
|
||||
onFinish: async () => {
|
||||
// After upload, fetch columns for preview
|
||||
if (!form.recentlySuccessful) return;
|
||||
// Inertia doesn't expose JSON response directly with useForm; fallback to API call using fetch
|
||||
const fd = new FormData();
|
||||
fd.append("file", form.file);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchColumns() {
|
||||
if (!importId.value) return;
|
||||
const url = route("imports.columns", { import: importId.value });
|
||||
const { data } = await axios.get(url, {
|
||||
params: { has_header: hasHeader.value ? 1 : 0 },
|
||||
});
|
||||
detected.value = {
|
||||
columns: data.columns || [],
|
||||
delimiter: data.detected_delimiter || ",",
|
||||
has_header: !!data.has_header,
|
||||
};
|
||||
// initialize simple mapping rows with defaults if none exist
|
||||
if (!mappingRows.value.length) {
|
||||
mappingRows.value = (detected.value.columns || []).map((c, idx) => ({
|
||||
source_column: c,
|
||||
entity: "",
|
||||
field: "",
|
||||
skip: false,
|
||||
transform: "trim",
|
||||
apply_mode: "both",
|
||||
position: idx,
|
||||
}));
|
||||
function onFileDrop(e) {
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length) {
|
||||
form.file = files[0];
|
||||
uploadError.value = null;
|
||||
}
|
||||
await refreshSuggestions(detected.value.columns);
|
||||
// If there are mappings already (template applied or saved), load them to auto-assign
|
||||
await loadImportMappings();
|
||||
dragActive.value = false;
|
||||
}
|
||||
|
||||
async function uploadAndPreview() {
|
||||
async function startImport() {
|
||||
uploadError.value = null;
|
||||
if (!form.file) {
|
||||
// Basic guard: require a file before proceeding
|
||||
uploadError.value = "Najprej izberite datoteko."; // "Select a file first."
|
||||
return;
|
||||
}
|
||||
templateApplied.value = false;
|
||||
processResult.value = null;
|
||||
const fd = new window.FormData();
|
||||
fd.append("file", form.file);
|
||||
if (
|
||||
form.import_template_id !== null &&
|
||||
form.import_template_id !== undefined &&
|
||||
String(form.import_template_id).trim() !== ""
|
||||
) {
|
||||
fd.append("import_template_id", String(form.import_template_id));
|
||||
}
|
||||
if (form.client_uuid) {
|
||||
fd.append("client_uuid", String(form.client_uuid));
|
||||
}
|
||||
fd.append("has_header", hasHeader.value ? "1" : "0");
|
||||
uploading.value = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", form.file);
|
||||
if (form.import_template_id != null) {
|
||||
fd.append("import_template_id", String(form.import_template_id));
|
||||
}
|
||||
if (form.client_uuid) {
|
||||
fd.append("client_uuid", form.client_uuid);
|
||||
}
|
||||
fd.append("has_header", form.has_header ? "1" : "0");
|
||||
const { data } = await axios.post(route("imports.store"), fd, {
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
});
|
||||
// Redirect immediately to the continue page for this import
|
||||
if (data?.uuid) {
|
||||
router.visit(route("imports.continue", { import: data.uuid }));
|
||||
} else if (data?.id) {
|
||||
// Fallback: if uuid not returned for some reason, fetch columns here (legacy)
|
||||
importId.value = data.id;
|
||||
await fetchColumns();
|
||||
return;
|
||||
}
|
||||
if (data?.id) {
|
||||
// Fallback if only numeric id returned
|
||||
router.visit(route("imports.continue", { import: data.id }));
|
||||
return;
|
||||
}
|
||||
uploadError.value = "Nepričakovan odgovor strežnika."; // Unexpected server response.
|
||||
} catch (e) {
|
||||
if (e.response) {
|
||||
console.error("Upload error", e.response.status, e.response.data);
|
||||
if (e.response.data?.errors) {
|
||||
// Optionally you could surface errors in the UI; for now, log for visibility
|
||||
}
|
||||
if (e.response?.data?.message) {
|
||||
uploadError.value = e.response.data.message;
|
||||
} else {
|
||||
console.error("Upload error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If continuing an existing import, set importId and hydrate columns and mappings
|
||||
// No continuation logic on Create page anymore
|
||||
|
||||
async function applyTemplateToImport() {
|
||||
if (!importId.value || !form.import_template_id) return;
|
||||
try {
|
||||
await axios.post(
|
||||
route("importTemplates.apply", {
|
||||
template: form.import_template_id,
|
||||
import: importId.value,
|
||||
}),
|
||||
{},
|
||||
{
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
templateApplied.value = true;
|
||||
// Load mappings and auto-assign UI rows
|
||||
await loadImportMappings();
|
||||
} catch (e) {
|
||||
templateApplied.value = false;
|
||||
if (e.response) {
|
||||
console.error("Apply template error", e.response.status, e.response.data);
|
||||
} else {
|
||||
console.error("Apply template error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadImportMappings() {
|
||||
if (!importId.value) return;
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
route("imports.mappings.get", { import: importId.value }),
|
||||
{
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
const rows = Array.isArray(data?.mappings) ? data.mappings : [];
|
||||
if (!rows.length) return;
|
||||
// Build a lookup by source_column
|
||||
const bySource = new Map(rows.map((r) => [r.source_column, r]));
|
||||
// Update mappingRows (detected columns) to reflect applied mappings
|
||||
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
|
||||
const m = bySource.get(r.source_column);
|
||||
if (!m) return r;
|
||||
// Parse target_field like 'person.first_name' into UI entity/field
|
||||
const [record, field] = String(m.target_field || "").split(".", 2);
|
||||
const entity = keyByCanonicalRoot.value[record] || record;
|
||||
return {
|
||||
...r,
|
||||
entity,
|
||||
field: field || "",
|
||||
transform: m.transform || "",
|
||||
apply_mode: m.apply_mode || "both",
|
||||
skip: false,
|
||||
position: idx,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Load import mappings error",
|
||||
e.response?.status || "",
|
||||
e.response?.data || e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function 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) {
|
||||
if (e.response) {
|
||||
console.error("Process import error", e.response.status, e.response.data);
|
||||
processResult.value = { error: e.response.data || "Processing failed" };
|
||||
} else {
|
||||
console.error("Process import error", e);
|
||||
processResult.value = { error: "Processing failed" };
|
||||
uploadError.value = "Nalaganje ni uspelo."; // Upload failed.
|
||||
}
|
||||
console.error("Import upload failed", e.response?.status, e.response?.data || e);
|
||||
} finally {
|
||||
processing.value = false;
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// entity options and fields are dynamic from API
|
||||
|
||||
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,
|
||||
target_field: `${canonicalRootByKey.value[r.entity] || r.entity}.${r.field}`,
|
||||
transform: r.transform || null,
|
||||
apply_mode: r.apply_mode || "both",
|
||||
options: null,
|
||||
}));
|
||||
if (!mappings.length) {
|
||||
mappingSaved.value = false;
|
||||
mappingError.value =
|
||||
"Select entity and field for at least one column (or uncheck Skip) before saving.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
savingMappings.value = true;
|
||||
const url =
|
||||
typeof route === "function"
|
||||
? route("imports.mappings.save", { import: importId.value })
|
||||
: `/imports/${importId.value}/mappings`;
|
||||
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 = "";
|
||||
} catch (e) {
|
||||
mappingSaved.value = false;
|
||||
if (e.response) {
|
||||
console.error("Save mappings error", e.response.status, e.response.data);
|
||||
alert(
|
||||
"Failed to save mappings: " + (e.response.data?.message || e.response.status)
|
||||
);
|
||||
} else {
|
||||
console.error("Save mappings error", e);
|
||||
alert("Failed to save mappings. See console for details.");
|
||||
}
|
||||
} finally {
|
||||
savingMappings.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset saved flag whenever user edits mappings
|
||||
watch(
|
||||
mappingRows,
|
||||
() => {
|
||||
mappingSaved.value = false;
|
||||
mappingSavedCount.value = 0;
|
||||
mappingError.value = "";
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEntityDefs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="New Import">
|
||||
<AppLayout title="Nov uvoz">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">New Import</h2>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Nov uvoz</h2>
|
||||
</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 class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white shadow sm:rounded-lg p-6 space-y-8">
|
||||
<!-- Intro / guidance -->
|
||||
<div class="text-sm text-gray-600 leading-relaxed">
|
||||
<p class="mb-2">
|
||||
1) Izberite stranko (opcijsko) in predlogo (če obstaja), 2) izberite
|
||||
datoteko (CSV, TXT, XLSX*) in 3) kliknite Začni uvoz. Nadaljnje preslikave
|
||||
in simulacija bodo na naslednji strani.
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
* XLSX podpora je odvisna od konfiguracije strežnika.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Client & Template selection -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Client</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Stranka</label>
|
||||
<Multiselect
|
||||
v-model="selectedClientOption"
|
||||
:options="clients"
|
||||
track-by="uuid"
|
||||
label="name"
|
||||
placeholder="Search clients..."
|
||||
placeholder="Poišči stranko..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Template</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Predloga</label>
|
||||
<Multiselect
|
||||
v-model="selectedTemplateOption"
|
||||
:options="filteredTemplates"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Search templates..."
|
||||
placeholder="Poišči predlogo..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
>
|
||||
<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="truncate">{{ option.name }}</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>
|
||||
<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"
|
||||
class="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
||||
>{{ option.client_id ? "Client" : "Global" }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</Multiselect>
|
||||
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">
|
||||
Only global templates are shown until a client is selected.
|
||||
Prikazane so samo globalne predloge dokler ne izberete stranke.
|
||||
</p>
|
||||
</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">File</label>
|
||||
<input type="file" @change="onFileChange" class="mt-1 block w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700"
|
||||
>Has header row</label
|
||||
<!-- File + Header -->
|
||||
<div class="grid grid-cols-1 gap-6 items-start">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Datoteka</label>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-md p-6 text-center cursor-pointer transition-colors"
|
||||
:class="{
|
||||
'border-indigo-400 bg-indigo-50': dragActive,
|
||||
'border-gray-300 hover:border-gray-400': !dragActive,
|
||||
}"
|
||||
@dragover.prevent="dragActive = true"
|
||||
@dragleave.prevent="dragActive = false"
|
||||
@drop.prevent="onFileDrop"
|
||||
>
|
||||
<input type="checkbox" v-model="hasHeader" class="mt-2" />
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
id="import-file-input"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<label for="import-file-input" class="block cursor-pointer select-none">
|
||||
<div v-if="!form.file" class="text-sm text-gray-600">
|
||||
Povlecite datoteko sem ali
|
||||
<span class="text-indigo-600 underline">kliknite za izbiro</span>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-800 flex flex-col gap-1">
|
||||
<span class="font-medium">{{ form.file.name }}</span>
|
||||
<span class="text-xs text-gray-500"
|
||||
>{{ (form.file.size / 1024).toFixed(1) }} kB</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] inline-block bg-gray-100 px-1.5 py-0.5 rounded"
|
||||
>Zamenjaj</span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<input type="checkbox" v-model="form.has_header" class="rounded" />
|
||||
<span>Prva vrstica je glava</span>
|
||||
</label>
|
||||
<div class="text-xs text-gray-500 leading-relaxed">
|
||||
Če ni označeno, bodo stolpci poimenovani po zaporedju (A, B, C ...).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<!-- Errors -->
|
||||
<div v-if="uploadError" class="text-sm text-red-600">
|
||||
{{ uploadError }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
<button
|
||||
@click.prevent="uploadAndPreview"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded"
|
||||
>
|
||||
Upload & Preview Columns
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="applyTemplateToImport"
|
||||
:disabled="!importId || !form.import_template_id || templateApplied"
|
||||
class="px-4 py-2 bg-emerald-600 disabled:bg-gray-300 text-white rounded"
|
||||
>
|
||||
{{ templateApplied ? "Template Applied" : "Apply Template" }}
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="saveMappings"
|
||||
:disabled="!importId || processing || savingMappings"
|
||||
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"
|
||||
type="button"
|
||||
@click="startImport"
|
||||
:disabled="uploading"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 rounded bg-indigo-600 disabled:bg-indigo-300 text-white text-sm font-medium shadow-sm"
|
||||
>
|
||||
<span
|
||||
v-if="savingMappings"
|
||||
class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
||||
v-if="uploading"
|
||||
class="h-4 w-4 border-2 border-white/60 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
|
||||
>
|
||||
<span>{{ uploading ? "Nalagam..." : "Začni uvoz" }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="processImport"
|
||||
:disabled="!importId || processing || (!templateApplied && !mappingSaved)"
|
||||
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded"
|
||||
type="button"
|
||||
@click="
|
||||
() => {
|
||||
form.file = null;
|
||||
uploadError = null;
|
||||
}
|
||||
"
|
||||
:disabled="uploading || !form.file"
|
||||
class="px-4 py-2 text-sm rounded border bg-white disabled:opacity-50"
|
||||
>
|
||||
{{ processing ? "Processing…" : "Process Import" }}
|
||||
Počisti
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600" v-if="!importId">
|
||||
Upload a file first to enable saving mappings.
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 text-xs text-gray-600"
|
||||
v-else-if="importId && !selectedMappingsCount"
|
||||
>
|
||||
Select an Entity and Field for at least one detected column (or uncheck Skip)
|
||||
and then click Save Mappings.
|
||||
</div>
|
||||
|
||||
<div v-if="detected.columns.length" class="pt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold">
|
||||
Detected Columns ({{ detected.has_header ? "header" : "positional" }})
|
||||
</h3>
|
||||
<button
|
||||
class="px-3 py-1.5 border rounded text-sm"
|
||||
@click.prevent="
|
||||
(async () => {
|
||||
await refreshSuggestions(detected.columns);
|
||||
mappingRows.forEach((r) => applySuggestionToRow(r));
|
||||
})()
|
||||
"
|
||||
>
|
||||
Auto map suggestions
|
||||
</button>
|
||||
</div>
|
||||
<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 mappingRows" :key="idx" class="border-t">
|
||||
<td class="p-2 border text-sm">
|
||||
<div>{{ row.source_column }}</div>
|
||||
<div class="text-xs mt-1" v-if="suggestions[row.source_column]">
|
||||
<span class="text-gray-500">Suggest:</span>
|
||||
<button
|
||||
class="ml-1 underline text-indigo-700 hover:text-indigo-900"
|
||||
@click.prevent="applySuggestionToRow(row)"
|
||||
>
|
||||
{{ suggestions[row.source_column].entity }}.{{
|
||||
suggestions[row.source_column].field
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<select v-model="row.entity" class="border rounded p-1 w-full">
|
||||
<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">
|
||||
<option value="">—</option>
|
||||
<option
|
||||
v-for="f in fieldOptionsByEntity[row.entity] || []"
|
||||
:key="f.value"
|
||||
:value="f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<select v-model="row.transform" class="border rounded p-1 w-full">
|
||||
<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">
|
||||
<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" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2">
|
||||
Mappings saved ({{ mappingSavedCount }}).
|
||||
</div>
|
||||
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">
|
||||
{{ mappingError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-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 class="text-xs text-gray-400 pt-4 border-t">
|
||||
Po nalaganju boste preusmerjeni na nadaljevanje uvoza, kjer lahko izvedete
|
||||
preslikave, simulacijo in končno obdelavo.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { ref } from "vue";
|
||||
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
imports: Object,
|
||||
});
|
||||
|
||||
const deletingId = ref(null);
|
||||
const confirming = ref(false);
|
||||
const errorMsg = ref(null);
|
||||
|
||||
function canDelete(status) {
|
||||
return !["completed", "processing"].includes(status);
|
||||
}
|
||||
|
||||
function confirmDelete(imp) {
|
||||
if (!canDelete(imp.status)) return;
|
||||
deletingId.value = imp.id;
|
||||
confirming.value = true;
|
||||
errorMsg.value = null;
|
||||
}
|
||||
|
||||
function performDelete() {
|
||||
if (!deletingId.value) return;
|
||||
router.delete(route("imports.destroy", { import: deletingId.value }), {
|
||||
preserveScroll: true,
|
||||
onFinish: () => {
|
||||
confirming.value = false;
|
||||
deletingId.value = null;
|
||||
},
|
||||
onError: (errs) => {
|
||||
errorMsg.value = errs?.message || "Brisanje ni uspelo.";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const map = {
|
||||
uploaded: 'bg-gray-200 text-gray-700',
|
||||
parsed: 'bg-blue-100 text-blue-800',
|
||||
validating: 'bg-amber-100 text-amber-800',
|
||||
completed: 'bg-emerald-100 text-emerald-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
uploaded: "bg-gray-200 text-gray-700",
|
||||
parsed: "bg-blue-100 text-blue-800",
|
||||
validating: "bg-amber-100 text-amber-800",
|
||||
completed: "bg-emerald-100 text-emerald-800",
|
||||
failed: "bg-red-100 text-red-800",
|
||||
};
|
||||
return map[status] || 'bg-gray-100 text-gray-800';
|
||||
return map[status] || "bg-gray-100 text-gray-800";
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -23,7 +54,11 @@ function statusBadge(status) {
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uvozi</h2>
|
||||
<Link :href="route('imports.create')" class="px-3 py-2 rounded bg-blue-600 text-white text-sm">Novi uvoz</Link>
|
||||
<Link
|
||||
:href="route('imports.create')"
|
||||
class="px-3 py-2 rounded bg-blue-600 text-white text-sm"
|
||||
>Novi uvoz</Link
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,14 +79,37 @@ function statusBadge(status) {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="imp in imports.data" :key="imp.uuid" class="border-b">
|
||||
<td class="p-2 whitespace-nowrap">{{ new Date(imp.created_at).toLocaleString() }}</td>
|
||||
<td class="p-2 whitespace-nowrap">
|
||||
{{ new Date(imp.created_at).toLocaleString() }}
|
||||
</td>
|
||||
<td class="p-2">{{ imp.original_name }}</td>
|
||||
<td class="p-2"><span :class="['px-2 py-0.5 rounded text-xs', statusBadge(imp.status)]">{{ imp.status }}</span></td>
|
||||
<td class="p-2">{{ imp.client?.person?.full_name ?? '—' }}</td>
|
||||
<td class="p-2">{{ imp.template?.name ?? '—' }}</td>
|
||||
<td class="p-2">
|
||||
<span
|
||||
:class="['px-2 py-0.5 rounded text-xs', statusBadge(imp.status)]"
|
||||
>{{ imp.status }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="p-2">{{ imp.client?.person?.full_name ?? "—" }}</td>
|
||||
<td class="p-2">{{ imp.template?.name ?? "—" }}</td>
|
||||
<td class="p-2 space-x-2">
|
||||
<Link :href="route('imports.continue', { import: imp.uuid })" class="px-2 py-1 rounded bg-gray-200 text-gray-800 text-xs">Poglej</Link>
|
||||
<Link v-if="imp.status !== 'completed'" :href="route('imports.continue', { import: imp.uuid })" class="px-2 py-1 rounded bg-amber-600 text-white text-xs">Nadaljuj</Link>
|
||||
<Link
|
||||
:href="route('imports.continue', { import: imp.uuid })"
|
||||
class="px-2 py-1 rounded bg-gray-200 text-gray-800 text-xs"
|
||||
>Poglej</Link
|
||||
>
|
||||
<Link
|
||||
v-if="imp.status !== 'completed'"
|
||||
:href="route('imports.continue', { import: imp.uuid })"
|
||||
class="px-2 py-1 rounded bg-amber-600 text-white text-xs"
|
||||
>Nadaljuj</Link
|
||||
>
|
||||
<button
|
||||
v-if="canDelete(imp.status)"
|
||||
class="px-2 py-1 rounded bg-red-600 text-white text-xs"
|
||||
@click="confirmDelete(imp)"
|
||||
>
|
||||
Izbriši
|
||||
</button>
|
||||
<span v-else class="text-xs text-gray-400">Zaključen</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -61,13 +119,57 @@ function statusBadge(status) {
|
||||
|
||||
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
|
||||
<div>
|
||||
Prikaz {{ imports.meta.from }}–{{ imports.meta.to }} od {{ imports.meta.total }}
|
||||
Prikaz {{ imports.meta.from }}–{{ imports.meta.to }} od
|
||||
{{ imports.meta.total }}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Link v-if="imports.links.prev" :href="imports.links.prev" class="px-2 py-1 border rounded">Nazaj</Link>
|
||||
<Link v-if="imports.links.next" :href="imports.links.next" class="px-2 py-1 border rounded">Naprej</Link>
|
||||
<Link
|
||||
v-if="imports.links.prev"
|
||||
:href="imports.links.prev"
|
||||
class="px-2 py-1 border rounded"
|
||||
>Nazaj</Link
|
||||
>
|
||||
<Link
|
||||
v-if="imports.links.next"
|
||||
:href="imports.links.next"
|
||||
class="px-2 py-1 border rounded"
|
||||
>Naprej</Link
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
:show="confirming"
|
||||
@close="
|
||||
confirming = false;
|
||||
deletingId = null;
|
||||
"
|
||||
>
|
||||
<template #title>Potrditev brisanja</template>
|
||||
<template #content>
|
||||
<p class="text-sm">
|
||||
Ste prepričani, da želite izbrisati ta uvoz? Datoteka bo odstranjena iz
|
||||
shrambe, če je še prisotna.
|
||||
</p>
|
||||
<p v-if="errorMsg" class="text-sm text-red-600 mt-2">{{ errorMsg }}</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm border rounded me-2"
|
||||
@click="
|
||||
confirming = false;
|
||||
deletingId = null;
|
||||
"
|
||||
>
|
||||
Prekliči
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm rounded bg-red-600 text-white"
|
||||
@click="performDelete"
|
||||
>
|
||||
Izbriši
|
||||
</button>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import {
|
||||
EyeIcon,
|
||||
ArrowPathIcon,
|
||||
BeakerIcon,
|
||||
ArrowDownOnSquareIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
const props = defineProps({
|
||||
importId: [Number, String],
|
||||
isCompleted: Boolean,
|
||||
processing: Boolean,
|
||||
savingMappings: Boolean,
|
||||
canProcess: Boolean,
|
||||
selectedMappingsCount: Number,
|
||||
});
|
||||
const emits = defineEmits(["preview", "save-mappings", "process-import", "simulate"]);
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted">
|
||||
<button
|
||||
@click.prevent="$emit('preview')"
|
||||
:disabled="!importId"
|
||||
class="px-4 py-2 bg-gray-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||
>
|
||||
<EyeIcon class="h-4 w-4" />
|
||||
Predogled vrstic
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="$emit('save-mappings')"
|
||||
: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="Shrani preslikave za ta uvoz"
|
||||
>
|
||||
<span
|
||||
v-if="savingMappings"
|
||||
class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
||||
></span>
|
||||
<ArrowPathIcon v-else class="h-4 w-4" />
|
||||
<span>Shrani preslikave</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="$emit('process-import')"
|
||||
:disabled="!canProcess"
|
||||
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||
>
|
||||
<BeakerIcon class="h-4 w-4" />
|
||||
{{ processing ? "Obdelava…" : "Obdelaj uvoz" }}
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="$emit('simulate')"
|
||||
:disabled="!importId || processing"
|
||||
class="px-4 py-2 bg-blue-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||
>
|
||||
<ArrowDownOnSquareIcon class="h-4 w-4" />
|
||||
Simulacija vnosa
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { CheckCircleIcon } from '@heroicons/vue/24/solid'
|
||||
const props = defineProps({ steps: Array, missingCritical: Array })
|
||||
</script>
|
||||
<template>
|
||||
<div class="bg-gray-50 border rounded p-3 text-xs flex flex-col gap-1 h-fit">
|
||||
<div class="font-semibold text-gray-700 mb-1">Kontrolni seznam</div>
|
||||
<div v-for="s in steps" :key="s.label" class="flex items-center gap-2" :class="s.done ? 'text-emerald-700' : 'text-gray-500'">
|
||||
<CheckCircleIcon v-if="s.done" class="h-4 w-4 text-emerald-600" />
|
||||
<span v-else class="h-4 w-4 rounded-full border border-gray-300 inline-block"></span>
|
||||
<span>{{ s.label }}</span>
|
||||
</div>
|
||||
<div v-if="missingCritical?.length" class="mt-2 text-red-600 font-medium">Manjkajo kritične: {{ missingCritical.join(', ') }}</div>
|
||||
<div v-else class="mt-2 text-emerald-600">Kritične preslikave prisotne</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import Modal from '@/Components/Modal.vue'
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
limit: Number,
|
||||
rows: Array,
|
||||
columns: Array,
|
||||
loading: Boolean,
|
||||
truncated: Boolean,
|
||||
hasHeader: Boolean,
|
||||
})
|
||||
const emits = defineEmits(['close','change-limit','refresh'])
|
||||
function onLimit(e){ emits('change-limit', Number(e.target.value)); emits('refresh') }
|
||||
</script>
|
||||
<template>
|
||||
<Modal :show="show" max-width="wide" @close="$emit('close')">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-semibold text-lg">CSV Preview ({{ rows.length }} / {{ limit }})</h3>
|
||||
<button class="text-sm px-2 py-1 rounded border" @click="$emit('close')">Close</button>
|
||||
</div>
|
||||
<div class="mb-2 flex items-center gap-3 text-sm">
|
||||
<div>
|
||||
<label class="mr-1 text-gray-600">Limit:</label>
|
||||
<select :value="limit" class="border rounded p-1" @change="onLimit">
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
<option :value="200">200</option>
|
||||
<option :value="300">300</option>
|
||||
<option :value="500">500</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="$emit('refresh')" class="px-2 py-1 border rounded" :disabled="loading">{{ loading ? 'Loading…' : 'Refresh' }}</button>
|
||||
<span v-if="truncated" class="text-xs text-amber-600">Truncated at limit</span>
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[60vh] border rounded">
|
||||
<table class="min-w-full text-xs">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="p-2 border bg-white">#</th>
|
||||
<th v-for="col in columns" :key="col" class="p-2 border text-left">{{ col }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td :colspan="columns.length + 1" class="p-4 text-center text-gray-500">Loading…</td>
|
||||
</tr>
|
||||
<tr v-for="(r, idx) in rows" :key="idx" class="border-t hover:bg-gray-50">
|
||||
<td class="p-2 border text-gray-500">{{ idx + 1 }}</td>
|
||||
<td v-for="col in columns" :key="col" class="p-2 border whitespace-pre-wrap">{{ r[col] }}</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !rows.length">
|
||||
<td :colspan="columns.length + 1" class="p-4 text-center text-gray-500">No rows</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">Showing up to {{ limit }} rows from source file. Header detection: {{ hasHeader ? 'header present' : 'no header' }}.</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,268 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
|
||||
const props = defineProps({
|
||||
events: Array,
|
||||
loading: Boolean,
|
||||
limit: Number,
|
||||
});
|
||||
const emits = defineEmits(["update:limit", "refresh"]);
|
||||
function onLimit(e) {
|
||||
emits("update:limit", Number(e.target.value));
|
||||
emits("refresh");
|
||||
}
|
||||
|
||||
// Level filter (all | error | warning | info/other)
|
||||
const levelFilter = ref("all");
|
||||
const levelOptions = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "error", label: "Error" },
|
||||
{ value: "warning", label: "Warning" },
|
||||
{ value: "info", label: "Info / Other" },
|
||||
];
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
if (levelFilter.value === "all") return props.events || [];
|
||||
if (levelFilter.value === "info") {
|
||||
return (props.events || []).filter(
|
||||
(e) => e.level !== "error" && e.level !== "warning"
|
||||
);
|
||||
}
|
||||
return (props.events || []).filter((e) => e.level === levelFilter.value);
|
||||
});
|
||||
|
||||
// Expanded state per event id
|
||||
const expanded = ref(new Set());
|
||||
function isExpanded(id) {
|
||||
return expanded.value.has(id);
|
||||
}
|
||||
function toggleExpand(id) {
|
||||
if (expanded.value.has(id)) {
|
||||
expanded.value.delete(id);
|
||||
} else {
|
||||
expanded.value.add(id);
|
||||
}
|
||||
expanded.value = new Set(expanded.value);
|
||||
}
|
||||
|
||||
function isLong(msg) {
|
||||
return msg && String(msg).length > 160;
|
||||
}
|
||||
function shortMsg(msg) {
|
||||
if (!msg) return "";
|
||||
const s = String(msg);
|
||||
return s.length <= 160 ? s : s.slice(0, 160) + "…";
|
||||
}
|
||||
|
||||
function tryJson(val) {
|
||||
if (val == null) return null;
|
||||
if (typeof val === "object") return val;
|
||||
if (typeof val === "string") {
|
||||
const t = val.trim();
|
||||
if (
|
||||
(t.startsWith("{") && t.endsWith("}")) ||
|
||||
(t.startsWith("[") && t.endsWith("]"))
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(t);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function contextPreview(ctx) {
|
||||
if (!ctx) return "";
|
||||
const obj = tryJson(ctx) || ctx;
|
||||
let str = typeof obj === "string" ? obj : JSON.stringify(obj);
|
||||
if (str.length > 60) str = str.slice(0, 60) + "…";
|
||||
return str;
|
||||
}
|
||||
|
||||
// JSON formatting & lightweight syntax highlight
|
||||
function htmlEscape(s) {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function prettyJson(val) {
|
||||
const obj = tryJson(val);
|
||||
if (!obj) {
|
||||
return htmlEscape(typeof val === "string" ? val : String(val ?? ""));
|
||||
}
|
||||
try {
|
||||
return htmlEscape(JSON.stringify(obj, null, 2));
|
||||
} catch {
|
||||
return htmlEscape(String(val ?? ""));
|
||||
}
|
||||
}
|
||||
|
||||
function highlightJson(val) {
|
||||
const src = prettyJson(val);
|
||||
return src.replace(
|
||||
/(\"([^"\\]|\\.)*\"\s*:)|(\"([^"\\]|\\.)*\")|\b(true|false|null)\b|-?\b\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g,
|
||||
(match) => {
|
||||
if (/^\"([^"\\]|\\.)*\"\s*:/.test(match)) {
|
||||
return `<span class=\"text-indigo-600\">${match}</span>`; // key
|
||||
}
|
||||
if (/^\"/.test(match)) {
|
||||
return `<span class=\"text-emerald-700\">${match}</span>`; // string
|
||||
}
|
||||
if (/true|false/.test(match)) {
|
||||
return `<span class=\"text-orange-600 font-medium\">${match}</span>`; // boolean
|
||||
}
|
||||
if (/null/.test(match)) {
|
||||
return `<span class=\"text-gray-500 italic\">${match}</span>`; // null
|
||||
}
|
||||
if (/^-?\d/.test(match)) {
|
||||
return `<span class=\"text-fuchsia-700\">${match}</span>`; // number
|
||||
}
|
||||
return match;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function formattedContext(ctx) {
|
||||
return highlightJson(ctx);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold">Logs</h3>
|
||||
<div class="flex items-center flex-wrap gap-2 text-sm">
|
||||
<label class="text-gray-600">Show</label>
|
||||
<select :value="limit" class="border rounded p-1" @change="onLimit">
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
<option :value="200">200</option>
|
||||
<option :value="500">500</option>
|
||||
</select>
|
||||
<label class="text-gray-600 ml-2">Level</label>
|
||||
<select v-model="levelFilter" class="border rounded p-1">
|
||||
<option v-for="opt in levelOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
@click.prevent="$emit('refresh')"
|
||||
class="px-2 py-1 border rounded text-sm"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ loading ? "Refreshing…" : "Refresh" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[30rem] overflow-y-auto rounded border">
|
||||
<table class="min-w-full bg-white text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col class="w-40" />
|
||||
<col class="w-20" />
|
||||
<col class="w-40" />
|
||||
<col />
|
||||
<col class="w-16" />
|
||||
</colgroup>
|
||||
<thead class="bg-gray-50 sticky top-0 z-10 shadow">
|
||||
<tr class="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 filteredEvents" :key="ev.id" class="border-t align-top">
|
||||
<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 break-words max-w-[9rem]">
|
||||
<span class="block truncate" :title="ev.event">{{ ev.event }}</span>
|
||||
</td>
|
||||
<td class="p-2 border align-top max-w-[28rem]">
|
||||
<div class="space-y-1 break-words">
|
||||
<div class="leading-snug whitespace-pre-wrap">
|
||||
<span v-if="!isLong(ev.message)">{{ ev.message }}</span>
|
||||
<span v-else>
|
||||
<span v-if="!isExpanded(ev.id)">{{ shortMsg(ev.message) }}</span>
|
||||
<span v-else>{{ ev.message }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 inline-flex items-center gap-0.5 text-xs text-indigo-600 hover:underline"
|
||||
@click="toggleExpand(ev.id)"
|
||||
>
|
||||
{{ isExpanded(ev.id) ? "Show less" : "Read more" }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="ev.context" class="text-xs text-gray-600">
|
||||
<Dropdown
|
||||
align="left"
|
||||
width="wide"
|
||||
:content-classes="[
|
||||
'p-3',
|
||||
'bg-white',
|
||||
'text-xs',
|
||||
'break-words',
|
||||
'space-y-2',
|
||||
'max-h-[28rem]',
|
||||
'overflow-auto',
|
||||
'max-w-[34rem]',
|
||||
]"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="px-1.5 py-0.5 rounded border border-gray-300 bg-gray-50 hover:bg-gray-100 text-gray-700 transition text-[11px] font-medium"
|
||||
>
|
||||
Context: {{ contextPreview(ev.context) }}
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
class="font-medium text-gray-700 mb-1 flex items-center justify-between"
|
||||
>
|
||||
<span>Context JSON</span>
|
||||
<span class="text-[10px] text-gray-400">ID: {{ ev.id }}</span>
|
||||
</div>
|
||||
<pre
|
||||
class="whitespace-pre break-words text-gray-800 text-[11px] leading-snug"
|
||||
>
|
||||
<code v-html="formattedContext(ev.context)"></code>
|
||||
</pre>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 border">{{ ev.import_row_id ?? "—" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredEvents.length">
|
||||
<td class="p-3 text-center text-gray-500" colspan="5">No events yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
rows: Array,
|
||||
entityOptions: Array,
|
||||
isCompleted: Boolean,
|
||||
detected: Object,
|
||||
detectedNote: String,
|
||||
duplicateTargets: Object,
|
||||
missingCritical: Array,
|
||||
mappingSaved: Boolean,
|
||||
mappingSavedCount: Number,
|
||||
mappingError: String,
|
||||
show: { type: Boolean, default: true },
|
||||
fieldsForEntity: Function,
|
||||
})
|
||||
const emits = defineEmits(['update:rows','save'])
|
||||
|
||||
function duplicateTarget(row){
|
||||
if(!row || !row.entity || !row.field) return false
|
||||
// parent already marks duplicates in duplicateTargets set keyed as record.field
|
||||
return props.duplicateTargets?.has?.(row.entity + '.' + row.field) || false
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="show && rows?.length" class="pt-4">
|
||||
<h3 class="font-semibold mb-2">
|
||||
Detected Columns ({{ detected?.has_header ? 'header' : 'positional' }})
|
||||
<span class="ml-2 text-xs text-gray-500">detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }}, delimiter: {{ detected?.delimiter || 'auto' }}</span>
|
||||
</h3>
|
||||
<p v-if="detectedNote" class="text-xs text-gray-500 mb-2">{{ detectedNote }}</p>
|
||||
<div class="relative border rounded overflow-auto max-h-[420px]">
|
||||
<table class="min-w-full bg-white">
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr class="bg-gray-50/95 backdrop-blur 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 rows" :key="idx" class="border-t" :class="duplicateTarget(row) ? 'bg-red-50' : ''">
|
||||
<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', duplicateTarget(row) ? 'border-red-500 bg-red-50' : '']" :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="keyref">Keyref</option>
|
||||
<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 v-if="missingCritical?.length" class="text-xs text-amber-600 mt-1">Missing critical: {{ missingCritical.join(', ') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
const props = defineProps({ result: [String, Object] })
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="result" 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">{{ result }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
const props = defineProps({ mappings: Array })
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="mappings?.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 mappings" :key="m.id || (m.source_column + m.target_field)" 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>
|
||||
</template>
|
||||
@@ -0,0 +1,793 @@
|
||||
<script setup>
|
||||
import Modal from "@/Components/Modal.vue";
|
||||
import { useEurFormat } from "../useEurFormat.js";
|
||||
import { ArrowRightIcon, ArrowDownIcon, ArrowUpIcon } from "@heroicons/vue/24/solid";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
// Props expected by the template
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
rows: { type: Array, default: () => [] },
|
||||
limit: { type: Number, default: 50 },
|
||||
loading: { type: Boolean, default: false },
|
||||
entities: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(["close", "update:limit"]);
|
||||
|
||||
// Map technical entity keys to localized labels
|
||||
const entityLabelMap = {
|
||||
account: "računi",
|
||||
payment: "plačila",
|
||||
contract: "pogodbe",
|
||||
person: "osebe",
|
||||
client_case: "primeri",
|
||||
address: "naslovi",
|
||||
email: "emaili",
|
||||
phone: "telefoni",
|
||||
booking: "knjižbe",
|
||||
activity: "aktivnosti",
|
||||
};
|
||||
|
||||
// Formatting helpers
|
||||
const { formatEur } = useEurFormat();
|
||||
const fmt = (v) => formatEur(v);
|
||||
function formatDate(val) {
|
||||
if (!val) return "—";
|
||||
try {
|
||||
const d = val instanceof Date ? val : new Date(val);
|
||||
if (isNaN(d.getTime())) return String(val);
|
||||
return d.toLocaleDateString("sl-SI", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch (_) {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
|
||||
// Localized list for header
|
||||
const localizedEntities = computed(() =>
|
||||
Array.isArray(props.entities) && props.entities.length
|
||||
? props.entities.map((e) => entityLabelMap[e] ?? e).join(", ")
|
||||
: ""
|
||||
);
|
||||
|
||||
const entitiesWithRows = computed(() => {
|
||||
if (!props.rows?.length || !props.entities?.length) return [];
|
||||
const present = new Set();
|
||||
for (const r of props.rows) {
|
||||
if (!r.entities) continue;
|
||||
for (const k of Object.keys(r.entities)) {
|
||||
if (props.entities.includes(k)) present.add(k);
|
||||
}
|
||||
}
|
||||
return props.entities.filter((e) => present.has(e));
|
||||
});
|
||||
|
||||
const activeEntity = ref(null);
|
||||
const hideChain = ref(false);
|
||||
const showOnlyChanged = ref(false);
|
||||
watch(
|
||||
entitiesWithRows,
|
||||
(val) => {
|
||||
if (!val.length) {
|
||||
activeEntity.value = null;
|
||||
return;
|
||||
}
|
||||
if (!activeEntity.value || !val.includes(activeEntity.value))
|
||||
activeEntity.value = val[0];
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const entityStats = computed(() => {
|
||||
const stats = {};
|
||||
for (const e of entitiesWithRows.value)
|
||||
stats[e] = {
|
||||
total_rows: 0,
|
||||
create: 0,
|
||||
update: 0,
|
||||
missing_ref: 0,
|
||||
invalid: 0,
|
||||
duplicate: 0,
|
||||
duplicate_db: 0,
|
||||
};
|
||||
for (const r of props.rows || []) {
|
||||
if (!r.entities) continue;
|
||||
for (const [k, ent] of Object.entries(r.entities)) {
|
||||
if (!stats[k]) continue;
|
||||
stats[k].total_rows++;
|
||||
switch (ent.action) {
|
||||
case "create":
|
||||
stats[k].create++;
|
||||
break;
|
||||
case "update":
|
||||
stats[k].update++;
|
||||
break;
|
||||
case "missing_ref":
|
||||
stats[k].missing_ref++;
|
||||
break;
|
||||
case "invalid":
|
||||
stats[k].invalid++;
|
||||
break;
|
||||
}
|
||||
if (ent.duplicate) stats[k].duplicate++;
|
||||
if (ent.duplicate_db) stats[k].duplicate_db++;
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
});
|
||||
const activeSummary = computed(() =>
|
||||
activeEntity.value ? entityStats.value[activeEntity.value] : null
|
||||
);
|
||||
const entityHasDuplicates = (e) => {
|
||||
const s = entityStats.value[e];
|
||||
return s ? s.duplicate + s.duplicate_db > 0 : false;
|
||||
};
|
||||
const visibleRows = computed(() => {
|
||||
if (!props.rows || !activeEntity.value) return [];
|
||||
const eps = 0.0000001;
|
||||
return props.rows
|
||||
.filter((r) => {
|
||||
if (!r.entities || !r.entities[activeEntity.value]) return false;
|
||||
const ent = r.entities[activeEntity.value];
|
||||
if (hideChain.value && ent.existing_chain) return false;
|
||||
if (showOnlyChanged.value) {
|
||||
// Define change criteria per entity
|
||||
if (activeEntity.value === "account") {
|
||||
if (ent.delta !== undefined && Math.abs(ent.delta) > eps) return true;
|
||||
// new account creation counts as change
|
||||
if (ent.action === "create") return true;
|
||||
return false;
|
||||
}
|
||||
if (activeEntity.value === "payment") {
|
||||
// payment with valid amount considered change
|
||||
return ent.amount !== null && ent.amount !== undefined;
|
||||
}
|
||||
// Generic entities: any create/update considered change
|
||||
if (ent.action === "create" || ent.action === "update") return true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, props.limit || props.rows.length);
|
||||
});
|
||||
function referenceOf(entityName, ent) {
|
||||
if (!ent || typeof ent !== "object") return "—";
|
||||
|
||||
const pick = (val) => {
|
||||
if (val === undefined || val === null) return null;
|
||||
if (typeof val === "object") {
|
||||
if (
|
||||
val.normalized !== undefined &&
|
||||
val.normalized !== null &&
|
||||
String(val.normalized).trim() !== ""
|
||||
)
|
||||
return val.normalized;
|
||||
if (
|
||||
val.value !== undefined &&
|
||||
val.value !== null &&
|
||||
String(val.value).trim() !== ""
|
||||
)
|
||||
return val.value;
|
||||
return null;
|
||||
}
|
||||
const s = String(val).trim();
|
||||
return s === "" ? null : val;
|
||||
};
|
||||
|
||||
// 1. direct reference
|
||||
const direct = pick(ent.reference);
|
||||
if (direct !== null) return direct;
|
||||
|
||||
// 2. other plausible keys
|
||||
const candidates = [
|
||||
"ref",
|
||||
"code",
|
||||
"number",
|
||||
"identifier",
|
||||
"external_id",
|
||||
`${entityName}_reference`,
|
||||
`${entityName}Reference`,
|
||||
];
|
||||
for (const k of candidates) {
|
||||
if (k in ent) {
|
||||
const v = pick(ent[k]);
|
||||
if (v !== null) return v;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. any property containing 'reference'
|
||||
for (const [k, v] of Object.entries(ent)) {
|
||||
if (k.toLowerCase().includes("reference")) {
|
||||
const pv = pick(v);
|
||||
if (pv !== null) return pv;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. sources map
|
||||
const sources = ent.sources;
|
||||
if (sources && typeof sources === "object") {
|
||||
const priority = [`${entityName}.reference`, "reference"];
|
||||
for (const k of priority) {
|
||||
if (k in sources) {
|
||||
const pv = pick(sources[k]);
|
||||
if (pv !== null) return pv;
|
||||
}
|
||||
}
|
||||
for (const [k, v] of Object.entries(sources)) {
|
||||
if (k.toLowerCase().includes("reference")) {
|
||||
const pv = pick(v);
|
||||
if (pv !== null) return pv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "—";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :show="show" max-width="wide" @close="emit('close')">
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-800">Simulacija uvoza</h2>
|
||||
<p v-if="localizedEntities" class="text-[12px] text-gray-500">
|
||||
Entitete: {{ localizedEntities }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-[11px] text-gray-600 flex items-center gap-1"
|
||||
>Prikaži:
|
||||
<select
|
||||
class="border rounded px-1 py-0.5 text-[11px]"
|
||||
:value="limit"
|
||||
@change="onLimit"
|
||||
>
|
||||
<option :value="25">25</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
<option :value="250">250</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[11px] px-2 py-1 rounded border bg-white hover:bg-gray-50"
|
||||
@click="toggleVerbose"
|
||||
>
|
||||
{{ verbose ? "Manj" : "Več" }} podrobnosti
|
||||
</button>
|
||||
<label class="flex items-center gap-1 text-[11px] text-gray-600">
|
||||
<input type="checkbox" v-model="hideChain" class="rounded border-gray-300" />
|
||||
Skrij verižne
|
||||
</label>
|
||||
<label class="flex items-center gap-1 text-[11px] text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showOnlyChanged"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
Samo spremenjeni
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[11px] px-2 py-1 rounded bg-gray-800 text-white hover:bg-gray-700"
|
||||
@click="emit('close')"
|
||||
>
|
||||
Zapri
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="entitiesWithRows.length" class="flex flex-wrap gap-1 border-b pb-1">
|
||||
<button
|
||||
v-for="e in entitiesWithRows"
|
||||
:key="e"
|
||||
type="button"
|
||||
@click="activeEntity = e"
|
||||
class="relative px-2 py-1 rounded-t text-[11px] font-medium border"
|
||||
:class="
|
||||
activeEntity === e
|
||||
? 'bg-white border-b-white text-gray-900'
|
||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-600'
|
||||
"
|
||||
>
|
||||
<span class="uppercase tracking-wide">{{ e }}</span>
|
||||
<span
|
||||
v-if="entityHasDuplicates(e)"
|
||||
class="absolute -top-1 -right-1 inline-block w-3 h-3 rounded-full bg-amber-500 ring-2 ring-white"
|
||||
title="Duplikati"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeSummary"
|
||||
class="text-[11px] flex flex-wrap items-center gap-3 bg-gray-50 border rounded px-2 py-1"
|
||||
>
|
||||
<div class="font-semibold uppercase tracking-wide text-gray-600">
|
||||
{{ activeEntity }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-600"
|
||||
>Vrstic:
|
||||
<span class="font-medium text-gray-800">{{
|
||||
activeSummary.total_rows
|
||||
}}</span></span
|
||||
>
|
||||
<span v-if="activeSummary.create" class="text-emerald-700"
|
||||
>+{{ activeSummary.create }} novo</span
|
||||
>
|
||||
<span v-if="activeSummary.update" class="text-blue-700"
|
||||
>{{ activeSummary.update }} posodobitev</span
|
||||
>
|
||||
<span v-if="activeSummary.duplicate" class="text-amber-600"
|
||||
>{{ activeSummary.duplicate }} duplikat</span
|
||||
>
|
||||
<span v-if="activeSummary.duplicate_db" class="text-amber-700"
|
||||
>{{ activeSummary.duplicate_db }} obstaja</span
|
||||
>
|
||||
<span v-if="activeSummary.missing_ref" class="text-red-600"
|
||||
>{{ activeSummary.missing_ref }} manjka referenca</span
|
||||
>
|
||||
<span v-if="activeSummary.invalid" class="text-red-700"
|
||||
>{{ activeSummary.invalid }} neveljavnih</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeEntity" class="border rounded bg-white">
|
||||
<div class="max-h-[28rem] overflow-auto">
|
||||
<table class="min-w-full text-[12px]">
|
||||
<thead class="bg-gray-100 text-left sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="px-2 py-1 border w-14">#</th>
|
||||
<th class="px-2 py-1 border">Podatki</th>
|
||||
<th class="px-2 py-1 border w-48">Učinek (plačilo)</th>
|
||||
<th class="px-2 py-1 border w-24">Opombe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td :colspan="4" class="p-4 text-center text-gray-500">Nalagam…</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="r in visibleRows"
|
||||
:key="r.index"
|
||||
class="border-t"
|
||||
:class="r.status !== 'ok' ? 'bg-red-50' : ''"
|
||||
>
|
||||
<td class="p-2 border text-gray-500 align-top">{{ r.index }}</td>
|
||||
<td class="p-2 border align-top">
|
||||
<div
|
||||
v-if="r.entities && r.entities[activeEntity]"
|
||||
class="text-[11px] border rounded p-2 bg-white/70 max-w-[360px]"
|
||||
>
|
||||
<div
|
||||
class="font-semibold uppercase tracking-wide text-gray-600 mb-1 flex items-center justify-between"
|
||||
>
|
||||
<span>{{ activeEntity }}</span>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].action_label"
|
||||
class="text-[10px] px-1 py-0.5 rounded bg-gray-100"
|
||||
>{{ r.entities[activeEntity].action_label }}</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].existing_chain"
|
||||
class="ml-1 text-[9px] px-1 py-0.5 rounded bg-indigo-100 text-indigo-700"
|
||||
title="Iz obstoječe verige (contract → client_case → person)"
|
||||
>chain</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].inherited_reference"
|
||||
class="ml-1 text-[9px] px-1 py-0.5 rounded bg-amber-100 text-amber-700"
|
||||
title="Referenca podedovana"
|
||||
>inh</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].action === 'implicit'"
|
||||
class="ml-1 text-[9px] px-1 py-0.5 rounded bg-teal-100 text-teal-700"
|
||||
title="Implicitno"
|
||||
>impl</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<template v-if="activeEntity === 'account'">
|
||||
<div class="flex items-center gap-1">
|
||||
Ref:
|
||||
<span class="font-medium flex items-center gap-1">
|
||||
{{ referenceOf(activeEntity, r.entities[activeEntity]) }}
|
||||
<span
|
||||
v-if="r.entities[activeEntity].inherited_reference"
|
||||
class="text-[9px] px-1 py-0.5 rounded bg-indigo-100 text-indigo-700"
|
||||
title="Podedovano iz pogodbe"
|
||||
>inh</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].balance_before !== undefined"
|
||||
class="mt-1 space-y-0.5"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500">Saldo:</span
|
||||
><span>{{ fmt(r.entities[activeEntity].balance_before) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].balance_after !== undefined"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<ArrowRightIcon
|
||||
v-if="
|
||||
(r.entities[activeEntity].balance_after ??
|
||||
r.entities[activeEntity].balance_before) ===
|
||||
r.entities[activeEntity].balance_before
|
||||
"
|
||||
class="h-3 w-3 text-gray-400"
|
||||
/>
|
||||
<ArrowDownIcon
|
||||
v-else-if="
|
||||
(r.entities[activeEntity].balance_after ??
|
||||
r.entities[activeEntity].balance_before) <
|
||||
r.entities[activeEntity].balance_before
|
||||
"
|
||||
class="h-3 w-3 text-emerald-500"
|
||||
/>
|
||||
<ArrowUpIcon v-else class="h-3 w-3 text-red-500" />
|
||||
<span
|
||||
:class="
|
||||
(r.entities[activeEntity].balance_after ??
|
||||
r.entities[activeEntity].balance_before) <
|
||||
r.entities[activeEntity].balance_before
|
||||
? 'text-emerald-600 font-medium'
|
||||
: 'text-red-600 font-medium'
|
||||
"
|
||||
>{{
|
||||
fmt(
|
||||
r.entities[activeEntity].balance_after ??
|
||||
r.entities[activeEntity].balance_before
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeEntity === 'payment'">
|
||||
<div>
|
||||
Znesek:
|
||||
<span class="font-medium">{{
|
||||
fmt(
|
||||
r.entities[activeEntity].amount ??
|
||||
r.entities[activeEntity].raw_amount
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
Datum: {{ formatDate(r.entities[activeEntity].payment_date) }}
|
||||
</div>
|
||||
<div v-if="r.entities[activeEntity].reference">
|
||||
Ref:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].reference
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
Status:
|
||||
<span
|
||||
:class="
|
||||
r.entities[activeEntity].status === 'ok'
|
||||
? 'text-emerald-600'
|
||||
: r.entities[activeEntity].status === 'duplicate' ||
|
||||
r.entities[activeEntity].status === 'duplicate_db'
|
||||
? 'text-amber-600'
|
||||
: 'text-red-600'
|
||||
"
|
||||
>{{
|
||||
r.entities[activeEntity].status_label ||
|
||||
r.entities[activeEntity].status
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeEntity === 'contract'">
|
||||
<div>
|
||||
Ref:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
Akcija:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].action_label ||
|
||||
r.entities[activeEntity].action
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap gap-1 mb-1">
|
||||
<span
|
||||
v-if="r.entities[activeEntity].identity_used"
|
||||
class="px-1 py-0.5 rounded bg-indigo-50 text-indigo-700 text-[10px]"
|
||||
title="Uporabljena identiteta"
|
||||
>{{ r.entities[activeEntity].identity_used }}</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].duplicate"
|
||||
class="px-1 py-0.5 rounded bg-amber-100 text-amber-700 text-[10px]"
|
||||
title="Podvojen v tej seriji"
|
||||
>duplikat</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].duplicate_db"
|
||||
class="px-1 py-0.5 rounded bg-amber-200 text-amber-800 text-[10px]"
|
||||
title="Že obstaja v bazi"
|
||||
>obstaja v bazi</span
|
||||
>
|
||||
</div>
|
||||
<template v-if="activeEntity === 'person'">
|
||||
<div class="grid grid-cols-1 gap-0.5">
|
||||
<div
|
||||
v-if="
|
||||
referenceOf(activeEntity, r.entities[activeEntity]) !== '—'
|
||||
"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Ref:
|
||||
<span class="font-medium text-gray-800">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].full_name"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Ime:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].full_name
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
r.entities[activeEntity].first_name ||
|
||||
r.entities[activeEntity].last_name
|
||||
"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Ime:
|
||||
<span class="font-medium">{{
|
||||
[
|
||||
r.entities[activeEntity].first_name,
|
||||
r.entities[activeEntity].last_name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].birthday"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Rojstvo:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].birthday
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].description"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Opis:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].description
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].identity_candidates?.length"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Identitete:
|
||||
{{ r.entities[activeEntity].identity_candidates.join(", ") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeEntity === 'email'"
|
||||
><div class="text-[10px] text-gray-600">
|
||||
Email:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div></template
|
||||
>
|
||||
<template v-else-if="activeEntity === 'phone'"
|
||||
><div class="text-[10px] text-gray-600">
|
||||
Telefon:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div></template
|
||||
>
|
||||
<template v-else-if="activeEntity === 'address'">
|
||||
<div class="text-[10px] text-gray-600 space-y-0.5">
|
||||
<div
|
||||
v-if="
|
||||
referenceOf(activeEntity, r.entities[activeEntity]) !== '—'
|
||||
"
|
||||
>
|
||||
Ref:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="r.entities[activeEntity].address">
|
||||
Naslov:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].address
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
r.entities[activeEntity].postal_code ||
|
||||
r.entities[activeEntity].country
|
||||
"
|
||||
>
|
||||
Lokacija:
|
||||
<span class="font-medium">{{
|
||||
[
|
||||
r.entities[activeEntity].postal_code,
|
||||
r.entities[activeEntity].country,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeEntity === 'client_case'">
|
||||
<div class="text-[10px] text-gray-600 space-y-0.5">
|
||||
<div
|
||||
v-if="
|
||||
referenceOf(activeEntity, r.entities[activeEntity]) !== '—'
|
||||
"
|
||||
>
|
||||
Ref:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="r.entities[activeEntity].title">
|
||||
Naslov:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].title
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="r.entities[activeEntity].status">
|
||||
Status:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].status
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<pre class="text-[10px] whitespace-pre-wrap">{{
|
||||
r.entities[activeEntity]
|
||||
}}</pre>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 border align-top text-[11px]">
|
||||
<div v-if="r.entities.payment">
|
||||
<div class="mb-1 font-semibold text-gray-700">Učinek plačila</div>
|
||||
<div v-if="r.entities.account && r.entities.payment.amount !== null">
|
||||
Saldo:
|
||||
<span class="inline-flex items-center gap-1 font-medium">
|
||||
<ArrowDownIcon
|
||||
v-if="
|
||||
r.entities.account.balance_after -
|
||||
r.entities.account.balance_before <
|
||||
0
|
||||
"
|
||||
class="h-3 w-3 text-emerald-500"
|
||||
/>
|
||||
<ArrowUpIcon
|
||||
v-else-if="
|
||||
r.entities.account.balance_after -
|
||||
r.entities.account.balance_before >
|
||||
0
|
||||
"
|
||||
class="h-3 w-3 text-red-500"
|
||||
/>
|
||||
<ArrowRightIcon v-else class="h-3 w-3 text-gray-400" />
|
||||
<span
|
||||
:class="
|
||||
r.entities.account.balance_after -
|
||||
r.entities.account.balance_before <
|
||||
0
|
||||
? 'text-emerald-600'
|
||||
: r.entities.account.balance_after -
|
||||
r.entities.account.balance_before >
|
||||
0
|
||||
? 'text-red-600'
|
||||
: 'text-gray-700'
|
||||
"
|
||||
>{{
|
||||
fmt(
|
||||
r.entities.account.balance_after -
|
||||
r.entities.account.balance_before
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities.account && r.entities.account.delta !== undefined"
|
||||
class="text-gray-500"
|
||||
>
|
||||
(pred {{ fmt(r.entities.account.balance_before) }} → po
|
||||
{{ fmt(r.entities.account.balance_after) }})
|
||||
</div>
|
||||
<div
|
||||
v-if="verbose && r.entities.payment.sources"
|
||||
class="mt-2 space-y-1"
|
||||
>
|
||||
<div class="font-semibold text-gray-600">Učinkoviti stolpci</div>
|
||||
<table class="min-w-full border text-[10px] bg-white">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="px-1 py-0.5 border text-left">Tarča</th>
|
||||
<th class="px-1 py-0.5 border text-left">Izvorni stolpec</th>
|
||||
<th class="px-1 py-0.5 border text-left">Vrednost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(src, key) in r.entities.payment.sources" :key="key">
|
||||
<td class="px-1 py-0.5 border whitespace-nowrap">
|
||||
{{ key }}
|
||||
</td>
|
||||
<td class="px-1 py-0.5 border">{{ src.source_column }}</td>
|
||||
<td class="px-1 py-0.5 border">
|
||||
<span v-if="key === 'payment.amount'"
|
||||
>{{ src.value
|
||||
}}<span
|
||||
v-if="
|
||||
src.normalized !== undefined &&
|
||||
src.normalized !== src.value
|
||||
"
|
||||
class="text-gray-500"
|
||||
>
|
||||
→ {{ src.normalized }}</span
|
||||
></span
|
||||
><span v-else>{{ src.value ?? "—" }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 border text-[11px] align-top">
|
||||
<div class="text-gray-400">—</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !visibleRows.length">
|
||||
<td :colspan="4" class="p-4 text-center text-gray-500">
|
||||
Ni simuliranih vrstic
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] text-gray-500">
|
||||
Samo simulacija – podatki niso bili spremenjeni. Saldi predpostavljajo zaporedno
|
||||
obdelavo plačil.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup>
|
||||
import Multiselect from "vue-multiselect";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
isCompleted: Boolean,
|
||||
hasHeader: Boolean,
|
||||
delimiterState: Object,
|
||||
selectedTemplateOption: Object,
|
||||
filteredTemplates: Array,
|
||||
templateApplied: Boolean,
|
||||
form: Object, // reactive object reference from parent
|
||||
});
|
||||
const emits = defineEmits([
|
||||
"update:hasHeader",
|
||||
"update:delimiterMode",
|
||||
"update:delimiterCustom",
|
||||
"apply-template",
|
||||
"preview",
|
||||
]);
|
||||
|
||||
function onHeaderChange(e) {
|
||||
emits("update:hasHeader", e.target.value === "true");
|
||||
}
|
||||
function onDelimiterMode(e) {
|
||||
emits("update:delimiterMode", e.target.value);
|
||||
}
|
||||
function onDelimiterCustom(e) {
|
||||
emits("update:delimiterCustom", e.target.value);
|
||||
}
|
||||
|
||||
// Proxy selected template object <-> form.import_template_id (which stores the id)
|
||||
const selectedTemplateProxy = computed({
|
||||
get() {
|
||||
return props.selectedTemplateOption || null;
|
||||
},
|
||||
set(opt) {
|
||||
props.form.import_template_id = opt ? opt.id : null;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Template</label>
|
||||
<Multiselect
|
||||
v-model="selectedTemplateProxy"
|
||||
:options="filteredTemplates"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Izberi predlogo..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
:custom-label="(o) => o.name"
|
||||
:disabled="filteredTemplates?.length === 0"
|
||||
:show-no-results="true"
|
||||
:clear-on-select="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 v-if="option.source_type" 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 v-if="option.source_type" 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>
|
||||
<template #noResult>
|
||||
<div class="px-2 py-1 text-xs text-gray-500">Ni predlog.</div>
|
||||
</template>
|
||||
</Multiselect>
|
||||
<div v-if="isCompleted" class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('preview')"
|
||||
class="px-3 py-1.5 bg-indigo-600 text-white rounded text-sm hover:bg-indigo-500 w-full sm:w-auto"
|
||||
>
|
||||
Ogled CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isCompleted" class="flex flex-col gap-3">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-gray-600">Header row</label>
|
||||
<select
|
||||
:value="hasHeader"
|
||||
@change="onHeaderChange"
|
||||
class="mt-1 block w-full border rounded p-2 text-sm"
|
||||
>
|
||||
<option value="true">Has header</option>
|
||||
<option value="false">No header (positional)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-gray-600">Delimiter</label>
|
||||
<select
|
||||
:value="delimiterState.mode"
|
||||
@change="onDelimiterMode"
|
||||
class="mt-1 block w-full border rounded p-2 text-sm"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="delimiterState.mode === 'custom'" class="flex items-end gap-3">
|
||||
<div class="w-40">
|
||||
<label class="block text-xs font-medium text-gray-600">Custom delimiter</label>
|
||||
<input
|
||||
:value="delimiterState.custom"
|
||||
@input="onDelimiterCustom"
|
||||
maxlength="4"
|
||||
placeholder=","
|
||||
class="mt-1 block w-full border rounded p-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isCompleted"
|
||||
class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm"
|
||||
:disabled="!form.import_template_id"
|
||||
@click="$emit('apply-template')"
|
||||
>
|
||||
{{ templateApplied ? "Ponovno uporabi predlogo" : "Uporabi predlogo" }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
// Generic currency formatting composable with fallback chain.
|
||||
// Usage:
|
||||
// const { formatMoney, currentCurrency } = useCurrencyFormat({
|
||||
// primary: clientCurrency, // e.g. 'EUR'
|
||||
// fallbacks: ['EUR'],
|
||||
// locale: 'sl-SI'
|
||||
// })
|
||||
// formatMoney(123.45) -> '123,45 €'
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export function useCurrencyFormat(options = {}) {
|
||||
const {
|
||||
primary = 'EUR',
|
||||
fallbacks = ['EUR'],
|
||||
locale = 'sl-SI',
|
||||
minimumFractionDigits = 2,
|
||||
maximumFractionDigits = 2,
|
||||
} = options
|
||||
|
||||
const primaryCurrency = ref(primary)
|
||||
const fallbackList = ref(Array.isArray(fallbacks) && fallbacks.length ? fallbacks : ['EUR'])
|
||||
|
||||
const currencyChain = computed(() => [primaryCurrency.value, ...fallbackList.value].filter(Boolean))
|
||||
|
||||
const formatterByCode = new Map()
|
||||
function getFormatter(code) {
|
||||
if (!code) return null
|
||||
if (!formatterByCode.has(code)) {
|
||||
try {
|
||||
formatterByCode.set(
|
||||
code,
|
||||
new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: code,
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
// invalid currency code – skip
|
||||
formatterByCode.set(code, null)
|
||||
}
|
||||
}
|
||||
return formatterByCode.get(code)
|
||||
}
|
||||
|
||||
const activeCurrency = computed(() => {
|
||||
for (const c of currencyChain.value) {
|
||||
if (getFormatter(c)) return c
|
||||
}
|
||||
return 'EUR'
|
||||
})
|
||||
|
||||
function formatMoney(val, overrideCurrency) {
|
||||
if (val === null || val === undefined || val === '' || isNaN(val)) return '—'
|
||||
const code = overrideCurrency || activeCurrency.value
|
||||
const fmt = getFormatter(code) || getFormatter('EUR')
|
||||
return fmt ? fmt.format(Number(val)) : Number(val).toFixed(2) + ' ' + code
|
||||
}
|
||||
|
||||
return {
|
||||
formatMoney,
|
||||
activeCurrency,
|
||||
primaryCurrency,
|
||||
setCurrency(code) { primaryCurrency.value = code },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function useEurFormat(locale = 'sl-SI', currency = 'EUR') {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
function formatEur(val) {
|
||||
if (val === null || val === undefined || val === '' || isNaN(val)) {
|
||||
return '—';
|
||||
}
|
||||
return formatter.format(Number(val));
|
||||
}
|
||||
return { formatEur };
|
||||
}
|
||||
Reference in New Issue
Block a user