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,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>