Importer update add support for meta data and multiple inserts for some entities like addresses and phones, updated other things

This commit is contained in:
Simon Pocrnjič
2025-10-09 22:28:48 +02:00
parent c8029c9eb0
commit 0598261cdc
27 changed files with 2517 additions and 375 deletions
+45 -5
View File
@@ -70,6 +70,14 @@ const logout = () => {
// Flash toast notifications (same as AppLayout for consistency)
const page = usePage();
const flash = computed(() => page.props.flash || {});
const isCompletedMode = computed(() => !!page.props.completed_mode);
// On mobile, always show labels in the overlay menu, regardless of collapsed state
const showLabels = computed(() => !sidebarCollapsed.value || isMobile.value);
// On mobile, force full width for the slide-out menu
const widthClass = computed(() =>
isMobile.value ? "w-64" : sidebarCollapsed.value ? "w-16" : "w-64"
);
const showToast = ref(false);
const toastMessage = ref("");
const toastType = ref("success");
@@ -113,7 +121,7 @@ const closeSearch = () => (searchOpen.value = false);
<!-- Sidebar -->
<aside
:class="[
sidebarCollapsed ? 'w-16' : 'w-64',
widthClass,
'bg-white border-r border-gray-200 transition-all duration-200 z-50',
isMobile
? 'fixed inset-y-0 left-0 transform ' +
@@ -124,18 +132,19 @@ const closeSearch = () => (searchOpen.value = false);
<div class="h-16 px-4 flex items-center justify-between border-b">
<Link :href="route('phone.index')" class="flex items-center gap-2">
<ApplicationMark class="h-8 w-auto" />
<span v-if="!sidebarCollapsed" class="text-sm font-semibold">Teren</span>
<span v-if="showLabels" class="text-sm font-semibold">Teren</span>
</Link>
</div>
<nav class="py-4">
<ul class="space-y-1">
<!-- Single phone link only -->
<!-- Assigned jobs link -->
<li>
<Link
:href="route('phone.index')"
:class="[
'flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100',
route().current('phone.index') || route().current('phone.*')
route().current('phone.index') ||
(route().current('phone.case') && !isCompletedMode)
? 'bg-gray-100 text-gray-900'
: 'text-gray-600',
]"
@@ -156,7 +165,38 @@ const closeSearch = () => (searchOpen.value = false);
d="M9 6.75H7.5A2.25 2.25 0 005.25 9v9A2.25 2.25 0 007.5 20.25h9A2.25 2.25 0 0018.75 18v-9A2.25 2.25 0 0016.5 6.75H15M9 6.75A1.5 1.5 0 0010.5 5.25h3A1.5 1.5 0 0015 6.75M9 6.75A1.5 1.5 0 0110.5 8.25h3A1.5 1.5 0 0015 6.75M9 12h.008v.008H9V12zm0 3h.008v.008H9V15zm3-3h3m-3 3h3"
/>
</svg>
<span v-if="!sidebarCollapsed">Opravila</span>
<span v-if="showLabels">Opravila</span>
</Link>
</li>
<!-- Completed today link -->
<li>
<Link
:href="route('phone.completed')"
:class="[
'flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100',
route().current('phone.completed') ||
(route().current('phone.case') && isCompletedMode)
? 'bg-gray-100 text-gray-900'
: 'text-gray-600',
]"
title="Zaključeno danes"
>
<!-- check-circle icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span v-if="showLabels">Zaključeno danes</span>
</Link>
</li>
</ul>
@@ -24,6 +24,7 @@ import {
faBoxArchive,
faFileWord,
faSpinner,
faTags,
} from "@fortawesome/free-solid-svg-icons";
const props = defineProps({
@@ -36,7 +37,10 @@ const props = defineProps({
// Debug: log incoming contract balances (remove after fix)
try {
console.debug('Contracts received (balances):', props.contracts.map(c => ({ ref: c.reference, bal: c?.account?.balance_amount })));
console.debug(
"Contracts received (balances):",
props.contracts.map((c) => ({ ref: c.reference, bal: c?.account?.balance_amount }))
);
} catch (e) {}
const emit = defineEmits(["edit", "delete", "add-activity"]);
@@ -52,6 +56,107 @@ const hasDesc = (c) => {
return typeof d === "string" && d.trim().length > 0;
};
// Meta helpers
const formatMetaDate = (v) => {
if (!v) {
return "-";
}
const d = new Date(v);
if (isNaN(d.getTime())) {
return String(v);
}
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
return `${dd}.${mm}.${yyyy}`;
};
const formatMetaNumber = (v) => {
if (v === null || v === undefined || v === "") {
return "0";
}
let n = typeof v === "number" ? v : parseFloat(String(v).replace(",", "."));
if (isNaN(n)) {
return String(v);
}
const hasDecimal = Math.abs(n % 1) > 0;
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: hasDecimal ? 2 : 0,
maximumFractionDigits: hasDecimal ? 2 : 0,
}).format(n);
};
const formatMetaValue = (entry) => {
const value = entry?.value;
const type = entry?.type;
if (value === null || value === undefined || String(value).trim() === "") {
return "-";
}
if (type === "date") {
return formatMetaDate(value);
}
if (type === "number") {
return formatMetaNumber(value);
}
if (typeof value === "number") {
return formatMetaNumber(value);
}
if (typeof value === "string") {
// Try number
const n = parseFloat(value.replace(",", "."));
if (!isNaN(n)) {
return formatMetaNumber(n);
}
// Try date
const d = new Date(value);
if (!isNaN(d.getTime())) {
return formatMetaDate(value);
}
}
return String(value);
};
const getMetaEntries = (c) => {
const meta = c?.meta;
const results = [];
const visit = (node, keyName) => {
if (node === null || node === undefined) {
return;
}
if (Array.isArray(node)) {
node.forEach((el) => visit(el));
return;
}
if (typeof node === "object") {
const hasValue = Object.prototype.hasOwnProperty.call(node, "value");
const hasTitle = Object.prototype.hasOwnProperty.call(node, "title");
if (hasValue || hasTitle) {
const title =
(node.title || keyName || "").toString().trim() || keyName || "Meta";
results.push({ title, value: node.value, type: node.type });
return;
}
for (const [k, v] of Object.entries(node)) {
visit(v, k);
}
return;
}
if (keyName) {
results.push({ title: keyName, value: node });
}
};
visit(meta, undefined);
return results.filter(
(e) =>
e.title &&
e.value !== null &&
e.value !== undefined &&
String(e.value).trim() !== ""
);
};
const hasMeta = (c) => getMetaEntries(c).length > 0;
const onEdit = (c) => emit("edit", c);
const onDelete = (c) => emit("delete", c);
const onAddActivity = (c) => emit("add-activity", c);
@@ -385,7 +490,7 @@ const closePaymentsDialog = () => {
}}</FwbTableCell>
<FwbTableCell class="text-center">
<div class="inline-flex items-center justify-center gap-0.5">
<Dropdown width="64" align="left">
<Dropdown align="right">
<template #trigger>
<button
type="button"
@@ -414,8 +519,50 @@ const closePaymentsDialog = () => {
</template>
</Dropdown>
<!-- Meta data dropdown -->
<Dropdown align="right">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-5 w-5 rounded-full"
:title="'Pokaži meta'"
:disabled="!hasMeta(c)"
:class="
hasMeta(c)
? 'hover:bg-gray-100 focus:outline-none'
: 'text-gray-400'
"
>
<FontAwesomeIcon
:icon="faTags"
class="h-4 w-4"
:class="hasMeta(c) ? 'text-gray-700' : 'text-gray-400'"
/>
</button>
</template>
<template #content>
<div class="max-w-sm px-3 py-2 text-sm text-gray-700">
<template v-if="hasMeta(c)">
<div
v-for="(m, idx) in getMetaEntries(c)"
:key="idx"
class="flex items-start gap-2 py-0.5"
>
<span class="text-gray-500 whitespace-nowrap"
>{{ m.title }}:</span
>
<span class="text-gray-800">{{ formatMetaValue(m) }}</span>
</div>
</template>
<template v-else>
<div class="text-gray-500">Ni meta podatkov.</div>
</template>
</div>
</template>
</Dropdown>
<!-- Promise date indicator -->
<Dropdown width="64" align="left">
<Dropdown align="right">
<template #trigger>
<button
type="button"
+250
View File
@@ -0,0 +1,250 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { ref } from "vue";
import { Link, router } from "@inertiajs/vue3";
import Pagination from "@/Components/Pagination.vue";
import PersonInfoGrid from "@/Components/PersonInfoGrid.vue";
import SectionTitle from "@/Components/SectionTitle.vue";
const props = defineProps({
client: Object,
contracts: Object,
filters: Object,
types: Object,
});
const fromDate = ref(props.filters?.from || "");
const toDate = ref(props.filters?.to || "");
const search = ref(props.filters?.search || "");
function applyDateFilter() {
const params = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
if (fromDate.value) {
params.from = fromDate.value;
} else {
delete params.from;
}
if (toDate.value) {
params.to = toDate.value;
} else {
delete params.to;
}
if (search.value && search.value.trim() !== "") {
params.search = search.value.trim();
} else {
delete params.search;
}
delete params.page;
router.get(route("client.contracts", { uuid: props.client.uuid }), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
}
function applySearch() {
const params = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
if (fromDate.value) {
params.from = fromDate.value;
} else {
delete params.from;
}
if (toDate.value) {
params.to = toDate.value;
} else {
delete params.to;
}
if (search.value && search.value.trim() !== "") {
params.search = search.value.trim();
} else {
delete params.search;
}
delete params.page;
router.get(route("client.contracts", { uuid: props.client.uuid }), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
}
// Build params for navigating to client case show, including active segment if available
function caseShowParams(contract) {
const params = { client_case: contract?.client_case?.uuid };
const segId = contract?.segments?.[0]?.id;
if (segId) {
params.segment = segId;
}
return params;
}
// Format YYYY-MM-DD (or ISO date) to dd.mm.yyyy
function formatDate(value) {
if (!value) return "-";
try {
const iso = String(value).split("T")[0];
const parts = iso.split("-");
if (parts.length !== 3) return value;
const [y, m, d] = parts;
return `${d.padStart(2, "0")}.${m.padStart(2, "0")}.${y}`;
} catch (e) {
return value;
}
}
</script>
<template>
<AppLayout title="Pogodbe">
<template #header></template>
<!-- Header card (matches Client/Show header style) -->
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
>
<div class="mx-auto max-w-4x1 p-3">
<div class="flex items-center justify-between">
<SectionTitle>
<template #title>
{{ client.person.full_name }}
</template>
</SectionTitle>
</div>
<div class="mt-2 flex items-center gap-3 text-sm">
<Link
:href="route('client.show', { uuid: client.uuid })"
class="px-2 py-1 rounded hover:underline"
>Primeri</Link
>
<span class="text-gray-300">|</span>
<Link
:href="route('client.contracts', { uuid: client.uuid })"
class="px-2 py-1 rounded text-indigo-600 hover:underline"
>Pogodbe</Link
>
</div>
</div>
</div>
</div>
</div>
<!-- Client details card (separate container) -->
<div class="pt-1">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
>
<div class="mx-auto max-w-4x1 px-2">
<PersonInfoGrid :types="types" :person="client.person" />
</div>
</div>
</div>
</div>
<!-- Contracts list card -->
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
<div class="mx-auto max-w-4x1 py-3">
<div
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
>
<div class="flex items-center gap-3 flex-wrap">
<label class="font-medium mr-2">Filter po datumu:</label>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">Od</span>
<input
type="date"
v-model="fromDate"
@change="applyDateFilter"
class="rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 text-sm"
/>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">Do</span>
<input
type="date"
v-model="toDate"
@change="applyDateFilter"
class="rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 text-sm"
/>
</div>
</div>
<div class="flex items-center gap-2">
<input
type="text"
v-model="search"
@keyup.enter="applySearch"
placeholder="Išči po referenci ali imenu"
class="rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 text-sm w-64"
/>
<button
type="button"
@click="applySearch"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded"
>
Išči
</button>
</div>
</div>
<div class="overflow-x-auto mt-3">
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">Referenca</th>
<th class="py-2 pr-4">Stranka</th>
<th class="py-2 pr-4">Začetek</th>
<th class="py-2 pr-4">Segment</th>
<th class="py-2 pr-4 text-right">Stanje</th>
</tr>
</thead>
<tbody>
<tr
v-for="contract in contracts.data"
:key="contract.uuid"
class="border-b last:border-0"
>
<td class="py-2 pr-4">
<Link
:href="route('clientCase.show', caseShowParams(contract))"
class="text-indigo-600 hover:underline"
>
{{ contract.reference }}
</Link>
</td>
<td class="py-2 pr-4">
{{ contract.client_case?.person?.full_name || "-" }}
</td>
<td class="py-2 pr-4">
{{ formatDate(contract.start_date) }}
</td>
<td class="py-2 pr-4">{{ contract.segments?.[0]?.name || "-" }}</td>
<td class="py-2 pr-4 text-right">
{{
new Intl.NumberFormat("sl-SI", {
style: "currency",
currency: "EUR",
}).format(Number(contract.account?.balance_amount ?? 0))
}}
</td>
</tr>
<tr v-if="!contracts.data || contracts.data.length === 0">
<td colspan="5" class="py-4 text-gray-500">Ni zadetkov.</td>
</tr>
</tbody>
</table>
</div>
</div>
<Pagination
:links="contracts.links"
:from="contracts.from"
:to="contracts.to"
:total="contracts.total"
/>
</div>
</div>
</div>
</AppLayout>
</template>
+21 -7
View File
@@ -52,12 +52,27 @@ const openDrawerCreateCase = () => {
<div
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
>
<div class="mx-auto max-w-4x1 p-3 flex justify-between">
<SectionTitle>
<template #title>
{{ client.person.full_name }}
</template>
</SectionTitle>
<div class="mx-auto max-w-4x1 p-3">
<div class="flex items-center justify-between">
<SectionTitle>
<template #title>
{{ client.person.full_name }}
</template>
</SectionTitle>
</div>
<div class="mt-2 flex items-center gap-3 text-sm">
<Link
:href="route('client.show', { uuid: client.uuid })"
class="px-2 py-1 rounded hover:underline"
>Primeri</Link
>
<span class="text-gray-300">|</span>
<Link
:href="route('client.contracts', { uuid: client.uuid })"
class="px-2 py-1 rounded text-indigo-600 hover:underline"
>Pogodbe</Link
>
</div>
</div>
</div>
</div>
@@ -115,7 +130,6 @@ const openDrawerCreateCase = () => {
{{ c.person?.full_name || "-" }}
</Link>
</td>
<td class="py-2 pr-4">{{ c.person?.tax_number || "-" }}</td>
<td class="py-2 pr-4 text-right">
{{ c.active_contracts_count ?? 0 }}
+32 -1
View File
@@ -130,6 +130,24 @@ function evaluateMappingSaved() {
persistedSignature.value = computeMappingSignature(mappingRows.value);
}
function normalizeOptions(val) {
if (!val) {
return {};
}
if (typeof val === "string") {
try {
const parsed = JSON.parse(val);
return parsed && typeof parsed === "object" ? parsed : {};
} catch (e) {
return {};
}
}
if (typeof val === "object") {
return val;
}
return {};
}
function computeMappingSignature(rows) {
return rows
.filter((r) => r && r.source_column)
@@ -270,6 +288,7 @@ function defaultEntityDefs() {
"description",
"type_id",
"client_case_id",
"meta",
],
},
{
@@ -359,6 +378,7 @@ const displayRows = computed(() => {
skip: false,
transform: "trim",
apply_mode: "both",
options: {},
position: idx,
}));
});
@@ -570,6 +590,7 @@ async function fetchColumns() {
skip: false,
transform: "trim",
apply_mode: "both",
options: {},
position: idx,
}));
suppressMappingWatch = false;
@@ -592,6 +613,7 @@ async function fetchColumns() {
skip: false,
transform: m.transform || "trim",
apply_mode: m.apply_mode || "both",
options: normalizeOptions(m.options),
position: idx,
};
});
@@ -685,6 +707,7 @@ async function loadImportMappings() {
field,
transform: m.transform || "",
apply_mode: m.apply_mode || "both",
options: normalizeOptions(m.options) || r.options || {},
skip: false,
position: idx,
};
@@ -738,7 +761,13 @@ async function saveMappings() {
target_field: `${entityKeyToRecord(r.entity)}.${r.field}`,
transform: r.transform || null,
apply_mode: r.apply_mode || "both",
options: null,
options:
r.field === "meta"
? {
key: r.options?.key ?? null,
type: r.options?.type ?? null,
}
: null,
}));
if (!mappings.length) {
mappingSaved.value = false;
@@ -820,6 +849,7 @@ onMounted(async () => {
skip: false,
transform: "trim",
apply_mode: "both",
options: {},
position: idx,
};
});
@@ -877,6 +907,7 @@ watch(
skip: false,
transform: "trim",
apply_mode: "both",
options: {},
position: idx,
};
});
@@ -35,6 +35,8 @@ function duplicateTarget(row){
<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">Meta key</th>
<th class="p-2 border">Meta type</th>
<th class="p-2 border">Transform</th>
<th class="p-2 border">Apply mode</th>
<th class="p-2 border">Skip</th>
@@ -55,6 +57,32 @@ function duplicateTarget(row){
<option v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</option>
</select>
</td>
<td class="p-2 border">
<input
v-if="row.field === 'meta'"
v-model="(row.options ||= {}).key"
type="text"
class="border rounded p-1 w-full"
placeholder="e.g. monthly_rent"
:disabled="isCompleted"
/>
<span v-else class="text-gray-400 text-xs"></span>
</td>
<td class="p-2 border">
<select
v-if="row.field === 'meta'"
v-model="(row.options ||= {}).type"
class="border rounded p-1 w-full"
:disabled="isCompleted"
>
<option :value="null">Default (string)</option>
<option value="string">string</option>
<option value="number">number</option>
<option value="date">date</option>
<option value="boolean">boolean</option>
</select>
<span v-else class="text-gray-400 text-xs"></span>
</td>
<td class="p-2 border">
<select v-model="row.transform" class="border rounded p-1 w-full" :disabled="isCompleted">
<option value="">None</option>
@@ -1,5 +1,5 @@
<script setup>
const props = defineProps({ mappings: Array })
const props = defineProps({ mappings: Array });
</script>
<template>
<div v-if="mappings?.length" class="pt-4">
@@ -12,14 +12,30 @@ const props = defineProps({ mappings: Array })
<th class="p-2 border">Target field</th>
<th class="p-2 border">Transform</th>
<th class="p-2 border">Mode</th>
<th class="p-2 border">Options</th>
</tr>
</thead>
<tbody>
<tr v-for="m in mappings" :key="m.id || (m.source_column + m.target_field)" class="border-t">
<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>
<td class="p-2 border">{{ m.transform || "—" }}</td>
<td class="p-2 border">{{ m.apply_mode || "both" }}</td>
<td class="p-2 border">
<template v-if="m.options">
<span v-if="m.options.key" class="inline-block mr-2"
>key: <strong>{{ m.options.key }}</strong></span
>
<span v-if="m.options.type" class="inline-block"
>type: <strong>{{ m.options.type }}</strong></span
>
</template>
<span v-else></span>
</td>
</tr>
</tbody>
</table>
@@ -99,23 +99,45 @@ const entityStats = computed(() => {
if (!r.entities) continue;
for (const [k, ent] of Object.entries(r.entities)) {
if (!stats[k]) continue;
// Count one row per entity root
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 (Array.isArray(ent)) {
for (const item of ent) {
switch (item.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 (item.duplicate) stats[k].duplicate++;
if (item.duplicate_db) stats[k].duplicate_db++;
}
} else {
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++;
}
if (ent.duplicate) stats[k].duplicate++;
if (ent.duplicate_db) stats[k].duplicate_db++;
}
}
return stats;
@@ -134,7 +156,9 @@ const visibleRows = computed(() => {
.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 (!Array.isArray(ent)) {
if (hideChain.value && ent.existing_chain) return false;
}
if (showOnlyChanged.value) {
// Define change criteria per entity
if (activeEntity.value === "account") {
@@ -148,6 +172,9 @@ const visibleRows = computed(() => {
return ent.amount !== null && ent.amount !== undefined;
}
// Generic entities: any create/update considered change
if (Array.isArray(ent)) {
return ent.some((i) => i && (i.action === "create" || i.action === "update"));
}
if (ent.action === "create" || ent.action === "update") return true;
return false;
}
@@ -371,36 +398,45 @@ function referenceOf(entityName, ent) {
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',
r.entities[activeEntity].action === 'create' && 'bg-emerald-100 text-emerald-700',
r.entities[activeEntity].action === 'update' && 'bg-blue-100 text-blue-700',
r.entities[activeEntity].action === 'reactivate' && 'bg-purple-100 text-purple-700 font-semibold',
r.entities[activeEntity].action === 'skip' && 'bg-gray-100 text-gray-600',
r.entities[activeEntity].action === 'implicit' && 'bg-teal-100 text-teal-700'
].filter(Boolean)"
>{{ 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
>
<template v-if="!Array.isArray(r.entities[activeEntity])">
<span
v-if="r.entities[activeEntity].action_label"
:class="
[
'text-[10px] px-1 py-0.5 rounded',
r.entities[activeEntity].action === 'create' &&
'bg-emerald-100 text-emerald-700',
r.entities[activeEntity].action === 'update' &&
'bg-blue-100 text-blue-700',
r.entities[activeEntity].action === 'reactivate' &&
'bg-purple-100 text-purple-700 font-semibold',
r.entities[activeEntity].action === 'skip' &&
'bg-gray-100 text-gray-600',
r.entities[activeEntity].action === 'implicit' &&
'bg-teal-100 text-teal-700',
].filter(Boolean)
"
>{{ 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
>
</template>
</div>
<template v-if="activeEntity === 'account'">
@@ -510,10 +546,13 @@ function referenceOf(entityName, ent) {
<div>
Akcija:
<span
:class="[
'font-medium inline-flex items-center gap-1',
r.entities[activeEntity].action === 'reactivate' && 'text-purple-700'
].filter(Boolean)"
:class="
[
'font-medium inline-flex items-center gap-1',
r.entities[activeEntity].action === 'reactivate' &&
'text-purple-700',
].filter(Boolean)
"
>{{
r.entities[activeEntity].action_label ||
r.entities[activeEntity].action
@@ -526,179 +565,319 @@ function referenceOf(entityName, ent) {
></span
>
</div>
<div v-if="r.entities[activeEntity].original_action === 'update' && r.entities[activeEntity].action === 'reactivate'" class="text-[10px] text-purple-600 mt-0.5">
<div
v-if="
r.entities[activeEntity].original_action === 'update' &&
r.entities[activeEntity].action === 'reactivate'
"
class="text-[10px] text-purple-600 mt-0.5"
>
(iz neaktivnega aktivno)
</div>
<div
v-if="r.entities[activeEntity].meta"
class="mt-1 text-[10px] text-gray-700"
>
<div class="font-semibold text-gray-600">Meta</div>
<div class="space-y-1">
<div
v-for="(entries, grp) in r.entities[activeEntity].meta"
:key="grp"
class="border rounded p-1 bg-white"
>
<div class="text-[9px] text-gray-500 mb-0.5">
skupina: {{ grp }}
</div>
<div
v-for="(entry, key) in entries"
:key="key"
class="flex items-center gap-2"
>
<span class="text-gray-500">{{ key }}:</span>
<span class="text-gray-800">{{ entry?.value ?? "—" }}</span>
<span class="text-gray-400">(iz: {{ entry?.title }})</span>
</div>
</div>
</div>
</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">
<!-- Multi-item rendering for grouped roots (email/phone/address) -->
<template v-if="Array.isArray(r.entities[activeEntity])">
<div class="space-y-1">
<div
v-if="
referenceOf(activeEntity, r.entities[activeEntity]) !== '—'
"
class="text-[10px] text-gray-600"
v-for="(item, idx) in r.entities[activeEntity]"
:key="idx"
class="border rounded p-2 bg-white"
>
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 class="flex items-center justify-between mb-1">
<div class="flex items-center gap-1">
<span
v-if="
item.group !== undefined &&
item.group !== null &&
String(item.group) !== ''
"
class="text-[10px] px-1 py-0.5 rounded bg-gray-100 text-gray-700"
>skupina: {{ item.group }}</span
>
<span
v-if="item.identity_used"
class="px-1 py-0.5 rounded bg-indigo-50 text-indigo-700 text-[10px]"
title="Uporabljena identiteta"
>{{ item.identity_used }}</span
>
<span
v-if="item.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="item.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>
<span
v-if="item.action_label"
:class="
[
'text-[10px] px-1 py-0.5 rounded',
item.action === 'create' &&
'bg-emerald-100 text-emerald-700',
item.action === 'update' &&
'bg-blue-100 text-blue-700',
item.action === 'skip' && 'bg-gray-100 text-gray-600',
].filter(Boolean)
"
>{{ item.action_label || item.action }}</span
>
</div>
<template v-if="activeEntity === 'email'">
<div class="text-[10px] text-gray-600">
Email:
<span class="font-medium">{{
referenceOf(activeEntity, item)
}}</span>
</div>
</template>
<template v-else-if="activeEntity === 'phone'">
<div class="text-[10px] text-gray-600">
Telefon:
<span class="font-medium">{{
referenceOf(activeEntity, item)
}}</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, item) !== '—'">
Ref:
<span class="font-medium">{{
referenceOf(activeEntity, item)
}}</span>
</div>
<div v-if="item.address">
Naslov:
<span class="font-medium">{{ item.address }}</span>
</div>
<div v-if="item.postal_code || item.country">
Lokacija:
<span class="font-medium">{{
[item.postal_code, item.country]
.filter(Boolean)
.join(" ")
}}</span>
</div>
</div>
</template>
<template v-else>
<pre class="text-[10px] whitespace-pre-wrap">{{
item
}}</pre>
</template>
</div>
</div>
</template>
<!-- Single-item generic rendering (existing) -->
<template v-else>
<pre class="text-[10px] whitespace-pre-wrap">{{
r.entities[activeEntity]
}}</pre>
<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>
</template>
</div>
+134 -2
View File
@@ -55,6 +55,7 @@ const bulkGlobal = ref({
default_field: "",
transform: "",
apply_mode: "both",
group: "",
});
const unassigned = computed(() =>
(props.template.mappings || []).filter((m) => !m.target_field)
@@ -104,6 +105,21 @@ function saveUnassigned(m) {
} else {
m.target_field = null;
}
if (st.group) {
m.options = m.options && typeof m.options === "object" ? m.options : {};
m.options.group = st.group;
}
// If targeting any .meta field, allow setting options.key via UI
if (st.field === "meta") {
if (st.metaKey && String(st.metaKey).trim() !== "") {
m.options = m.options && typeof m.options === "object" ? m.options : {};
m.options.key = String(st.metaKey).trim();
}
if (st.metaType && String(st.metaType).trim() !== "") {
m.options = m.options && typeof m.options === "object" ? m.options : {};
m.options.type = String(st.metaType).trim();
}
}
updateMapping(m);
}
@@ -141,13 +157,22 @@ function addRow(entity) {
const row = newRows.value[entity];
if (!row || !row.source || !row.field) return;
const target_field = `${entity}.${row.field}`;
const opts = {};
if (row.group) opts.group = row.group;
if (entity === "contract" && row.field === "meta" && row.metaKey) {
opts.key = String(row.metaKey).trim();
}
const payload = {
source_column: row.source,
target_field,
transform: row.transform || null,
apply_mode: row.apply_mode || "both",
options: Object.keys(opts).length ? opts : null,
position: (props.template.mappings?.length || 0) + 1,
};
if (row.field === "meta" && row.metaType) {
opts.type = String(row.metaType).trim();
}
useForm(payload).post(
route("importTemplates.mappings.add", { template: props.template.uuid }),
{
@@ -165,6 +190,7 @@ function updateMapping(m) {
target_field: m.target_field,
transform: m.transform,
apply_mode: m.apply_mode,
options: m.options || null,
position: m.position,
};
useForm(payload).put(
@@ -602,6 +628,15 @@ watch(
<option value="update">update</option>
</select>
</div>
<div>
<label class="block text-xs text-indigo-900">Group (za vse)</label>
<input
v-model="bulkGlobal.group"
type="text"
class="mt-1 w-full border rounded p-2"
placeholder="1, 2, home, work"
/>
</div>
</div>
<div class="mt-3">
<button
@@ -614,6 +649,7 @@ watch(
default_field: bulkGlobal.default_field || null,
transform: bulkGlobal.transform || null,
apply_mode: bulkGlobal.apply_mode || 'both',
group: bulkGlobal.group || '',
}).post(
route('importTemplates.mappings.bulk', {
template: props.template.uuid,
@@ -626,6 +662,7 @@ watch(
bulkGlobal.default_field = '';
bulkGlobal.transform = '';
bulkGlobal.apply_mode = 'both';
bulkGlobal.group = '';
},
}
);
@@ -710,6 +747,39 @@ watch(
</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Group</label>
<input
v-model="(unassignedState[m.id] ||= {}).group"
class="mt-1 w-full border rounded p-2"
type="text"
placeholder="1, 2, home, work"
/>
</div>
<div v-if="(unassignedState[m.id] || {}).field === 'meta'">
<label class="block text-xs text-gray-600">Meta key</label>
<input
v-model="(unassignedState[m.id] ||= {}).metaKey"
class="mt-1 w-full border rounded p-2"
type="text"
placeholder="npr.: note, category"
/>
<label class="block text-xs text-gray-600 mt-2">Meta type</label>
<select
v-model="(unassignedState[m.id] ||= {}).metaType"
class="mt-1 w-full border rounded p-2"
>
<option value="">(auto/string)</option>
<option value="string">string</option>
<option value="number">number</option>
<option value="date">date</option>
<option value="boolean">boolean</option>
</select>
<p class="text-[11px] text-gray-500 mt-1">
Če ne določiš, lahko uporabiš tudi zapis cilja kot
<code>contract.meta[key]</code>.
</p>
</div>
<div>
<label class="block text-xs text-gray-600">Transform</label>
<select v-model="m.transform" class="mt-1 w-full border rounded p-2">
@@ -800,7 +870,7 @@ watch(
class="flex items-center justify-between p-2 border rounded gap-3"
>
<div
class="grid grid-cols-1 sm:grid-cols-5 gap-2 flex-1 items-center"
class="grid grid-cols-1 sm:grid-cols-6 gap-2 flex-1 items-center"
>
<input
v-model="m.source_column"
@@ -822,6 +892,28 @@ watch(
<option value="update">update</option>
<option value="keyref">keyref (use as lookup key)</option>
</select>
<input
v-model="(m.options ||= {}).group"
class="border rounded p-2 text-sm"
placeholder="Group"
/>
<input
v-if="/^(contracts?\.meta)(\.|\[|$)/.test(m.target_field || '')"
v-model="(m.options ||= {}).key"
class="border rounded p-2 text-sm"
placeholder="Meta key"
/>
<select
v-if="/^(contracts?\.meta)(\.|\[|$)/.test(m.target_field || '')"
v-model="(m.options ||= {}).type"
class="border rounded p-2 text-sm"
>
<option value="">(auto/string)</option>
<option value="string">string</option>
<option value="number">number</option>
<option value="date">date</option>
<option value="boolean">boolean</option>
</select>
<div class="flex items-center gap-2">
<button
class="px-2 py-1 text-xs border rounded"
@@ -859,7 +951,7 @@ watch(
<!-- Add new mapping row -->
<div class="p-3 bg-gray-50 rounded border">
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 items-end">
<div class="grid grid-cols-1 sm:grid-cols-6 gap-2 items-end">
<div>
<label class="block text-xs text-gray-600"
>Source column (ne-dodeljene)</label
@@ -919,6 +1011,35 @@ watch(
<option value="keyref">keyref (use as lookup key)</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Group</label>
<input
v-model="(newRows[entity] ||= {}).group"
class="mt-1 w-full border rounded p-2"
type="text"
placeholder="1, 2, home, work"
/>
</div>
<div v-if="(newRows[entity] || {}).field === 'meta'">
<label class="block text-xs text-gray-600">Meta key</label>
<input
v-model="(newRows[entity] ||= {}).metaKey"
class="mt-1 w-full border rounded p-2"
type="text"
placeholder="npr.: note, category"
/>
<label class="block text-xs text-gray-600 mt-2">Meta type</label>
<select
v-model="(newRows[entity] ||= {}).metaType"
class="mt-1 w-full border rounded p-2"
>
<option value="">(auto/string)</option>
<option value="string">string</option>
<option value="number">number</option>
<option value="date">date</option>
<option value="boolean">boolean</option>
</select>
</div>
<div class="sm:col-span-1">
<button
@click.prevent="addRow(entity)"
@@ -992,6 +1113,15 @@ watch(
<option value="keyref">keyref (use as lookup key)</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Group (za vse)</label>
<input
v-model="(bulkRows[entity] ||= {}).group"
class="mt-1 w-full border rounded p-2"
type="text"
placeholder="1, 2, home, work"
/>
</div>
</div>
<div class="mt-2">
<button
@@ -1006,6 +1136,7 @@ watch(
default_field: b.default_field || null,
transform: b.transform || null,
apply_mode: b.apply_mode || 'both',
group: b.group || '',
}).post(
route('importTemplates.mappings.bulk', {
template: props.template.uuid,
@@ -1051,6 +1182,7 @@ watch(
target_field: `${s.entity}.${s.field}`,
transform: b.transform || null,
apply_mode: b.apply_mode || 'both',
options: b.group ? { group: b.group } : null,
position: (props.template.mappings?.length || 0) + 1,
};
useForm(payload).post(
+14
View File
@@ -24,6 +24,7 @@ const props = defineProps({
types: Object,
actions: Array,
activities: Array,
completed_mode: { type: Boolean, default: false },
});
const viewer = reactive({ open: false, src: "", title: "" });
@@ -206,7 +207,14 @@ const clientSummary = computed(() => {
</h2>
</div>
<div class="shrink-0">
<span
v-if="props.completed_mode"
class="inline-flex items-center px-2 py-1 rounded-full bg-emerald-100 text-emerald-700 text-xs font-medium"
>
Zaključeno danes
</span>
<button
v-else
type="button"
class="px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700"
@click="confirmComplete = true"
@@ -247,6 +255,12 @@ const clientSummary = computed(() => {
<span class="truncate">{{ client_case.person.full_name }}</span>
<span class="chip-base chip-indigo">Primer</span>
</h3>
<div
v-if="client_case?.person?.description"
class="mt-2 text-sm text-gray-700 whitespace-pre-line"
>
{{ client_case.person.description }}
</div>
<div class="mt-4 pt-4 border-t border-dashed">
<PersonDetailPhone
:types="types"
+151 -39
View File
@@ -1,103 +1,215 @@
<script setup>
import AppPhoneLayout from '@/Layouts/AppPhoneLayout.vue';
import { computed, ref } from 'vue';
import AppPhoneLayout from "@/Layouts/AppPhoneLayout.vue";
import { computed, ref } from "vue";
const props = defineProps({
jobs: { type: Array, default: () => [] },
view_mode: { type: String, default: "assigned" }, // 'assigned' | 'completed-today'
});
const items = computed(() => props.jobs || []);
// Client filter options derived from jobs
const clientFilter = ref("");
const clientOptions = computed(() => {
const map = new Map();
for (const job of items.value) {
const client = job?.contract?.client_case?.client;
const uuid = client?.uuid;
const name = client?.person?.full_name;
if (uuid && name && !map.has(uuid)) {
map.set(uuid, { uuid, name });
}
}
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
});
// Search filter (contract reference or person full name)
const search = ref('');
const search = ref("");
const filteredJobs = computed(() => {
const term = search.value.trim().toLowerCase();
if (!term) return items.value;
return items.value.filter(job => {
const refStr = (job.contract?.reference || job.contract?.uuid || '').toString().toLowerCase();
const nameStr = (job.contract?.client_case?.person?.full_name || '').toLowerCase();
return refStr.includes(term) || nameStr.includes(term);
return items.value.filter((job) => {
// Filter by selected client (if any)
if (clientFilter.value) {
const juuid = job?.contract?.client_case?.client?.uuid;
if (juuid !== clientFilter.value) {
return false;
}
}
// Text search
if (!term) return true;
const refStr = (job.contract?.reference || job.contract?.uuid || "")
.toString()
.toLowerCase();
const nameStr = (job.contract?.client_case?.person?.full_name || "").toLowerCase();
const clientNameStr = (
job.contract?.client_case?.client?.person?.full_name || ""
).toLowerCase();
return (
refStr.includes(term) || nameStr.includes(term) || clientNameStr.includes(term)
);
});
});
function formatDateDMY(d) {
if (!d) return '-';
if (!d) return "-";
// Handle date-only strings from Laravel JSON casts (YYYY-MM-DD...)
if (/^\d{4}-\d{2}-\d{2}/.test(d)) {
const [y, m, rest] = d.split('-');
const day = (rest || '').slice(0, 2) || '01';
const [y, m, rest] = d.split("-");
const day = (rest || "").slice(0, 2) || "01";
return `${day}.${m}.${y}`;
}
const dt = new Date(d);
if (Number.isNaN(dt.getTime())) return String(d);
const dd = String(dt.getDate()).padStart(2, '0');
const mm = String(dt.getMonth() + 1).padStart(2, '0');
const dd = String(dt.getDate()).padStart(2, "0");
const mm = String(dt.getMonth() + 1).padStart(2, "0");
const yyyy = dt.getFullYear();
return `${dd}.${mm}.${yyyy}`;
}
function formatAmount(val) {
if (val === null || val === undefined) return '0,00';
const num = typeof val === 'number' ? val : parseFloat(val);
if (val === null || val === undefined) return "0,00";
const num = typeof val === "number" ? val : parseFloat(val);
if (Number.isNaN(num)) return String(val);
return num.toLocaleString('sl-SI', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return num.toLocaleString("sl-SI", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
// Safely resolve a client case UUID for a job
function getCaseUuid(job) {
return (
job?.contract?.client_case?.uuid || job?.client_case?.uuid || job?.case_uuid || null
);
}
</script>
<template>
<AppPhoneLayout title="Phone">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Moja terenska opravila</h2>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{
props.view_mode === "completed-today"
? "Zaključena opravila danes"
: "Moja terenska opravila"
}}
</h2>
</template>
<div class="py-4 sm:py-8">
<div class="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">
<div class="mb-4 flex items-center gap-2">
<input v-model="search" type="text" placeholder="Išči po referenci ali imenu..."
class="flex-1 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
<button v-if="search" type="button" @click="search = ''"
class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-600">Počisti</button>
<div class="mb-4 flex items-center gap-2 flex-wrap">
<select
v-model="clientFilter"
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="">Vsi naročniki</option>
<option v-for="c in clientOptions" :key="c.uuid" :value="c.uuid">
{{ c.name }}
</option>
</select>
<input
v-model="search"
type="text"
placeholder="Išči po referenci ali imenu..."
class="flex-1 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
<button
v-if="search"
type="button"
@click="search = ''"
class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-600"
>
Počisti
</button>
</div>
<div class="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3">
<template v-if="filteredJobs.length">
<div v-for="job in filteredJobs" :key="job.id" class="bg-white rounded-lg shadow border p-3 sm:p-4">
<div
v-for="job in filteredJobs"
:key="job.id"
class="bg-white rounded-lg shadow border p-3 sm:p-4"
>
<div class="mb-4 flex gap-2">
<a :href="route('phone.case', { client_case: job.contract?.client_case?.uuid })"
class="inline-flex-1 flex-1 text-center px-3 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700">Odpri
primer</a>
<a
v-if="getCaseUuid(job)"
:href="
route('phone.case', {
client_case: getCaseUuid(job),
completed: props.view_mode === 'completed-today' ? 1 : undefined,
})
"
class="inline-flex-1 flex-1 text-center px-3 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700"
>
Odpri primer
</a>
<button
v-else
type="button"
disabled
class="inline-flex-1 flex-1 text-center px-3 py-2 rounded-md bg-gray-300 text-gray-600 text-sm cursor-not-allowed"
>
Manjka primer
</button>
</div>
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500">Dodeljeno: <span class="font-medium text-gray-700">{{
formatDateDMY(job.assigned_at) }}</span></p>
<span v-if="job.priority"
class="inline-block text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700">Prioriteta</span>
<p class="text-sm text-gray-500">
Dodeljeno:
<span class="font-medium text-gray-700">{{
formatDateDMY(job.assigned_at)
}}</span>
</p>
<span
v-if="job.priority"
class="inline-block text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700"
>Prioriteta</span
>
</div>
<div class="mt-2">
<p class="text-base sm:text-lg font-semibold text-gray-800">
{{ job.contract?.client_case?.person?.full_name || '—' }}
{{ job.contract?.client_case?.person?.full_name || "—" }}
</p>
<p class="text-sm text-gray-600 truncate">Kontrakt: {{ job.contract?.reference || job.contract?.uuid }}
<p class="text-sm text-gray-600">
Naročnik:
<span class="font-semibold text-gray-800">
{{ job.contract?.client_case?.client?.person?.full_name || "—" }}
</span>
</p>
<p class="text-sm text-gray-600">Tip: {{ job.contract?.type?.name || '—' }}</p>
<p class="text-sm text-gray-600"
v-if="job.contract?.account && job.contract.account.balance_amount !== null && job.contract.account.balance_amount !== undefined">
<p class="text-sm text-gray-600 truncate">
Kontrakt: {{ job.contract?.reference || job.contract?.uuid }}
</p>
<p class="text-sm text-gray-600">
Tip: {{ job.contract?.type?.name || "—" }}
</p>
<p
class="text-sm text-gray-600"
v-if="
job.contract?.account &&
job.contract.account.balance_amount !== null &&
job.contract.account.balance_amount !== undefined
"
>
Odprto: {{ formatAmount(job.contract.account.balance_amount) }}
</p>
</div>
<div class="mt-3 text-sm text-gray-600">
<p>
<span class="font-medium">Naslov:</span>
{{ job.contract?.client_case?.person?.addresses?.[0]?.address || '—' }}
{{ job.contract?.client_case?.person?.addresses?.[0]?.address || "—" }}
</p>
<p>
<span class="font-medium">Telefon:</span>
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || '—' }}
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || "—" }}
</p>
</div>
</div>
</template>
<div v-else class="col-span-full bg-white rounded-lg shadow border p-6 text-center text-gray-600">
<div
v-else
class="col-span-full bg-white rounded-lg shadow border p-6 text-center text-gray-600"
>
<span v-if="search">Ni zadetkov za podani filter.</span>
<span v-else>Trenutno nimate dodeljenih terenskih opravil.</span>
</div>