added option to import payments from csv file

This commit is contained in:
Simon Pocrnjič
2025-10-02 22:09:05 +02:00
parent 971a9e89d1
commit 12de0186cf
21 changed files with 2828 additions and 824 deletions
+532 -254
View File
@@ -1,18 +1,18 @@
<script setup>
import { onMounted, onUnmounted, ref, watch, computed } from 'vue';
import { Head, Link, router, usePage } from '@inertiajs/vue3';
import ApplicationMark from '@/Components/ApplicationMark.vue';
import Banner from '@/Components/Banner.vue';
import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import Breadcrumbs from '@/Components/Breadcrumbs.vue';
import GlobalSearch from './Partials/GlobalSearch.vue';
import NotificationsBell from './Partials/NotificationsBell.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faMobileScreenButton } from '@fortawesome/free-solid-svg-icons';
import { onMounted, onUnmounted, ref, watch, computed } from "vue";
import { Head, Link, router, usePage } from "@inertiajs/vue3";
import ApplicationMark from "@/Components/ApplicationMark.vue";
import Banner from "@/Components/Banner.vue";
import Dropdown from "@/Components/Dropdown.vue";
import DropdownLink from "@/Components/DropdownLink.vue";
import Breadcrumbs from "@/Components/Breadcrumbs.vue";
import GlobalSearch from "./Partials/GlobalSearch.vue";
import NotificationsBell from "./Partials/NotificationsBell.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faMobileScreenButton } from "@fortawesome/free-solid-svg-icons";
const props = defineProps({
title: String,
title: String,
});
// Collapsible sidebar state (persisted when user explicitly toggles)
@@ -22,33 +22,35 @@ const hasSavedSidebarPref = ref(false);
const isMobile = ref(false);
const mobileSidebarOpen = ref(false);
function applyAutoCollapse() {
if (typeof window === 'undefined') return;
isMobile.value = window.innerWidth < 1024; // Tailwind lg breakpoint
sidebarCollapsed.value = isMobile.value;
if (typeof window === "undefined") return;
isMobile.value = window.innerWidth < 1024; // Tailwind lg breakpoint
sidebarCollapsed.value = isMobile.value;
}
function handleResize() {
if (typeof window !== 'undefined') {
isMobile.value = window.innerWidth < 1024;
if (!isMobile.value) mobileSidebarOpen.value = false; // close drawer when switching to desktop
}
if (!hasSavedSidebarPref.value) applyAutoCollapse();
if (typeof window !== "undefined") {
isMobile.value = window.innerWidth < 1024;
if (!isMobile.value) mobileSidebarOpen.value = false; // close drawer when switching to desktop
}
if (!hasSavedSidebarPref.value) applyAutoCollapse();
}
onMounted(() => {
try {
const saved = localStorage.getItem('sidebarCollapsed');
if (saved !== null) {
hasSavedSidebarPref.value = true;
sidebarCollapsed.value = saved === '1';
} else {
applyAutoCollapse();
}
} catch {}
window.addEventListener('resize', handleResize);
try {
const saved = localStorage.getItem("sidebarCollapsed");
if (saved !== null) {
hasSavedSidebarPref.value = true;
sidebarCollapsed.value = saved === "1";
} else {
applyAutoCollapse();
}
} catch {}
window.addEventListener("resize", handleResize);
});
onUnmounted(() => window.removeEventListener('resize', handleResize));
onUnmounted(() => window.removeEventListener("resize", handleResize));
watch(sidebarCollapsed, (v) => {
if (!hasSavedSidebarPref.value) return; // don't persist auto behavior
try { localStorage.setItem('sidebarCollapsed', v ? '1' : '0'); } catch {}
if (!hasSavedSidebarPref.value) return; // don't persist auto behavior
try {
localStorage.setItem("sidebarCollapsed", v ? "1" : "0");
} catch {}
});
// Global search modal state
@@ -58,265 +60,541 @@ const closeSearch = () => (searchOpen.value = false);
// Keyboard shortcut: Ctrl+K / Cmd+K to open search
function onKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
openSearch();
}
if (e.key === 'Escape' && mobileSidebarOpen.value) {
mobileSidebarOpen.value = false;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
openSearch();
}
if (e.key === "Escape" && mobileSidebarOpen.value) {
mobileSidebarOpen.value = false;
}
}
onMounted(() => window.addEventListener('keydown', onKeydown));
onUnmounted(() => window.removeEventListener('keydown', onKeydown));
onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
function toggleSidebar() {
hasSavedSidebarPref.value = true; // user explicitly chose
sidebarCollapsed.value = !sidebarCollapsed.value;
hasSavedSidebarPref.value = true; // user explicitly chose
sidebarCollapsed.value = !sidebarCollapsed.value;
}
function toggleMobileSidebar() {
mobileSidebarOpen.value = !mobileSidebarOpen.value;
mobileSidebarOpen.value = !mobileSidebarOpen.value;
}
function handleSidebarToggleClick() {
if (isMobile.value) toggleMobileSidebar();
else toggleSidebar();
if (isMobile.value) toggleMobileSidebar();
else toggleSidebar();
}
const logout = () => {
router.post(route('logout'));
router.post(route("logout"));
};
// Flash toast notifications
const page = usePage();
const flash = computed(() => page.props.flash || {});
const showToast = ref(false);
const toastMessage = ref('');
const toastType = ref('success');
const toastMessage = ref("");
const toastType = ref("success");
watch(
() => [flash.value.success, flash.value.error, flash.value.warning, flash.value.info],
([s, e, w, i]) => {
const message = s || e || w || i;
const type = s ? 'success' : e ? 'error' : w ? 'warning' : i ? 'info' : null;
if (message && type) {
toastMessage.value = message;
toastType.value = type;
showToast.value = true;
// auto-hide after 3s
setTimeout(() => (showToast.value = false), 3000);
}
},
{ immediate: true }
() => [flash.value.success, flash.value.error, flash.value.warning, flash.value.info],
([s, e, w, i]) => {
const message = s || e || w || i;
const type = s ? "success" : e ? "error" : w ? "warning" : i ? "info" : null;
if (message && type) {
toastMessage.value = message;
toastType.value = type;
showToast.value = true;
// auto-hide after 3s
setTimeout(() => (showToast.value = false), 3000);
}
},
{ immediate: true }
);
// No automatic daily notifications
// Sidebar menu groups (sorted alphabetically within each group)
const rawMenuGroups = [
{
label: "Glavno",
items: [
{
key: "dashboard",
title: "Nadzorna plošča",
routeName: "dashboard",
active: ["dashboard"],
},
],
},
{
label: "Stranke",
items: [
{
key: "clients",
title: "Naročniki",
routeName: "client",
active: ["client", "client.*"],
},
{
key: "cases",
title: "Primeri",
routeName: "clientCase",
active: ["clientCase", "clientCase.*"],
},
{
key: "segments",
title: "Segmenti",
routeName: "segments.index",
active: ["segments.index"],
},
],
},
{
label: "Uvoz",
items: [
{
key: "imports",
title: "Uvozi",
routeName: "imports.index",
active: ["imports.index", "imports.*"],
},
{
key: "import-templates",
title: "Uvozne predloge",
routeName: "importTemplates.index",
active: ["importTemplates.index"],
},
{
key: "import-templates-new",
title: "Nova uvozna predloga",
routeName: "importTemplates.create",
active: ["importTemplates.create"],
},
],
},
{
label: "Terensko",
items: [
{
key: "fieldjobs",
title: "Terenske naloge",
routeName: "fieldjobs.index",
active: ["fieldjobs.index"],
},
],
},
{
label: "Konfiguracija",
items: [
{
key: "settings",
title: "Nastavitve",
routeName: "settings",
active: ["settings", "settings.*"],
},
],
},
];
const menuGroups = computed(() => {
return rawMenuGroups.map((g) => ({
label: g.label,
items: [...g.items].sort((a, b) =>
a.title.localeCompare(b.title, "sl", { sensitivity: "base" })
),
}));
});
function isActive(patterns) {
try {
return patterns?.some((p) => route().current(p));
} catch {
return false;
}
}
</script>
<template>
<div>
<Head :title="title" />
<div>
<Head :title="title" />
<Banner />
<Banner />
<div class="min-h-screen bg-gray-100 flex">
<!-- Mobile backdrop -->
<div v-if="isMobile && mobileSidebarOpen" class="fixed inset-0 z-40 bg-black/30" @click="mobileSidebarOpen=false"></div>
<div class="min-h-screen bg-gray-100 flex">
<!-- Mobile backdrop -->
<div
v-if="isMobile && mobileSidebarOpen"
class="fixed inset-0 z-40 bg-black/30"
@click="mobileSidebarOpen = false"
></div>
<!-- Sidebar -->
<aside :class="[
sidebarCollapsed ? 'w-16' : 'w-64',
'bg-white border-r border-gray-200 transition-all duration-200 z-50',
// Off-canvas behavior on mobile; sticky fixed-like sidebar on desktop
isMobile
? ('fixed inset-y-0 left-0 transform ' + (mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'))
: 'sticky top-0 h-screen overflow-y-auto'
]">
<div class="h-16 px-4 flex items-center justify-between border-b">
<Link :href="route('dashboard')" class="flex items-center gap-2">
<ApplicationMark class="h-8 w-auto" />
<span v-if="!sidebarCollapsed" class="text-sm font-semibold">Teren</span>
</Link>
</div>
<nav class="py-4">
<ul class="space-y-1">
<li>
<Link :href="route('dashboard')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('dashboard') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nadzorna plošča">
<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="M2.25 12l8.954-8.955a1.125 1.125 0 011.592 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.5c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v4.5h4.125c.621 0 1.125-.504 1.125-1.125V9.75" />
</svg>
<span v-if="!sidebarCollapsed">Nadzorna plošča</span>
</Link>
</li>
<li>
<Link :href="route('segments.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('segments.index') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Segmenti">
<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="M3 3h7v7H3V3zm11 0h7v7h-7V3zM3 14h7v7H3v-7zm11 0h7v7h-7v-7z" />
</svg>
<span v-if="!sidebarCollapsed">Segmenti</span>
</Link>
</li>
<li>
<Link :href="route('client')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('client') || route().current('client.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Naročniki">
<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="M15 19.128a9.38 9.38 0 003.745-.479 3.375 3.375 0 00-6.49-1.072M15 19.128V18a4.5 4.5 0 00-4.5-4.5H8.25A4.5 4.5 0 003.75 18v1.128M15 19.128V21m0-1.872V21M6.75 7.5a3 3 0 116 0 3 3 0 01-6 0z" />
</svg>
<span v-if="!sidebarCollapsed">Naročniki</span>
</Link>
</li>
<li>
<Link :href="route('clientCase')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('clientCase') || route().current('clientCase.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Primeri">
<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="M19.5 14.25v-6a2.25 2.25 0 00-2.25-2.25H8.25A2.25 2.25 0 006 8.25v7.5A2.25 2.25 0 008.25 18h9a2.25 2.25 0 002.25-2.25z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9h6m-6 3h6m-6 3h3" />
</svg>
<span v-if="!sidebarCollapsed">Primeri</span>
</Link>
</li>
<li>
<Link :href="route('imports.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', (route().current('imports.index') || route().current('imports.*')) ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Uvozi">
<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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 10.5L12 6l4.5 4.5M12 6v12" />
</svg>
<span v-if="!sidebarCollapsed">Uvozi</span>
</Link>
</li>
<li>
<Link :href="route('importTemplates.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('importTemplates.index') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Uvozne predloge">
<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="M3.75 4.5h5.25l1.5 2.25H20.25A1.5 1.5 0 0121.75 8.25v9A2.25 2.25 0 0119.5 19.5H4.5A2.25 2.25 0 012.25 17.25V6A1.5 1.5 0 013.75 4.5z" />
</svg>
<span v-if="!sidebarCollapsed">Uvozne predloge</span>
</Link>
</li>
<li>
<Link :href="route('importTemplates.create')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('importTemplates.create') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nova uvozna predloga">
<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="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span v-if="!sidebarCollapsed">Nova uvozna predloga</span>
</Link>
</li>
<li>
<Link :href="route('fieldjobs.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('fieldjobs.index') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Terenske naloge">
<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="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.5-7.5 10.5-7.5 10.5S4.5 18 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
<span v-if="!sidebarCollapsed">Terenske naloge</span>
</Link>
</li>
<li>
<Link :href="route('settings')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('settings') || route().current('settings.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nastavitve">
<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="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93l.8.334c.486.203.682.78.4 1.223l-.5.805c-.214.343-.17.784.108 1.09l.596.654c.36.395.37 1.002.024 1.41l-.657.76c-.285.33-.347.79-.158 1.182l.3.65c.216.468-.02 1.02-.507 1.21l-.89.345c-.4.155-.68.52-.74.94l-.12.89c-.08.55-.54.96-1.09.96h-1.09c-.55 0-1.01-.41-1.09-.96l-.12-.89c-.06-.42-.34-.785-.74-.94l-.89-.345c-.49-.19-.72-.74-.507-1.21l.3-.65c.19-.392.127-.852-.158-1.182l-.657-.76a1.125 1.125 0 01.033-1.58l.596-.654c.278-.306.322-.747.108-1.09l-.5-.805c-.282-.443-.086-1.02.4-1.223l.8-.334c.396-.166.71-.506.78-.93l.149-.894zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" />
</svg>
<span v-if="!sidebarCollapsed">Nastavitve</span>
</Link>
</li>
</ul>
</nav>
</aside>
<!-- Sidebar -->
<aside
:class="[
sidebarCollapsed ? 'w-16' : 'w-64',
'bg-white border-r border-gray-200 transition-all duration-200 z-50',
// Off-canvas behavior on mobile; sticky fixed-like sidebar on desktop
isMobile
? 'fixed inset-y-0 left-0 transform ' +
(mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full')
: 'sticky top-0 h-screen overflow-y-auto',
]"
>
<div class="h-16 px-4 flex items-center justify-between border-b">
<Link :href="route('dashboard')" class="flex items-center gap-2">
<ApplicationMark class="h-8 w-auto" />
<span v-if="!sidebarCollapsed" class="text-sm font-semibold">Teren</span>
</Link>
</div>
<nav class="py-4">
<ul class="space-y-3">
<li v-for="group in menuGroups" :key="group.label">
<div
v-if="!sidebarCollapsed"
class="px-4 py-1 text-[11px] uppercase tracking-wide text-gray-400"
>
{{ group.label }}
</div>
<ul class="space-y-1">
<li v-for="item in group.items" :key="item.key">
<Link
:href="route(item.routeName)"
:class="[
'flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100',
isActive(item.active)
? 'bg-gray-100 text-gray-900'
: 'text-gray-600',
]"
:title="item.title"
>
<!-- Icons -->
<template v-if="item.key === 'dashboard'">
<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="M2.25 12l8.954-8.955a1.125 1.125 0 011.592 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.5c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v4.5h4.125c.621 0 1.125-.504 1.125-1.125V9.75"
/>
</svg>
</template>
<template v-else-if="item.key === 'segments'">
<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="M3 3h7v7H3V3zm11 0h7v7h-7V3zM3 14h7v7H3v-7zm11 0h7v7h-7v-7z"
/>
</svg>
</template>
<template v-else-if="item.key === 'clients'">
<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="M15 19.128a9.38 9.38 0 003.745-.479 3.375 3.375 0 00-6.49-1.072M15 19.128V18a4.5 4.5 0 00-4.5-4.5H8.25A4.5 4.5 0 003.75 18v1.128M15 19.128V21m0-1.872V21M6.75 7.5a3 3 0 116 0 3 3 0 01-6 0z"
/>
</svg>
</template>
<template v-else-if="item.key === 'cases'">
<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="M19.5 14.25v-6a2.25 2.25 0 00-2.25-2.25H8.25A2.25 2.25 0 006 8.25v7.5A2.25 2.25 0 008.25 18h9a2.25 2.25 0 002.25-2.25z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 9h6m-6 3h6m-6 3h3"
/>
</svg>
</template>
<template v-else-if="item.key === 'imports'">
<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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 10.5L12 6l4.5 4.5M12 6v12"
/>
</svg>
</template>
<template v-else-if="item.key === 'import-templates'">
<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="M3.75 4.5h5.25l1.5 2.25H20.25A1.5 1.5 0 0121.75 8.25v9A2.25 2.25 0 0119.5 19.5H4.5A2.25 2.25 0 012.25 17.25V6A1.5 1.5 0 013.75 4.5z"
/>
</svg>
</template>
<template v-else-if="item.key === 'import-templates-new'">
<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="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
</template>
<template v-else-if="item.key === 'fieldjobs'">
<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="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 10.5c0 7.5-7.5 10.5-7.5 10.5S4.5 18 4.5 10.5a7.5 7.5 0 1115 0z"
/>
</svg>
</template>
<template v-else-if="item.key === 'settings'">
<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="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93l.8.334c.486.203.682.78.4 1.223l-.5.805c-.214.343-.17.784.108 1.09l.596.654c.36.395.37 1.002.024 1.41l-.657.76c-.285.33-.347.79-.158 1.182l.3.65c.216.468-.02 1.02-.507 1.21l-.89.345c-.4.155-.68.52-.74.94l-.12.89c-.08.55-.54.96-1.09.96h-1.09c-.55 0-1.01-.41-1.09-.96l-.12-.89c-.06-.42-.34-.785-.74-.94l-.89-.345c-.49-.19-.72-.74-.507-1.21l.3-.65c.19-.392.127-.852-.158-1.182l-.657-.76a1.125 1.125 0 01-.033-1.58l.596-.654c.278-.306.322-.747.108-1.09l-.5-.805c-.282-.443-.086-1.02.4-1.223l.8-.334c.396-.166.71-.506.78-.93l.149-.894zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z"
/>
</svg>
</template>
<!-- Title -->
<span v-if="!sidebarCollapsed">{{ item.title }}</span>
</Link>
</li>
</ul>
</li>
</ul>
</nav>
</aside>
<!-- Main column -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Top bar -->
<div class="h-16 bg-white border-b border-gray-100 px-4 flex items-center justify-between sticky top-0 z-30">
<div class="flex items-center gap-2">
<!-- Sidebar toggle -->
<button
@click="handleSidebarToggleClick()"
class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100"
:title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'"
aria-label="Toggle sidebar"
>
<!-- Hamburger (Bars) icon -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<!-- Search trigger -->
<button @click="openSearch" class="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-md border border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z" />
</svg>
<span class="hidden sm:inline">Globalni iskalnik</span>
<kbd class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border bg-gray-50">Ctrl K</kbd>
</button>
</div>
<!-- Notifications + User drop menu --->
<div class="flex items-center">
<NotificationsBell class="mr-2" />
<!-- Phone page quick access button -->
<Link :href="route('phone.index')" class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 mr-2" title="Phone">
<FontAwesomeIcon :icon="faMobileScreenButton" class="h-5 w-5" />
</Link>
<div class="ms-3 relative">
<Dropdown align="right" width="48">
<template #trigger>
<button v-if="$page.props.jetstream.managesProfilePhotos" class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
<img class="h-8 w-8 rounded-full object-cover" :src="$page.props.auth.user.profile_photo_url" :alt="$page.props.auth.user.name">
</button>
<!-- Main column -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Top bar -->
<div
class="h-16 bg-white border-b border-gray-100 px-4 flex items-center justify-between sticky top-0 z-30"
>
<div class="flex items-center gap-2">
<!-- Sidebar toggle -->
<button
@click="handleSidebarToggleClick()"
class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100"
:title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'"
aria-label="Toggle sidebar"
>
<!-- Hamburger (Bars) icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
<!-- Search trigger -->
<button
@click="openSearch"
class="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-md border border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z"
/>
</svg>
<span class="hidden sm:inline">Globalni iskalnik</span>
<kbd
class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border bg-gray-50"
>Ctrl K</kbd
>
</button>
</div>
<!-- Notifications + User drop menu --->
<div class="flex items-center">
<NotificationsBell class="mr-2" />
<!-- Phone page quick access button -->
<Link
:href="route('phone.index')"
class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 mr-2"
title="Phone"
>
<FontAwesomeIcon :icon="faMobileScreenButton" class="h-5 w-5" />
</Link>
<div class="ms-3 relative">
<Dropdown align="right" width="48">
<template #trigger>
<button
v-if="$page.props.jetstream.managesProfilePhotos"
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition"
>
<img
class="h-8 w-8 rounded-full object-cover"
:src="$page.props.auth.user.profile_photo_url"
:alt="$page.props.auth.user.name"
/>
</button>
<span v-else class="inline-flex rounded-md">
<button type="button" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150">
{{ $page.props.auth.user.name }}
<svg class="ms-2 -me-0.5 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
</span>
</template>
<span v-else class="inline-flex rounded-md">
<button
type="button"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150"
>
{{ $page.props.auth.user.name }}
<svg
class="ms-2 -me-0.5 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
</span>
</template>
<template #content>
<div class="block px-4 py-2 text-xs text-gray-400">Nastavitve računa</div>
<template #content>
<div class="block px-4 py-2 text-xs text-gray-400">
Nastavitve računa
</div>
<DropdownLink :href="route('profile.show')">Profil</DropdownLink>
<DropdownLink v-if="$page.props.jetstream.hasApiFeatures" :href="route('api-tokens.index')">API Tokens</DropdownLink>
<DropdownLink :href="route('profile.show')">Profil</DropdownLink>
<DropdownLink
v-if="$page.props.jetstream.hasApiFeatures"
:href="route('api-tokens.index')"
>API Tokens</DropdownLink
>
<div class="border-t border-gray-200" />
<div class="border-t border-gray-200" />
<form @submit.prevent="logout">
<DropdownLink as="button">Izpis</DropdownLink>
</form>
</template>
</Dropdown>
</div>
</div>
</div>
<!-- Page Heading -->
<header v-if="$slots.header" class="bg-white border-b shadow-sm">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
<Breadcrumbs v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length" :breadcrumbs="$page.props.breadcrumbs" />
<slot name="header" />
</div>
</header>
<!-- Page Content -->
<main class="p-4">
<slot />
</main>
<form @submit.prevent="logout">
<DropdownLink as="button">Izpis</DropdownLink>
</form>
</template>
</Dropdown>
</div>
</div>
</div>
<!-- Global Search Modal -->
<GlobalSearch :open="searchOpen" @update:open="(v)=>searchOpen=v" />
<!-- Page Heading -->
<header v-if="$slots.header" class="bg-white border-b shadow-sm">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
<Breadcrumbs
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"
:breadcrumbs="$page.props.breadcrumbs"
/>
<slot name="header" />
</div>
</header>
<!-- Simple Toast -->
<transition name="fade">
<div
v-if="showToast"
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-white"
:class="{
'bg-emerald-600': toastType==='success',
'bg-red-600': toastType==='error',
'bg-amber-500': toastType==='warning',
'bg-blue-600': toastType==='info',
}"
>
{{ toastMessage }}
</div>
</transition>
<!-- Page Content -->
<main class="p-4">
<slot />
</main>
</div>
</div>
<!-- Global Search Modal -->
<GlobalSearch :open="searchOpen" @update:open="(v) => (searchOpen = v)" />
<!-- Simple Toast -->
<transition name="fade">
<div
v-if="showToast"
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-white"
:class="{
'bg-emerald-600': toastType === 'success',
'bg-red-600': toastType === 'error',
'bg-amber-500': toastType === 'warning',
'bg-blue-600': toastType === 'info',
}"
>
{{ toastMessage }}
</div>
</transition>
</div>
</template>
@@ -12,6 +12,7 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
import PaymentDialog from "./PaymentDialog.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import ViewPaymentsDialog from "./ViewPaymentsDialog.vue";
import {
faCircleInfo,
faClock,
@@ -102,6 +103,15 @@ const contractActiveSegment = (c) => {
return arr.find((s) => s.pivot?.active) || arr[0] || null;
};
const segmentName = (id) => props.segments.find((s) => s.id === id)?.name || "";
// Sorted segment lists for dropdowns
const sortedSegments = computed(() => {
const list = Array.isArray(props.segments) ? [...props.segments] : [];
return list.sort((a, b) => a.name.localeCompare(b.name, "sl", { sensitivity: "base" }));
});
const sortedAllSegments = computed(() => {
const list = Array.isArray(props.all_segments) ? [...props.all_segments] : [];
return list.sort((a, b) => a.name.localeCompare(b.name, "sl", { sensitivity: "base" }));
});
const confirmChange = ref({
show: false,
contract: null,
@@ -171,55 +181,25 @@ const submitPayment = () => {
return;
}
const accountId = paymentContract.value.account.id;
paymentForm.post(route("accounts.payments.store", { account: accountId }), {
preserveScroll: true,
onSuccess: () => {
closePaymentDialog();
// Reload contracts and activities (new payment may create an activity)
router.reload({ only: ["contracts", "activities"] });
paymentForm.post(route("accounts.payments.store", { account: accountId }), {
preserveScroll: true,
onSuccess: () => {
closePaymentDialog();
// Reload contracts and activities (new payment may create an activity)
router.reload({ only: ["contracts", "activities"] });
},
});
};
// View Payments dialog state and logic
// View Payments dialog state
const showPaymentsDialog = ref(false);
const paymentsForContract = ref([]);
const paymentsLoading = ref(false);
const openPaymentsDialog = async (c) => {
const openPaymentsDialog = (c) => {
selectedContract.value = c;
showPaymentsDialog.value = true;
await loadPayments();
};
const closePaymentsDialog = () => {
showPaymentsDialog.value = false;
selectedContract.value = null;
paymentsForContract.value = [];
};
const loadPayments = async () => {
if (!selectedContract.value?.account?.id) return;
paymentsLoading.value = true;
try {
const { data } = await axios.get(route("accounts.payments.list", { account: selectedContract.value.account.id }));
paymentsForContract.value = data.payments || [];
} finally {
paymentsLoading.value = false;
}
};
const deletePayment = (paymentId) => {
if (!selectedContract.value?.account?.id) return;
const accountId = selectedContract.value.account.id;
router.delete(route("accounts.payments.destroy", { account: accountId, payment: paymentId }), {
preserveScroll: true,
preserveState: true,
only: ["contracts", "activities"],
onSuccess: async () => {
await loadPayments();
},
onError: async () => {
// Even if there is an error, try to refresh payments list
await loadPayments();
},
});
};
</script>
@@ -275,7 +255,7 @@ const deletePayment = (paymentId) => {
<span class="text-gray-700">{{
contractActiveSegment(c)?.name || "-"
}}</span>
<Dropdown width="64" align="left">
<Dropdown align="left">
<template #trigger>
<button
type="button"
@@ -286,8 +266,8 @@ const deletePayment = (paymentId) => {
}"
:title="
segments && segments.length
? 'Change segment'
: 'No segments available for this case'
? 'Spremeni segment'
: 'Ni segmentov na voljo za ta primer'
"
>
<FontAwesomeIcon
@@ -300,7 +280,7 @@ const deletePayment = (paymentId) => {
<div class="py-1">
<template v-if="segments && segments.length">
<button
v-for="s in segments"
v-for="s in sortedSegments"
:key="s.id"
type="button"
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
@@ -315,7 +295,7 @@ const deletePayment = (paymentId) => {
Ni segmentov v tem primeru. Dodaj in nastavi segment:
</div>
<button
v-for="s in all_segments"
v-for="s in sortedAllSegments"
:key="s.id"
type="button"
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
@@ -326,7 +306,7 @@ const deletePayment = (paymentId) => {
</template>
<template v-else>
<div class="px-3 py-2 text-sm text-gray-500">
No segments configured.
Ni konfiguriranih segmentov.
</div>
</template>
</template>
@@ -356,7 +336,11 @@ const deletePayment = (paymentId) => {
class="inline-flex items-center justify-center h-5 w-5 rounded-full"
:title="'Pokaži opis'"
:disabled="!hasDesc(c)"
:class="hasDesc(c) ? 'hover:bg-gray-100 focus:outline-none' : text-gray-400"
:class="
hasDesc(c)
? 'hover:bg-gray-100 focus:outline-none'
: 'text-gray-400'
"
>
<FontAwesomeIcon
:icon="faCircleInfo"
@@ -373,7 +357,7 @@ const deletePayment = (paymentId) => {
</div>
</template>
</Dropdown>
<!-- Promise date indicator -->
<Dropdown width="64" align="left">
<template #trigger>
@@ -431,7 +415,7 @@ const deletePayment = (paymentId) => {
<button
type="button"
class="inline-flex items-center justify-center h-5 w-5 rounded-full hover:bg-gray-100 focus:outline-none"
:title="'Actions'"
:title="'Dejanja'"
>
<FontAwesomeIcon
:icon="faEllipsisVertical"
@@ -440,6 +424,12 @@ const deletePayment = (paymentId) => {
</button>
</template>
<template #content>
<!-- Urejanje -->
<div
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
>
Urejanje
</div>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@@ -449,15 +439,31 @@ const deletePayment = (paymentId) => {
:icon="faPenToSquare"
class="h-4 w-4 text-gray-600"
/>
<span>Edit</span>
<span>Uredi</span>
</button>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="onAddActivity(c)"
>
<FontAwesomeIcon :icon="faListCheck" class="h-4 w-4 text-gray-600" />
<span>Dodaj aktivnost</span>
</button>
<div class="my-1 border-t border-gray-100" />
<!-- Predmeti -->
<div
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
>
Predmeti
</div>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="openObjectsList(c)"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
<span>Predmeti</span>
<span>Seznam predmetov</span>
</button>
<button
type="button"
@@ -465,35 +471,24 @@ const deletePayment = (paymentId) => {
@click="openObjectDialog(c)"
>
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" />
<span>Predmeti</span>
</button>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-red-700 hover:bg-red-50 flex items-center gap-2"
@click="onDelete(c)"
>
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4 text-red-600" />
<span>Briši</span>
<span>Dodaj predmet</span>
</button>
<div class="my-1 border-t border-gray-100" />
<!-- Plačila -->
<div
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
>
Plačila
</div>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="openPaymentsDialog(c)"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
<span>Plačila</span>
<span>Pokaži plačila</span>
</button>
<div class="my-1 border-t border-gray-100" />
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="onAddActivity(c)"
>
<FontAwesomeIcon :icon="faListCheck" class="h-4 w-4 text-gray-600" />
<span>Aktivnost</span>
</button>
<div class="my-1 border-t border-gray-100" />
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@@ -502,6 +497,17 @@ const deletePayment = (paymentId) => {
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" />
<span>Dodaj plačilo</span>
</button>
<div class="my-1 border-t border-gray-100" />
<!-- Destruktivno -->
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-red-700 hover:bg-red-50 flex items-center gap-2"
@click="onDelete(c)"
>
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4 text-red-600" />
<span>Izbriši</span>
</button>
</template>
</Dropdown>
</FwbTableCell>
@@ -556,54 +562,16 @@ const deletePayment = (paymentId) => {
:contract="selectedContract"
/>
<PaymentDialog :show="showPaymentDialog" :form="paymentForm" @close="closePaymentDialog" @submit="submitPayment" />
<PaymentDialog
:show="showPaymentDialog"
:form="paymentForm"
@close="closePaymentDialog"
@submit="submitPayment"
/>
<!-- View Payments Dialog -->
<div v-if="showPaymentsDialog" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div class="bg-white rounded-lg shadow-lg p-4 w-full max-w-lg">
<div class="flex items-center justify-between">
<div class="text-base font-medium text-gray-800">
Plačila za pogodbo
<span class="text-gray-600">{{ selectedContract?.reference }}</span>
</div>
<button type="button" class="text-sm text-gray-500 hover:text-gray-700" @click="closePaymentsDialog">Zapri</button>
</div>
<div class="mt-3">
<div v-if="paymentsLoading" class="text-sm text-gray-500">Nalaganje</div>
<template v-else>
<div v-if="paymentsForContract.length === 0" class="text-sm text-gray-500">Ni plačil.</div>
<div v-else class="divide-y divide-gray-100 border rounded">
<div v-for="p in paymentsForContract" :key="p.id" class="px-3 py-2 flex items-center justify-between">
<div>
<div class="text-sm text-gray-800">
{{
Intl.NumberFormat('de-DE', { style: 'currency', currency: p.currency || 'EUR' }).format(p.amount ?? 0)
}}
</div>
<div class="text-xs text-gray-500">
<span>{{ formatDate(p.paid_at) }}</span>
<span v-if="p.reference" class="ml-2">Sklic: {{ p.reference }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1 px-2 py-1 rounded text-red-700 hover:bg-red-50"
@click="deletePayment(p.id)"
title="Izbriši plačilo"
>
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4 text-red-600" />
<span class="text-sm">Briši</span>
</button>
</div>
</div>
</div>
</template>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="loadPayments">Osveži</button>
<button type="button" class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" @click="closePaymentsDialog">Zapri</button>
</div>
</div>
</div>
<ViewPaymentsDialog
:show="showPaymentsDialog"
:contract="selectedContract"
@close="closePaymentsDialog"
/>
</template>
@@ -0,0 +1,152 @@
<script setup>
import DialogModal from "@/Components/DialogModal.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { ref, watch, computed } from "vue";
import axios from "axios";
import { router } from "@inertiajs/vue3";
const props = defineProps({
show: { type: Boolean, default: false },
contract: { type: Object, default: null },
});
const emit = defineEmits(["close"]);
const payments = ref([]);
const loading = ref(false);
const accountId = computed(() => props.contract?.account?.id ?? null);
const contractRef = computed(() => props.contract?.reference || "—");
async function loadPayments() {
if (!accountId.value) {
payments.value = [];
return;
}
loading.value = true;
try {
const { data } = await axios.get(
route("accounts.payments.list", { account: accountId.value })
);
payments.value = data.payments || [];
} finally {
loading.value = false;
}
}
function close() {
emit("close");
}
function formatDate(d) {
if (!d) return "—";
const dt = new Date(d);
return isNaN(dt.getTime()) ? "—" : dt.toLocaleDateString("de");
}
function fmtMoney(amount, currency = "EUR") {
const num = typeof amount === "string" ? Number(amount) : amount;
return new Intl.NumberFormat("de-DE", { style: "currency", currency }).format(num ?? 0);
}
async function deletePayment(paymentId) {
if (!accountId.value) return;
await router.delete(
route("accounts.payments.destroy", { account: accountId.value, payment: paymentId }),
{
preserveScroll: true,
preserveState: true,
only: ["contracts", "activities"],
onSuccess: async () => {
await loadPayments();
},
onError: async () => {
await loadPayments();
},
}
);
}
watch(
() => props.show,
async (visible) => {
if (visible) {
await loadPayments();
} else {
payments.value = [];
}
}
);
watch(
() => props.contract?.account?.id,
async () => {
if (props.show) {
await loadPayments();
}
}
);
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>
Plačila za pogodbo <span class="text-gray-600">{{ contractRef }}</span>
</template>
<template #content>
<div>
<div v-if="loading" class="text-sm text-gray-500">Nalaganje</div>
<template v-else>
<div v-if="payments.length === 0" class="text-sm text-gray-500">Ni plačil.</div>
<div v-else class="divide-y divide-gray-100 border rounded">
<div
v-for="p in payments"
:key="p.id"
class="px-3 py-2 flex items-center justify-between"
>
<div>
<div class="text-sm text-gray-800">
{{ fmtMoney(p.amount, p.currency || "EUR") }}
</div>
<div class="text-xs text-gray-500">
<span>{{ formatDate(p.paid_at) }}</span>
<span v-if="p.reference" class="ml-2">Sklic: {{ p.reference }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1 px-2 py-1 rounded text-red-700 hover:bg-red-50"
@click="deletePayment(p.id)"
title="Izbriši plačilo"
>
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4 text-red-600" />
<span class="text-sm">Briši</span>
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<template #footer>
<div class="mt-2 flex justify-end gap-2">
<button
type="button"
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
@click="loadPayments"
>
Osveži
</button>
<button
type="button"
class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
@click="close"
>
Zapri
</button>
</div>
</template>
</DialogModal>
</template>
@@ -0,0 +1,141 @@
<script setup>
import DialogModal from "@/Components/DialogModal.vue";
import { ref, watch, computed } from "vue";
import { router } from "@inertiajs/vue3";
import axios from "axios";
const props = defineProps({
show: { type: Boolean, default: false },
contract: { type: Object, default: null },
});
const emit = defineEmits(["close"]);
const payments = ref([]);
const loading = ref(false);
const contractRef = computed(() => props.contract?.reference || "—");
const accountId = computed(() => props.contract?.account?.id || null);
function formatDate(d) {
if (!d) return "-";
const dt = new Date(d);
return isNaN(dt.getTime()) ? "-" : dt.toLocaleDateString("de");
}
async function loadPayments() {
if (!accountId.value) {
payments.value = [];
return;
}
loading.value = true;
try {
const { data } = await axios.get(
route("accounts.payments.list", { account: accountId.value })
);
payments.value = data.payments || [];
} finally {
loading.value = false;
}
}
function close() {
emit("close");
payments.value = [];
}
function deletePayment(paymentId) {
if (!accountId.value) return;
router.delete(
route("accounts.payments.destroy", { account: accountId.value, payment: paymentId }),
{
preserveScroll: true,
preserveState: true,
only: ["contracts", "activities"],
onSuccess: async () => {
await loadPayments();
},
onError: async () => {
await loadPayments();
},
}
);
}
watch(
() => props.show,
async (visible) => {
if (visible) {
await loadPayments();
}
}
);
watch(
() => props.contract?.account?.id,
async () => {
if (props.show) {
await loadPayments();
}
}
);
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>
Plačila za pogodbo <span class="text-gray-600">{{ contractRef }}</span>
</template>
<template #content>
<div>
<div v-if="loading" class="text-sm text-gray-500">Nalaganje</div>
<template v-else>
<div v-if="payments.length === 0" class="text-sm text-gray-500">Ni plačil.</div>
<div v-else class="divide-y divide-gray-100 border rounded">
<div
v-for="p in payments"
:key="p.id"
class="px-3 py-2 flex items-center justify-between"
>
<div>
<div class="text-sm text-gray-800">
{{
Intl.NumberFormat('de-DE', { style: 'currency', currency: p.currency || 'EUR' }).format(p.amount ?? 0)
}}
</div>
<div class="text-xs text-gray-500">
<span>{{ formatDate(p.paid_at) }}</span>
<span v-if="p.reference" class="ml-2">Sklic: {{ p.reference }}</span>
<span v-if="p.balance_before !== undefined" class="ml-2">
Stanje pred: {{
Intl.NumberFormat('de-DE', { style: 'currency', currency: p.currency || 'EUR' }).format(p.balance_before ?? 0)
}}
</span>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1 px-2 py-1 rounded text-red-700 hover:bg-red-50"
@click="deletePayment(p.id)"
title="Izbriši plačilo"
>
<span class="text-sm">Briši</span>
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<button type="button" class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="loadPayments">
Osveži
</button>
<button type="button" class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" @click="close">
Zapri
</button>
</div>
</template>
</DialogModal>
</template>
+239 -119
View File
@@ -1,9 +1,9 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref, watch, computed, onMounted } from 'vue';
import { useForm, router } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
import axios from 'axios';
import AppLayout from "@/Layouts/AppLayout.vue";
import { ref, watch, computed, onMounted } from "vue";
import { useForm, router } from "@inertiajs/vue3";
import Multiselect from "vue-multiselect";
import axios from "axios";
const props = defineProps({
templates: Array,
@@ -11,7 +11,7 @@ const props = defineProps({
});
const hasHeader = ref(true);
const detected = ref({ columns: [], delimiter: ',', has_header: true });
const detected = ref({ columns: [], delimiter: ",", has_header: true });
const importId = ref(null);
const templateApplied = ref(false);
const processing = ref(false);
@@ -19,15 +19,28 @@ const processResult = ref(null);
const mappingRows = ref([]);
const mappingSaved = ref(false);
const mappingSavedCount = ref(0);
const selectedMappingsCount = computed(() => mappingRows.value.filter(r => !r.skip && r.entity && r.field).length);
const mappingError = ref('');
const selectedMappingsCount = computed(
() => mappingRows.value.filter((r) => !r.skip && r.entity && r.field).length
);
const mappingError = ref("");
const savingMappings = ref(false);
// Dynamic entity definitions and suggestions from API
const entityDefs = ref([]);
const entityOptions = computed(() => entityDefs.value.map(e => ({ value: e.key, label: e.label || e.key })));
const fieldOptionsByEntity = computed(() => Object.fromEntries(entityDefs.value.map(e => [e.key, (e.fields || []).map(f => ({ value: f, label: f }))])));
const canonicalRootByKey = computed(() => Object.fromEntries(entityDefs.value.map(e => [e.key, e.canonical_root || e.key])));
const entityOptions = computed(() =>
entityDefs.value.map((e) => ({ value: e.key, label: e.label || e.key }))
);
const fieldOptionsByEntity = computed(() =>
Object.fromEntries(
entityDefs.value.map((e) => [
e.key,
(e.fields || []).map((f) => ({ value: f, label: f })),
])
)
);
const canonicalRootByKey = computed(() =>
Object.fromEntries(entityDefs.value.map((e) => [e.key, e.canonical_root || e.key]))
);
const keyByCanonicalRoot = computed(() => {
const m = {};
for (const e of entityDefs.value) {
@@ -40,20 +53,24 @@ const keyByCanonicalRoot = computed(() => {
const suggestions = ref({});
async function loadEntityDefs() {
try {
const { data } = await axios.get('/api/import-entities');
const { data } = await axios.get("/api/import-entities");
entityDefs.value = data?.entities || [];
} catch (e) {
console.error('Failed to load import entity definitions', e);
console.error("Failed to load import entity definitions", e);
}
}
async function refreshSuggestions(columns) {
const cols = Array.isArray(columns) ? columns : (detected.value.columns || []);
if (!cols || cols.length === 0) { return; }
const cols = Array.isArray(columns) ? columns : detected.value.columns || [];
if (!cols || cols.length === 0) {
return;
}
try {
const { data } = await axios.post('/api/import-entities/suggest', { columns: cols });
// When a template is chosen and provides meta.entities, limit suggestions to those entities
const only = (selectedTemplate.value?.meta?.entities || []);
const { data } = await axios.post("/api/import-entities/suggest", { columns: cols, only_entities: only });
suggestions.value = { ...suggestions.value, ...(data?.suggestions || {}) };
} catch (e) {
console.error('Failed to load suggestions', e);
console.error("Failed to load suggestions", e);
}
}
@@ -64,8 +81,12 @@ function applySuggestionToRow(row) {
row.entity = s.entity;
row.field = s.field;
// default transform on if missing
if (!row.transform) { row.transform = 'trim'; }
if (!row.apply_mode) { row.apply_mode = 'both'; }
if (!row.transform) {
row.transform = "trim";
}
if (!row.apply_mode) {
row.apply_mode = "both";
}
row.skip = false;
return true;
}
@@ -84,11 +105,11 @@ const selectedClientOption = computed({
get() {
const cuuid = form.client_uuid;
if (!cuuid) return null;
return (props.clients || []).find(c => c.uuid === cuuid) || null;
return (props.clients || []).find((c) => c.uuid === cuuid) || null;
},
set(val) {
form.client_uuid = val ? val.uuid : null;
}
},
});
// Bridge Template Multiselect to store only template id (number) in form
@@ -96,18 +117,18 @@ const selectedTemplateOption = computed({
get() {
const tid = form.import_template_id;
if (tid == null) return null;
return (props.templates || []).find(t => t.id === tid) || null;
return (props.templates || []).find((t) => t.id === tid) || null;
},
set(val) {
form.import_template_id = val ? val.id : null;
}
},
});
// Helper: selected client's numeric id (fallback)
const selectedClientId = computed(() => {
const cuuid = form.client_uuid;
if (!cuuid) return null;
const c = (props.clients || []).find(x => x.uuid === cuuid);
const c = (props.clients || []).find((x) => x.uuid === cuuid);
return c ? c.id : null;
});
@@ -117,10 +138,12 @@ const filteredTemplates = computed(() => {
const cuuid = form.client_uuid;
const list = props.templates || [];
if (!cuuid) {
return list.filter(t => t.client_id == null);
return list.filter((t) => t.client_id == null);
}
// When client is selected, only show that client's templates (no globals)
return list.filter(t => t.client_uuid && t.client_uuid === cuuid);
return list.filter(
(t) => (t.client_uuid && t.client_uuid === cuuid) || t.client_id == null
);
});
function onFileChange(e) {
@@ -131,7 +154,7 @@ function onFileChange(e) {
}
async function submitUpload() {
await form.post(route('imports.store'), {
await form.post(route("imports.store"), {
forceFormData: true,
onSuccess: (res) => {
const data = res?.props || {};
@@ -141,29 +164,31 @@ async function submitUpload() {
if (!form.recentlySuccessful) return;
// Inertia doesn't expose JSON response directly with useForm; fallback to API call using fetch
const fd = new FormData();
fd.append('file', form.file);
fd.append("file", form.file);
},
});
}
async function fetchColumns() {
if (!importId.value) return;
const url = route('imports.columns', { import: importId.value });
const { data } = await axios.get(url, { params: { has_header: hasHeader.value ? 1 : 0 } });
const url = route("imports.columns", { import: importId.value });
const { data } = await axios.get(url, {
params: { has_header: hasHeader.value ? 1 : 0 },
});
detected.value = {
columns: data.columns || [],
delimiter: data.detected_delimiter || ',',
delimiter: data.detected_delimiter || ",",
has_header: !!data.has_header,
};
// initialize simple mapping rows with defaults if none exist
if (!mappingRows.value.length) {
mappingRows.value = (detected.value.columns || []).map((c, idx) => ({
source_column: c,
entity: '',
field: '',
entity: "",
field: "",
skip: false,
transform: 'trim',
apply_mode: 'both',
transform: "trim",
apply_mode: "both",
position: idx,
}));
}
@@ -180,22 +205,26 @@ async function uploadAndPreview() {
templateApplied.value = false;
processResult.value = null;
const fd = new window.FormData();
fd.append('file', form.file);
if (form.import_template_id !== null && form.import_template_id !== undefined && String(form.import_template_id).trim() !== '') {
fd.append('import_template_id', String(form.import_template_id));
fd.append("file", form.file);
if (
form.import_template_id !== null &&
form.import_template_id !== undefined &&
String(form.import_template_id).trim() !== ""
) {
fd.append("import_template_id", String(form.import_template_id));
}
if (form.client_uuid) {
fd.append('client_uuid', String(form.client_uuid));
fd.append("client_uuid", String(form.client_uuid));
}
fd.append('has_header', hasHeader.value ? '1' : '0');
fd.append("has_header", hasHeader.value ? "1" : "0");
try {
const { data } = await axios.post(route('imports.store'), fd, {
headers: { Accept: 'application/json' },
const { data } = await axios.post(route("imports.store"), fd, {
headers: { Accept: "application/json" },
withCredentials: true,
});
// Redirect immediately to the continue page for this import
if (data?.uuid) {
router.visit(route('imports.continue', { import: data.uuid }));
router.visit(route("imports.continue", { import: data.uuid }));
} else if (data?.id) {
// Fallback: if uuid not returned for some reason, fetch columns here (legacy)
importId.value = data.id;
@@ -203,12 +232,12 @@ async function uploadAndPreview() {
}
} catch (e) {
if (e.response) {
console.error('Upload error', e.response.status, e.response.data);
console.error("Upload error", e.response.status, e.response.data);
if (e.response.data?.errors) {
// Optionally you could surface errors in the UI; for now, log for visibility
}
} else {
console.error('Upload error', e);
console.error("Upload error", e);
}
}
}
@@ -219,19 +248,26 @@ async function uploadAndPreview() {
async function applyTemplateToImport() {
if (!importId.value || !form.import_template_id) return;
try {
await axios.post(route('importTemplates.apply', { template: form.import_template_id, import: importId.value }), {}, {
headers: { Accept: 'application/json' },
withCredentials: true,
});
await axios.post(
route("importTemplates.apply", {
template: form.import_template_id,
import: importId.value,
}),
{},
{
headers: { Accept: "application/json" },
withCredentials: true,
}
);
templateApplied.value = true;
// Load mappings and auto-assign UI rows
await loadImportMappings();
} catch (e) {
templateApplied.value = false;
if (e.response) {
console.error('Apply template error', e.response.status, e.response.data);
console.error("Apply template error", e.response.status, e.response.data);
} else {
console.error('Apply template error', e);
console.error("Apply template error", e);
}
}
}
@@ -239,33 +275,40 @@ async function applyTemplateToImport() {
async function loadImportMappings() {
if (!importId.value) return;
try {
const { data } = await axios.get(route('imports.mappings.get', { import: importId.value }), {
headers: { Accept: 'application/json' },
withCredentials: true,
});
const { data } = await axios.get(
route("imports.mappings.get", { import: importId.value }),
{
headers: { Accept: "application/json" },
withCredentials: true,
}
);
const rows = Array.isArray(data?.mappings) ? data.mappings : [];
if (!rows.length) return;
// Build a lookup by source_column
const bySource = new Map(rows.map(r => [r.source_column, r]));
const bySource = new Map(rows.map((r) => [r.source_column, r]));
// Update mappingRows (detected columns) to reflect applied mappings
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
const m = bySource.get(r.source_column);
if (!m) return r;
// Parse target_field like 'person.first_name' into UI entity/field
const [record, field] = String(m.target_field || '').split('.', 2);
const entity = keyByCanonicalRoot.value[record] || record;
// Parse target_field like 'person.first_name' into UI entity/field
const [record, field] = String(m.target_field || "").split(".", 2);
const entity = keyByCanonicalRoot.value[record] || record;
return {
...r,
entity,
field: field || '',
transform: m.transform || '',
apply_mode: m.apply_mode || 'both',
field: field || "",
transform: m.transform || "",
apply_mode: m.apply_mode || "both",
skip: false,
position: idx,
};
});
} catch (e) {
console.error('Load import mappings error', e.response?.status || '', e.response?.data || e);
console.error(
"Load import mappings error",
e.response?.status || "",
e.response?.data || e
);
}
}
@@ -274,18 +317,22 @@ async function processImport() {
processing.value = true;
processResult.value = null;
try {
const { data } = await axios.post(route('imports.process', { import: importId.value }), {}, {
headers: { Accept: 'application/json' },
withCredentials: true,
});
const { data } = await axios.post(
route("imports.process", { import: importId.value }),
{},
{
headers: { Accept: "application/json" },
withCredentials: true,
}
);
processResult.value = data;
} catch (e) {
if (e.response) {
console.error('Process import error', e.response.status, e.response.data);
processResult.value = { error: e.response.data || 'Processing failed' };
console.error("Process import error", e.response.status, e.response.data);
processResult.value = { error: e.response.data || "Processing failed" };
} else {
console.error('Process import error', e);
processResult.value = { error: 'Processing failed' };
console.error("Process import error", e);
processResult.value = { error: "Processing failed" };
}
} finally {
processing.value = false;
@@ -296,41 +343,49 @@ async function processImport() {
async function saveMappings() {
if (!importId.value) return;
mappingError.value = '';
mappingError.value = "";
const mappings = mappingRows.value
.filter(r => !r.skip && r.entity && r.field)
.map(r => ({
.filter((r) => !r.skip && r.entity && r.field)
.map((r) => ({
source_column: r.source_column,
target_field: `${(canonicalRootByKey.value[r.entity] || r.entity)}.${r.field}`,
target_field: `${canonicalRootByKey.value[r.entity] || r.entity}.${r.field}`,
transform: r.transform || null,
apply_mode: r.apply_mode || 'both',
apply_mode: r.apply_mode || "both",
options: null,
}));
if (!mappings.length) {
mappingSaved.value = false;
mappingError.value = 'Select entity and field for at least one column (or uncheck Skip) before saving.';
mappingError.value =
"Select entity and field for at least one column (or uncheck Skip) before saving.";
return;
}
try {
savingMappings.value = true;
const url = (typeof route === 'function')
? route('imports.mappings.save', { import: importId.value })
: `/imports/${importId.value}/mappings`;
const { data } = await axios.post(url, { mappings }, {
headers: { 'Accept': 'application/json' },
withCredentials: true,
});
const url =
typeof route === "function"
? route("imports.mappings.save", { import: importId.value })
: `/imports/${importId.value}/mappings`;
const { data } = await axios.post(
url,
{ mappings },
{
headers: { Accept: "application/json" },
withCredentials: true,
}
);
mappingSaved.value = true;
mappingSavedCount.value = Number(data?.saved || mappings.length);
mappingError.value = '';
mappingError.value = "";
} catch (e) {
mappingSaved.value = false;
if (e.response) {
console.error('Save mappings error', e.response.status, e.response.data);
alert('Failed to save mappings: ' + (e.response.data?.message || e.response.status));
console.error("Save mappings error", e.response.status, e.response.data);
alert(
"Failed to save mappings: " + (e.response.data?.message || e.response.status)
);
} else {
console.error('Save mappings error', e);
alert('Failed to save mappings. See console for details.');
console.error("Save mappings error", e);
alert("Failed to save mappings. See console for details.");
}
} finally {
savingMappings.value = false;
@@ -338,16 +393,19 @@ async function saveMappings() {
}
// Reset saved flag whenever user edits mappings
watch(mappingRows, () => {
mappingSaved.value = false;
mappingSavedCount.value = 0;
mappingError.value = '';
}, { deep: true });
watch(
mappingRows,
() => {
mappingSaved.value = false;
mappingSavedCount.value = 0;
mappingError.value = "";
},
{ deep: true }
);
onMounted(async () => {
await loadEntityDefs();
});
</script>
<template>
@@ -388,20 +446,32 @@ onMounted(async () => {
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<span>{{ option.name }}</span>
<span class="ml-2 text-xs text-gray-500">({{ option.source_type }})</span>
<span class="ml-2 text-xs text-gray-500"
>({{ option.source_type }})</span
>
</div>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{ option.client_id ? 'Client' : 'Global' }}</span>
<span
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
>{{ option.client_id ? "Client" : "Global" }}</span
>
</div>
</template>
<template #singleLabel="{ option }">
<div class="flex items-center gap-2">
<span>{{ option.name }}</span>
<span class="ml-1 text-xs text-gray-500">({{ option.source_type }})</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{ option.client_id ? 'Client' : 'Global' }}</span>
<span class="ml-1 text-xs text-gray-500"
>({{ option.source_type }})</span
>
<span
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
>{{ option.client_id ? "Client" : "Global" }}</span
>
</div>
</template>
</Multiselect>
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">Only global templates are shown until a client is selected.</p>
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">
Only global templates are shown until a client is selected.
</p>
</div>
</div>
@@ -411,19 +481,26 @@ onMounted(async () => {
<input type="file" @change="onFileChange" class="mt-1 block w-full" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Has header row</label>
<label class="block text-sm font-medium text-gray-700"
>Has header row</label
>
<input type="checkbox" v-model="hasHeader" class="mt-2" />
</div>
</div>
<div class="flex gap-3">
<button @click.prevent="uploadAndPreview" class="px-4 py-2 bg-blue-600 text-white rounded">Upload & Preview Columns</button>
<button
@click.prevent="uploadAndPreview"
class="px-4 py-2 bg-blue-600 text-white rounded"
>
Upload & Preview Columns
</button>
<button
@click.prevent="applyTemplateToImport"
:disabled="!importId || !form.import_template_id || templateApplied"
class="px-4 py-2 bg-emerald-600 disabled:bg-gray-300 text-white rounded"
>
{{ templateApplied ? 'Template Applied' : 'Apply Template' }}
{{ templateApplied ? "Template Applied" : "Apply Template" }}
</button>
<button
@click.prevent="saveMappings"
@@ -431,32 +508,52 @@ onMounted(async () => {
class="px-4 py-2 bg-orange-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
title="Save ad-hoc mappings for this import"
>
<span v-if="savingMappings" class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"></span>
<span
v-if="savingMappings"
class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
></span>
<span>Save Mappings</span>
<span v-if="selectedMappingsCount" class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded">{{ selectedMappingsCount }}</span>
<span
v-if="selectedMappingsCount"
class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded"
>{{ selectedMappingsCount }}</span
>
</button>
<button
@click.prevent="processImport"
:disabled="!importId || processing || (!templateApplied && !mappingSaved)"
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded"
>
{{ processing ? 'Processing…' : 'Process Import' }}
{{ processing ? "Processing…" : "Process Import" }}
</button>
</div>
<div class="mt-2 text-xs text-gray-600" v-if="!importId">
Upload a file first to enable saving mappings.
</div>
<div class="mt-2 text-xs text-gray-600" v-else-if="importId && !selectedMappingsCount">
Select an Entity and Field for at least one detected column (or uncheck Skip) and then click Save Mappings.
<div
class="mt-2 text-xs text-gray-600"
v-else-if="importId && !selectedMappingsCount"
>
Select an Entity and Field for at least one detected column (or uncheck Skip)
and then click Save Mappings.
</div>
<div v-if="detected.columns.length" class="pt-4">
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})</h3>
<h3 class="font-semibold">
Detected Columns ({{ detected.has_header ? "header" : "positional" }})
</h3>
<button
class="px-3 py-1.5 border rounded text-sm"
@click.prevent="(async () => { await refreshSuggestions(detected.columns); mappingRows.forEach(r => applySuggestionToRow(r)); })()"
>Auto map suggestions</button>
@click.prevent="
(async () => {
await refreshSuggestions(detected.columns);
mappingRows.forEach((r) => applySuggestionToRow(r));
})()
"
>
Auto map suggestions
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full border bg-white">
@@ -476,21 +573,38 @@ onMounted(async () => {
<div>{{ row.source_column }}</div>
<div class="text-xs mt-1" v-if="suggestions[row.source_column]">
<span class="text-gray-500">Suggest:</span>
<button class="ml-1 underline text-indigo-700 hover:text-indigo-900" @click.prevent="applySuggestionToRow(row)">
{{ suggestions[row.source_column].entity }}.{{ suggestions[row.source_column].field }}
<button
class="ml-1 underline text-indigo-700 hover:text-indigo-900"
@click.prevent="applySuggestionToRow(row)"
>
{{ suggestions[row.source_column].entity }}.{{
suggestions[row.source_column].field
}}
</button>
</div>
</td>
<td class="p-2 border">
<select v-model="row.entity" class="border rounded p-1 w-full">
<option value=""></option>
<option v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
<option
v-for="opt in entityOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</td>
<td class="p-2 border">
<select v-model="row.field" class="border rounded p-1 w-full">
<option value=""></option>
<option v-for="f in fieldOptionsByEntity[row.entity] || []" :key="f.value" :value="f.value">{{ f.label }}</option>
<option
v-for="f in fieldOptionsByEntity[row.entity] || []"
:key="f.value"
:value="f.value"
>
{{ f.label }}
</option>
</select>
</td>
<td class="p-2 border">
@@ -515,16 +629,22 @@ onMounted(async () => {
</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="mappingSaved" class="text-sm text-emerald-700 mt-2">
Mappings saved ({{ mappingSavedCount }}).
</div>
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">
{{ mappingError }}
</div>
</div>
<div v-if="processResult" class="pt-4">
<h3 class="font-semibold mb-2">Import Result</h3>
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{ processResult }}</pre>
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{
processResult
}}</pre>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
</template>
+205 -64
View File
@@ -1,9 +1,9 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
import { computed, watch } from 'vue';
import AppLayout from "@/Layouts/AppLayout.vue";
import { ref } from "vue";
import { useForm } from "@inertiajs/vue3";
import Multiselect from "vue-multiselect";
import { computed, watch } from "vue";
const props = defineProps({
clients: Array,
@@ -13,10 +13,10 @@ const props = defineProps({
});
const form = useForm({
name: '',
description: '',
source_type: 'csv',
default_record_type: '',
name: "",
description: "",
source_type: "csv",
default_record_type: "",
is_active: true,
client_uuid: null,
entities: [],
@@ -24,33 +24,63 @@ const form = useForm({
segment_id: null,
decision_id: null,
action_id: null,
delimiter: '',
delimiter: "",
// Payments import mode
payments_import: false,
// For payments mode: how to locate Contract - use single key 'reference'
contract_key_mode: null,
},
});
const decisionsForSelectedAction = computed(() => {
const act = (props.actions || []).find(a => a.id === form.meta.action_id);
const act = (props.actions || []).find((a) => a.id === form.meta.action_id);
return act?.decisions || [];
});
watch(() => form.meta.action_id, () => {
// Clear decision when action changes to enforce valid pair
form.meta.decision_id = null;
});
watch(
() => form.meta.action_id,
() => {
// Clear decision when action changes to enforce valid pair
form.meta.decision_id = null;
}
);
function submit() {
form.post(route('importTemplates.store'), {
form.post(route("importTemplates.store"), {
onSuccess: () => {
// You can redirect or show a success message here
},
});
}
// Payments mode: lock entities to Contract -> Account -> Payment and provide key mode
const prevEntities = ref([]);
watch(
() => form.meta.payments_import,
(enabled) => {
if (enabled) {
// Save current selection and lock to the required chain
prevEntities.value = Array.isArray(form.entities) ? [...form.entities] : [];
form.entities = ["contracts", "accounts", "payments"];
// default contract key mode to 'reference'
if (!form.meta.contract_key_mode) {
form.meta.contract_key_mode = "reference";
}
} else {
// Restore previous selection when turning off
form.entities = prevEntities.value?.length ? [...prevEntities.value] : [];
form.meta.contract_key_mode = null;
}
}
);
</script>
<template>
<AppLayout title="Create Import Template">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Create Import Template</h2>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Create Import Template
</h2>
</template>
<div class="py-6">
@@ -58,11 +88,13 @@ function submit() {
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Client (optional)</label>
<label class="block text-sm font-medium text-gray-700"
>Client (optional)</label
>
<Multiselect
v-model="form.client_uuid"
:options="props.clients || []"
:reduce="c => c.uuid"
:reduce="(c) => c.uuid"
track-by="uuid"
label="name"
placeholder="Global (no client)"
@@ -70,71 +102,159 @@ function submit() {
:allow-empty="true"
class="mt-1"
/>
<p class="text-xs text-gray-500 mt-1">Leave empty to make this template global (visible to all clients).</p>
<p class="text-xs text-gray-500 mt-1">
Leave empty to make this template global (visible to all clients).
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Entities (tables)</label>
<Multiselect
v-model="form.entities"
:options="[
{ value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Addresses' },
{ value: 'person_phones', label: 'Person Phones' },
{ value: 'emails', label: 'Emails' },
{ value: 'accounts', label: 'Accounts' },
{ value: 'contracts', label: 'Contracts' },
]"
:multiple="true"
track-by="value"
label="label"
:reduce="o => o.value"
placeholder="Select one or more entities"
:searchable="false"
class="mt-1"
/>
<p class="text-xs text-gray-500 mt-1">Choose which tables this template targets. You can still define per-column mappings later.</p>
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-gray-700"
>Entities (tables)</label
>
<label class="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
v-model="form.meta.payments_import"
class="rounded"
/>
<span>Payments import</span>
</label>
</div>
<template v-if="!form.meta.payments_import">
<Multiselect
v-model="form.entities"
:options="[
{ value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Addresses' },
{ value: 'person_phones', label: 'Person Phones' },
{ value: 'emails', label: 'Emails' },
{ value: 'accounts', label: 'Accounts' },
{ value: 'contracts', label: 'Contracts' },
{ value: 'payments', label: 'Payments' },
]"
:multiple="true"
track-by="value"
label="label"
:reduce="(o) => o.value"
placeholder="Select one or more entities"
:searchable="false"
class="mt-1"
/>
</template>
<template v-else>
<div class="mt-1">
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 px-3 py-1 text-xs font-medium">Contracts</span>
<span class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 px-3 py-1 text-xs font-medium">Accounts</span>
<span class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 px-3 py-1 text-xs font-medium">Payments</span>
</div>
</div>
</template>
<p class="text-xs text-gray-500 mt-1">
Choose which tables this template targets. You can still define per-column
mappings later.
</p>
<div v-if="form.meta.payments_import" class="mt-2 text-xs text-gray-600">
Payments mode locks entities to:
<span class="font-medium">Contracts Accounts Payments</span> and
optimizes matching for payments import.
</div>
<div v-if="form.meta.payments_import" class="mt-3">
<label class="block text-sm font-medium text-gray-700"
>Contract match key</label
>
<select
v-model="form.meta.contract_key_mode"
class="mt-1 block w-full border rounded p-2"
>
<option value="reference">
Reference (use only contract.reference to locate records)
</option>
</select>
<p class="text-xs text-gray-500 mt-1">
When importing payments, Contract records are located using the selected
key. Use your CSV mapping to map the appropriate column to the contract
reference.
</p>
</div>
</div>
</div>
<!-- Defaults: Segment / Decision / Action -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Default Segment</label>
<select v-model="form.meta.segment_id" class="mt-1 block w-full border rounded p-2">
<label class="block text-sm font-medium text-gray-700"
>Default Segment</label
>
<select
v-model="form.meta.segment_id"
class="mt-1 block w-full border rounded p-2"
>
<option :value="null">(none)</option>
<option v-for="s in (props.segments || [])" :key="s.id" :value="s.id">{{ s.name }}</option>
<option v-for="s in props.segments || []" :key="s.id" :value="s.id">
{{ s.name }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"
>Default Action (for Activity)</label
>
<select
v-model="form.meta.action_id"
class="mt-1 block w-full border rounded p-2"
>
<option :value="null">(none)</option>
<option v-for="a in props.actions || []" :key="a.id" :value="a.id">
{{ a.name }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Default Decision</label>
<select v-model="form.meta.decision_id" class="mt-1 block w-full border rounded p-2" :disabled="!form.meta.action_id">
<label class="block text-sm font-medium text-gray-700"
>Default Decision</label
>
<select
v-model="form.meta.decision_id"
class="mt-1 block w-full border rounded p-2"
:disabled="!form.meta.action_id"
>
<option :value="null">(none)</option>
<option v-for="d in decisionsForSelectedAction" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
<p v-if="!form.meta.action_id" class="text-xs text-gray-500 mt-1">Select an Action to see its Decisions.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Default Action (for Activity)</label>
<select v-model="form.meta.action_id" class="mt-1 block w-full border rounded p-2">
<option :value="null">(none)</option>
<option v-for="a in (props.actions || [])" :key="a.id" :value="a.id">{{ a.name }}</option>
<option v-for="d in decisionsForSelectedAction" :key="d.id" :value="d.id">
{{ d.name }}
</option>
</select>
<p v-if="!form.meta.action_id" class="text-xs text-gray-500 mt-1">
Select an Action to see its Decisions.
</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<input v-model="form.name" type="text" class="mt-1 block w-full border rounded p-2" />
<input
v-model="form.name"
type="text"
class="mt-1 block w-full border rounded p-2"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Description</label>
<textarea v-model="form.description" class="mt-1 block w-full border rounded p-2" rows="3" />
<textarea
v-model="form.description"
class="mt-1 block w-full border rounded p-2"
rows="3"
/>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Source Type</label>
<select v-model="form.source_type" class="mt-1 block w-full border rounded p-2">
<select
v-model="form.source_type"
class="mt-1 block w-full border rounded p-2"
>
<option value="csv">CSV</option>
<option value="xml">XML</option>
<option value="xls">XLS</option>
@@ -143,23 +263,44 @@ function submit() {
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Default Record Type (optional)</label>
<input v-model="form.default_record_type" type="text" class="mt-1 block w-full border rounded p-2" placeholder="e.g., account, person" />
<label class="block text-sm font-medium text-gray-700"
>Default Record Type (optional)</label
>
<input
v-model="form.default_record_type"
type="text"
class="mt-1 block w-full border rounded p-2"
placeholder="e.g., account, person"
/>
</div>
</div>
<div class="flex items-center gap-2">
<input id="is_active" v-model="form.is_active" type="checkbox" class="rounded" />
<label for="is_active" class="text-sm font-medium text-gray-700">Active</label>
<input
id="is_active"
v-model="form.is_active"
type="checkbox"
class="rounded"
/>
<label for="is_active" class="text-sm font-medium text-gray-700"
>Active</label
>
</div>
<div class="pt-4">
<button @click.prevent="submit" class="px-4 py-2 bg-emerald-600 text-white rounded" :disabled="form.processing">
{{ form.processing ? 'Saving…' : 'Create Template' }}
<button
@click.prevent="submit"
class="px-4 py-2 bg-emerald-600 text-white rounded"
:disabled="form.processing"
>
{{ form.processing ? "Saving…" : "Create Template" }}
</button>
</div>
<div v-if="form.errors && Object.keys(form.errors).length" class="text-sm text-red-600">
<div
v-if="form.errors && Object.keys(form.errors).length"
class="text-sm text-red-600"
>
<div v-for="(msg, key) in form.errors" :key="key">{{ msg }}</div>
</div>
</div>
File diff suppressed because it is too large Load Diff