Mass changes

This commit is contained in:
Simon Pocrnjič
2025-10-04 23:36:18 +02:00
parent ab50336e97
commit fe91c7e4bc
46 changed files with 5738 additions and 1873 deletions
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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>