changes 0328092025

This commit is contained in:
Simon Pocrnjič
2025-09-28 22:36:47 +02:00
parent b40ee9dcde
commit 7e8e0a479b
61 changed files with 4306 additions and 654 deletions
+37 -5
View File
@@ -16,6 +16,11 @@ const props = defineProps({
description: String,
header: Array,
body: Array,
// Make table header sticky while body scrolls
stickyHeader: {
type: Boolean,
default: true
},
editor: {
type: Boolean,
default: false
@@ -115,12 +120,16 @@ const remove = () => {
<p v-if="description" class="mt-1 text-sm text-gray-600">{{ description }}</p>
</div>
<div :class="['relative rounded-lg border border-gray-200 bg-white shadow-sm overflow-x-auto overflow-y-auto', bodyMaxHeight]">
<div :class="['relative rounded-lg border border-gray-200 bg-white shadow-sm overflow-x-auto overflow-y-auto', bodyMaxHeight, stickyHeader ? 'table-sticky' : '']">
<FwbTable hoverable striped class="text-sm">
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur supports-[backdrop-filter]:bg-gray-50/80 border-b border-gray-200 shadow-sm">
<FwbTableHeadCell v-for="(h, hIndex) in header" :key="hIndex" class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6">{{ h.data }}</FwbTableHeadCell>
<FwbTableHeadCell v-if="editor" class="w-px text-gray-700 py-3"></FwbTableHeadCell>
<FwbTableHeadCell v-else class="w-px text-gray-700 py-3" />
<FwbTableHeadCell
v-for="(h, hIndex) in header"
:key="hIndex"
class="sticky top-0 z-10 uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6 bg-gray-50/90"
>{{ h.data }}</FwbTableHeadCell>
<FwbTableHeadCell v-if="editor" class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90"></FwbTableHeadCell>
<FwbTableHeadCell v-else class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90" />
</FwbTableHead>
<FwbTableBody>
<FwbTableRow v-for="(row, key, parent_index) in body" :key="key" :class="row.options.class">
@@ -208,4 +217,27 @@ const remove = () => {
</div>
</form>
</Modal>
</template>
</template>
<style scoped>
/* Ensure sticky header remains above scrollable body inside wrapper */
:deep(.table-sticky thead) {
position: sticky;
top: 0;
z-index: 10;
}
:deep(.table-sticky thead th) {
position: sticky;
top: 0;
z-index: 10;
background-color: rgba(249, 250, 251, 0.9); /* gray-50/90 */
backdrop-filter: saturate(180%) blur(5px);
}
/* Maintain column widths alignment when scrollbar appears */
.table-sticky {
/* Make sure the header and body share the same scroll container */
overflow-y: auto;
}
</style>
@@ -11,6 +11,9 @@ import { ref, watch } from 'vue'
const props = defineProps({
show: { type: Boolean, default: false },
postUrl: { type: String, required: true },
// Optional list of contracts to allow attaching the document directly to a contract
// Each item should have at least: { uuid, reference }
contracts: { type: Array, default: () => [] },
})
const emit = defineEmits(['close', 'uploaded'])
@@ -21,7 +24,8 @@ const form = useForm({
name: '',
description: '',
file: null,
is_public: false,
is_public: true,
contract_uuid: null,
})
const localError = ref('')
@@ -86,6 +90,13 @@ const close = () => emit('close')
<template #title>Dodaj dokument</template>
<template #content>
<div class="space-y-4">
<div v-if="props.contracts && props.contracts.length" class="grid grid-cols-1 gap-2">
<InputLabel for="doc_attach" value="Pripiši k" />
<select id="doc_attach" v-model="form.contract_uuid" class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
<option :value="null">Primer</option>
<option v-for="c in props.contracts" :key="c.uuid" :value="c.uuid">Pogodba: {{ c.reference }}</option>
</select>
</div>
<div>
<InputLabel for="doc_name" value="Name" />
<TextInput id="doc_name" class="mt-1 block w-full" v-model="form.name" />
+18 -6
View File
@@ -11,6 +11,15 @@ const props = defineProps({
// Optional: build a direct download URL for a document; if not provided, a 'download' event will be emitted
downloadUrlBuilder: { type: Function, default: null },
})
// Derive a human-friendly source for a document: Case or Contract reference
const sourceLabel = (doc) => {
// Server can include optional documentable meta; fall back to type
if (doc.documentable_type?.toLowerCase?.().includes('contract')) {
return doc.contract_reference ? `Pogodba ${doc.contract_reference}` : 'Pogodba'
}
return 'Primer'
}
const emit = defineEmits(['view', 'download'])
const formatSize = (bytes) => {
@@ -110,11 +119,11 @@ const handleDownload = (doc) => {
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
<FwbTable hoverable striped class="text-sm">
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm">
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Name</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Type</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Size</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Added</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Drugo</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Naziv</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Vrsta</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Velikost</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Dodano</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Vir</FwbTableHeadCell>
<FwbTableHeadCell class="w-px" />
</FwbTableHead>
<FwbTableBody>
@@ -122,7 +131,7 @@ const handleDownload = (doc) => {
<FwbTableRow>
<FwbTableCell>
<div class="flex items-center gap-2">
<button type="button" class="text-indigo-600 hover:underline" @click="$emit('view', doc)">{{ doc.original_name || doc.name }}</button>
<button type="button" class="text-indigo-600 hover:underline" @click="$emit('view', doc)">{{ doc.name }}</button>
<FwbBadge v-if="doc.is_public" type="green">Public</FwbBadge>
</div>
</FwbTableCell>
@@ -134,6 +143,9 @@ const handleDownload = (doc) => {
</FwbTableCell>
<FwbTableCell>{{ formatSize(doc.size) }}</FwbTableCell>
<FwbTableCell>{{ new Date(doc.created_at).toLocaleString() }}</FwbTableCell>
<FwbTableCell>
<FwbBadge type="purple">{{ sourceLabel(doc) }}</FwbBadge>
</FwbTableCell>
<FwbTableCell class="text-center">
<button
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
@@ -0,0 +1,143 @@
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
person: { type: Object, required: true },
types: { type: Object, default: () => ({}) },
// Allow overriding the default active tab: 'addresses' | 'phones' | 'emails' | 'bank'
defaultTab: { type: String, default: 'addresses' },
})
const phoneTypes = computed(() => {
const arr = props.types?.phone_types || []
const map = {}
for (const t of arr) { map[t.id] = t.name }
return map
})
const displayName = computed(() => {
const p = props.person || {}
const full = p.full_name?.trim()
if (full) { return full }
const first = p.first_name?.trim() || ''
const last = p.last_name?.trim() || ''
return `${first} ${last}`.trim()
})
const primaryAddress = computed(() => props.person?.addresses?.[0] || null)
const primaryEmail = computed(() => props.person?.emails?.[0]?.value || null)
// Backend phone model uses `nu` as the number
const allPhones = computed(() => props.person?.phones || [])
const allAddresses = computed(() => props.person?.addresses || [])
const allEmails = computed(() => props.person?.emails || [])
// Laravel serializes relation names to snake_case, so prefer bank_accounts, fallback to bankAccounts
const allBankAccounts = computed(() => props.person?.bank_accounts || props.person?.bankAccounts || [])
const bankIban = computed(() => allBankAccounts.value?.[0]?.iban || null)
const taxNumber = computed(() => props.person?.tax_number || null)
const ssn = computed(() => props.person?.social_security_number || null)
// Summary sizing
const showMore = ref(false)
const summaryPhones = computed(() => allPhones.value.slice(0, showMore.value ? 2 : 1))
// Tabs
const activeTab = ref(props.defaultTab || 'addresses')
watch(() => props.defaultTab, (val) => { if (val) activeTab.value = val })
function maskIban(iban) {
if (!iban || typeof iban !== 'string') return null
const clean = iban.replace(/\s+/g, '')
if (clean.length <= 8) return clean
return `${clean.slice(0, 4)} **** **** ${clean.slice(-4)}`
}
</script>
<template>
<div class="text-sm">
<div v-if="displayName" class="font-medium text-gray-900">{{ displayName }}</div>
<div v-if="primaryAddress" class="mt-1 text-gray-700">
<span>{{ primaryAddress.address }}</span>
<span v-if="primaryAddress.country" class="text-gray-500 text-xs ml-1">({{ primaryAddress.country }})</span>
</div>
<div v-if="summaryPhones?.length" class="mt-1 space-y-0.5">
<div v-for="p in summaryPhones" :key="p.id" class="text-gray-700">
<span>{{ p.nu }}</span>
<span v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name" class="text-gray-500 text-xs ml-1">({{ p.type?.name || phoneTypes[p.type_id] }})</span>
</div>
</div>
<div v-if="showMore && primaryEmail" class="mt-1 text-gray-700">{{ primaryEmail }}</div>
<div v-if="showMore && bankIban" class="mt-1 text-gray-700">TRR: <span class="font-mono">{{ maskIban(bankIban) }}</span></div>
<div v-if="showMore && taxNumber" class="mt-1 text-gray-700">Davčna: <span class="font-mono">{{ taxNumber }}</span></div>
<div v-if="showMore && ssn" class="mt-1 text-gray-700">EMŠO: <span class="font-mono">{{ ssn }}</span></div>
<button type="button" class="mt-2 text-xs text-blue-600 hover:underline" @click="showMore = !showMore">
{{ showMore ? 'Skrij' : 'Prikaži več' }}
</button>
</div>
<!-- Tabs -->
<div class="mt-3">
<div class="flex gap-2 overflow-x-auto">
<button type="button" @click="activeTab = 'addresses'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='addresses' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a1 1 0 0 1 .832.445l6 8.5a1 1 0 0 1 .168.555V17a1 1 0 0 1-1 1h-4v-4H8v4H4a1 1 0 0 1-1-1v-5.5a1 1 0 0 1 .168-.555l6-8.5A1 1 0 0 1 10 2Z"/></svg>
Naslovi ({{ allAddresses.length }})
</button>
<button type="button" @click="activeTab = 'phones'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='phones' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M2.3 3.3c.6-1 1.9-1.3 2.9-.7l1.7 1a2 2 0 0 1 .9 2.5l-.5 1.2a2 2 0 0 0 .4 2.2l2.8 2.8a2 2 0 0 0 2.2.4l1.2-.5a2 2 0 0 1 2.5.9l1 1.7c.6 1 .3 2.3-.7 2.9-2 1.1-4.5 1.1-6.5 0-2.5-1.3-4.8-3.6-6.1-6.1-1.1-2-1.1-4.5 0-6.5Z"/></svg>
Telefoni ({{ allPhones.length }})
</button>
<button type="button" @click="activeTab = 'emails'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='emails' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M2.5 5A1.5 1.5 0 0 1 4 3.5h12A1.5 1.5 0 0 1 17.5 5v10A1.5 1.5 0 0 1 16 16.5H4A1.5 1.5 0 0 1 2.5 15V5Zm2.1.5 5.4 3.6a1 1 0 0 0 1.1 0l5.4-3.6V5H4.6Z"/></svg>
E-pošta ({{ allEmails.length }})
</button>
<button type="button" @click="activeTab = 'bank'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='bank' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M10 2 2 6v2h16V6l-8-4Zm-6 7h12v7H4V9Zm-1 8h14v1H3v-1Z"/></svg>
TRR ({{ allBankAccounts.length }})
</button>
</div>
<div class="mt-2">
<!-- Addresses -->
<div v-if="activeTab==='addresses'">
<div v-if="!allAddresses.length" class="text-gray-500 text-xs">Ni naslovov.</div>
<div v-for="(a,idx) in allAddresses" :key="a.id || idx" class="py-1">
<div class="text-gray-800">{{ a.address }}</div>
<div v-if="a.country" class="text-gray-600 text-xs">{{ a.country }}</div>
</div>
</div>
<!-- Phones -->
<div v-else-if="activeTab==='phones'">
<div v-if="!allPhones.length" class="text-gray-500 text-xs">Ni telefonov.</div>
<div v-for="(p,idx) in allPhones" :key="p.id || idx" class="py-1">
<div class="text-gray-800">{{ p.nu }} <span v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name" class="text-gray-500 text-xs">({{ p.type?.name || phoneTypes[p.type_id] }})</span></div>
</div>
</div>
<!-- Emails -->
<div v-else-if="activeTab==='emails'">
<div v-if="!allEmails.length" class="text-gray-500 text-xs">Ni e-poštnih naslovov.</div>
<div v-for="(e,idx) in allEmails" :key="e.id || idx" class="py-1">
<div class="text-gray-800">{{ e.value }}<span v-if="e.label" class="text-gray-500 text-xs ml-1">({{ e.label }})</span></div>
</div>
</div>
<!-- Bank accounts -->
<div v-else>
<div v-if="!allBankAccounts.length" class="text-gray-500 text-xs">Ni TRR računov.</div>
<div v-for="(b,idx) in allBankAccounts" :key="b.id || idx" class="py-1">
<div class="text-gray-800">{{ maskIban(b.iban) }}</div>
<div v-if="b.bank_name" class="text-gray-600 text-xs">{{ b.bank_name }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>
+16 -1
View File
@@ -7,6 +7,8 @@ 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 { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faMobileScreenButton } from '@fortawesome/free-solid-svg-icons';
const props = defineProps({
title: String,
@@ -184,6 +186,15 @@ watch(
<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">
@@ -222,8 +233,12 @@ watch(
<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>
<!-- User drop menu --->
<div class="flex items-center">
<!-- 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>
+233
View File
@@ -0,0 +1,233 @@
<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 GlobalSearch from './Partials/GlobalSearch.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faDesktop } from '@fortawesome/free-solid-svg-icons';
const props = defineProps({
title: String,
});
// Sidebar + responsive behavior (same feel as AppLayout)
const sidebarCollapsed = ref(false);
const hasSavedSidebarPref = ref(false);
const isMobile = ref(false);
const mobileSidebarOpen = ref(false);
function applyAutoCollapse() {
if (typeof window === 'undefined') return;
isMobile.value = window.innerWidth < 1024;
sidebarCollapsed.value = isMobile.value;
}
function handleResize() {
if (typeof window !== 'undefined') {
isMobile.value = window.innerWidth < 1024;
if (!isMobile.value) mobileSidebarOpen.value = false;
}
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);
});
onUnmounted(() => window.removeEventListener('resize', handleResize));
watch(sidebarCollapsed, (v) => {
if (!hasSavedSidebarPref.value) return;
try { localStorage.setItem('sidebarCollapsed', v ? '1' : '0'); } catch {}
});
function toggleSidebar() {
hasSavedSidebarPref.value = true;
sidebarCollapsed.value = !sidebarCollapsed.value;
}
function toggleMobileSidebar() {
mobileSidebarOpen.value = !mobileSidebarOpen.value;
}
function handleSidebarToggleClick() {
if (isMobile.value) toggleMobileSidebar();
else toggleSidebar();
}
const logout = () => {
router.post(route('logout'));
};
// Flash toast notifications (same as AppLayout for consistency)
const page = usePage();
const flash = computed(() => page.props.flash || {});
const showToast = ref(false);
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;
setTimeout(() => (showToast.value = false), 3000);
}
},
{ immediate: true }
);
// Global search modal state
const searchOpen = ref(false);
const openSearch = () => (searchOpen.value = true);
const closeSearch = () => (searchOpen.value = false);
</script>
<template>
<div>
<Head :title="title" />
<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>
<!-- Sidebar -->
<aside :class="[
sidebarCollapsed ? 'w-16' : 'w-64',
'bg-white border-r border-gray-200 transition-all duration-200 z-50',
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('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>
</Link>
</div>
<nav class="py-4">
<ul class="space-y-1">
<!-- Single phone link only -->
<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.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Opravila">
<!-- clipboard-list 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 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>
</Link>
</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"
>
<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>
<!-- User drop menu + Desktop switch button -->
<div class="flex items-center">
<!-- Desktop page quick access button -->
<Link :href="route('clientCase')" 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="Desktop">
<FontAwesomeIcon :icon="faDesktop" 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>
<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>
<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">
<slot name="header" />
</div>
</header>
<!-- 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>
@@ -16,7 +16,8 @@ let header = [
C_TD.make('Odločitev', 'header'),
C_TD.make('Opomba', 'header'),
C_TD.make('Datum zapadlosti', 'header'),
C_TD.make('Znesek obljube', 'header')
C_TD.make('Znesek obljube', 'header'),
C_TD.make('Dodal', 'header')
];
const createBody = (data) => {
@@ -25,6 +26,7 @@ const createBody = (data) => {
data.forEach((p) => {
const createdDate = new Date(p.created_at).toLocaleDateString('de');
const dueDate = (p.due_date) ? new Date().toLocaleDateString('de') : null;
const userName = (p.user && p.user.name) ? p.user.name : (p.user_name || '');
const cols = [
C_TD.make(p.contract?.reference ?? ''),
@@ -33,7 +35,8 @@ const createBody = (data) => {
C_TD.make(p.decision.name, 'body'),
C_TD.make(p.note, 'body' ),
C_TD.make(dueDate, 'body' ),
C_TD.make(Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(p.amount), 'body' )
C_TD.make(Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(p.amount), 'body' ),
C_TD.make(userName, 'body')
];
body.push(
@@ -9,7 +9,8 @@ import { useForm } from '@inertiajs/vue3'
const props = defineProps({
show: { type: Boolean, default: false },
client_case: { type: Object, required: true },
contract: { type: Object, required: true },
// Contract can initially be null while dialog is hidden; make it optional to avoid prop warning
contract: { type: Object, default: null },
})
const emit = defineEmits(['close', 'created'])
@@ -23,6 +24,10 @@ const form = useForm({
})
const submit = () => {
if (!props.contract) {
// No contract selected; do nothing safely
return
}
form.post(route('clientCase.contract.object.store', { client_case: props.client_case.uuid, uuid: props.contract.uuid }), {
preserveScroll: true,
onSuccess: () => { emit('created'); form.reset(); close() },
@@ -16,7 +16,7 @@ const items = () => Array.isArray(props.contract?.objects) ? props.contract.obje
<template>
<DialogModal :show="show" @close="close">
<template #title>
Premeti
Predmeti
<span v-if="contract" class="ml-2 text-sm text-gray-500">(Pogodba: {{ contract.reference }})</span>
</template>
<template #content>
@@ -14,6 +14,7 @@ const props = defineProps({
client_case: Object,
show: { type: Boolean, default: false },
types: Array,
account_types: { type: Array, default: () => [] },
// Optional: when provided, drawer acts as edit mode
contract: { type: Object, default: null },
});
@@ -40,6 +41,7 @@ const formContract = useForm({
// nested account fields, if exists
initial_amount: props.contract?.account?.initial_amount ?? null,
balance_amount: props.contract?.account?.balance_amount ?? null,
account_type_id: props.contract?.account?.type_id ?? null,
});
// keep form in sync when switching between create and edit
@@ -51,6 +53,7 @@ const applyContract = (c) => {
formContract.description = c?.description ?? ''
formContract.initial_amount = c?.account?.initial_amount ?? null
formContract.balance_amount = c?.account?.balance_amount ?? null
formContract.account_type_id = c?.account?.type_id ?? null
}
watch(() => props.contract, (c) => {
@@ -160,6 +163,18 @@ const storeOrUpdate = () => {
Račun
</template>
</SectionTitle>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="accountTypeSelect" value="Tip računa"/>
<select
id="accountTypeSelect"
v-model="formContract.account_type_id"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option :value="null"></option>
<option v-for="at in account_types" :key="at.id" :value="at.id">{{ at.name ?? ('#' + at.id) }}</option>
</select>
<InputError :message="formContract.errors.account_type_id" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<InputLabel for="initialAmount" value="Predani znesek"/>
@@ -1,250 +1,463 @@
<script setup>
import { FwbTable, FwbTableHead, FwbTableHeadCell, FwbTableBody, FwbTableRow, FwbTableCell } from 'flowbite-vue'
import Dropdown from '@/Components/Dropdown.vue'
import CaseObjectCreateDialog from './CaseObjectCreateDialog.vue'
import CaseObjectsDialog from './CaseObjectsDialog.vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faCircleInfo, faEllipsisVertical, faPenToSquare, faTrash, faListCheck, faPlus } from '@fortawesome/free-solid-svg-icons'
import {
FwbTable,
FwbTableHead,
FwbTableHeadCell,
FwbTableBody,
FwbTableRow,
FwbTableCell,
} from "flowbite-vue";
import Dropdown from "@/Components/Dropdown.vue";
import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faCircleInfo,
faClock,
faEllipsisVertical,
faPenToSquare,
faTrash,
faListCheck,
faPlus,
} from "@fortawesome/free-solid-svg-icons";
const props = defineProps({
client_case: Object,
contract_types: Array,
contracts: { type: Array, default: () => [] },
segments: { type: Array, default: () => [] },
all_segments: { type: Array, default: () => [] },
})
client_case: Object,
contract_types: Array,
contracts: { type: Array, default: () => [] },
segments: { type: Array, default: () => [] },
all_segments: { type: Array, default: () => [] },
});
const emit = defineEmits(['edit', 'delete', 'add-activity'])
const emit = defineEmits(["edit", "delete", "add-activity"]);
const formatDate = (d) => {
if (!d) return '-'
const dt = new Date(d)
return isNaN(dt.getTime()) ? '-' : dt.toLocaleDateString('de')
}
if (!d) return "-";
const dt = new Date(d);
return isNaN(dt.getTime()) ? "-" : dt.toLocaleDateString("de");
};
const hasDesc = (c) => {
const d = c?.description
return typeof d === 'string' && d.trim().length > 0
}
const d = c?.description;
return typeof d === "string" && d.trim().length > 0;
};
const onEdit = (c) => emit('edit', c)
const onDelete = (c) => emit('delete', c)
const onAddActivity = (c) => emit('add-activity', c)
const onEdit = (c) => emit("edit", c);
const onDelete = (c) => emit("delete", c);
const onAddActivity = (c) => emit("add-activity", c);
// CaseObject dialog state
import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3'
const showObjectDialog = ref(false)
const showObjectsList = ref(false)
const selectedContract = ref(null)
const openObjectDialog = (c) => { selectedContract.value = c; showObjectDialog.value = true }
const closeObjectDialog = () => { showObjectDialog.value = false; selectedContract.value = null }
const openObjectsList = (c) => { selectedContract.value = c; showObjectsList.value = true }
const closeObjectsList = () => { showObjectsList.value = false; selectedContract.value = null }
import { ref, computed } from "vue";
import { router } from "@inertiajs/vue3";
const showObjectDialog = ref(false);
const showObjectsList = ref(false);
const selectedContract = ref(null);
const openObjectDialog = (c) => {
selectedContract.value = c;
showObjectDialog.value = true;
};
const closeObjectDialog = () => {
showObjectDialog.value = false;
selectedContract.value = null;
};
const openObjectsList = (c) => {
selectedContract.value = c;
showObjectsList.value = true;
};
const closeObjectsList = () => {
showObjectsList.value = false;
selectedContract.value = null;
};
// Promise date helpers
const todayStr = computed(() => {
const d = new Date();
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
});
const getPromiseDate = (c) => c?.account?.promise_date || null;
const promiseStatus = (c) => {
const p = getPromiseDate(c);
if (!p) return null;
if (p > todayStr.value) return "future";
if (p === todayStr.value) return "today";
return "past";
};
const promiseColorClass = (c) => {
const s = promiseStatus(c);
if (s === "future") return "text-green-600";
if (s === "today") return "text-yellow-500";
if (s === "past") return "text-red-600";
return "text-gray-400";
};
// Segment helpers
const contractActiveSegment = (c) => {
const arr = c?.segments || []
return arr.find(s => s.pivot?.active) || arr[0] || null
}
const segmentName = (id) => props.segments.find(s => s.id === id)?.name || ''
const confirmChange = ref({ show: false, contract: null, segmentId: null, fromAll: false })
const arr = c?.segments || [];
return arr.find((s) => s.pivot?.active) || arr[0] || null;
};
const segmentName = (id) => props.segments.find((s) => s.id === id)?.name || "";
const confirmChange = ref({
show: false,
contract: null,
segmentId: null,
fromAll: false,
});
const askChangeSegment = (c, segmentId, fromAll = false) => {
confirmChange.value = { show: true, contract: c, segmentId, fromAll }
}
const closeConfirm = () => { confirmChange.value = { show: false, contract: null, segmentId: null } }
confirmChange.value = { show: true, contract: c, segmentId, fromAll };
};
const closeConfirm = () => {
confirmChange.value = { show: false, contract: null, segmentId: null };
};
const doChangeSegment = () => {
const { contract, segmentId, fromAll } = confirmChange.value
if (!contract || !segmentId) return closeConfirm()
if (fromAll) {
router.post(route('clientCase.segments.attach', props.client_case), {
segment_id: segmentId,
contract_uuid: contract.uuid,
make_active_for_contract: true,
}, {
preserveScroll: true,
only: ['contracts', 'segments'],
onFinish: () => closeConfirm(),
})
} else {
router.post(route('clientCase.contract.updateSegment', { client_case: props.client_case.uuid, uuid: contract.uuid }), { segment_id: segmentId }, {
preserveScroll: true,
only: ['contracts'],
onFinish: () => closeConfirm(),
})
}
}
const { contract, segmentId, fromAll } = confirmChange.value;
if (!contract || !segmentId) return closeConfirm();
if (fromAll) {
router.post(
route("clientCase.segments.attach", props.client_case),
{
segment_id: segmentId,
contract_uuid: contract.uuid,
make_active_for_contract: true,
},
{
preserveScroll: true,
only: ["contracts", "segments"],
onFinish: () => closeConfirm(),
}
);
} else {
router.post(
route("clientCase.contract.updateSegment", {
client_case: props.client_case.uuid,
uuid: contract.uuid,
}),
{ segment_id: segmentId },
{
preserveScroll: true,
only: ["contracts"],
onFinish: () => closeConfirm(),
}
);
}
};
</script>
<template>
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
<FwbTable hoverable striped class="text-sm">
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm">
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Ref.</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Datum začetka</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Tip</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Segment</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-right">Predano</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-right">Odprto</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-center">Opis</FwbTableHeadCell>
<FwbTableHeadCell class="w-px" />
</FwbTableHead>
<FwbTableBody>
<template v-for="(c, i) in contracts" :key="c.uuid || i">
<FwbTableRow>
<FwbTableCell>{{ c.reference }}</FwbTableCell>
<FwbTableCell>{{ formatDate(c.start_date) }}</FwbTableCell>
<FwbTableCell>{{ c?.type?.name }}</FwbTableCell>
<FwbTableCell>
<div class="flex items-center gap-2">
<span class="text-gray-700">{{ contractActiveSegment(c)?.name || '-' }}</span>
<Dropdown width="64" align="left">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-7 w-7 rounded-full hover:bg-gray-100"
:class="{ 'opacity-50 cursor-not-allowed': !segments || segments.length === 0 }"
:title="segments && segments.length ? 'Change segment' : 'No segments available for this case'"
>
<FontAwesomeIcon :icon="faPenToSquare" class="h-4 w-4 text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<template v-if="segments && segments.length">
<button v-for="s in segments" :key="s.id" type="button" class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50" @click="askChangeSegment(c, s.id)">
<span>{{ s.name }}</span>
</button>
</template>
<template v-else>
<template v-if="all_segments && all_segments.length">
<div class="px-3 py-2 text-xs text-gray-500">Ni segmentov v tem primeru. Dodaj in nastavi segment:</div>
<button v-for="s in all_segments" :key="s.id" type="button" class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50" @click="askChangeSegment(c, s.id, true)">
<span>{{ s.name }}</span>
</button>
</template>
<template v-else>
<div class="px-3 py-2 text-sm text-gray-500">No segments configured.</div>
</template>
</template>
</div>
</template>
</Dropdown>
</div>
</FwbTableCell>
<FwbTableCell class="text-right">{{ Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(c?.account?.initial_amount ?? 0) }}</FwbTableCell>
<FwbTableCell class="text-right">{{ Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(c?.account?.balance_amount ?? 0) }}</FwbTableCell>
<FwbTableCell class="text-center">
<Dropdown v-if="hasDesc(c)" width="64" align="left">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
:title="'Pokaži opis'"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-700" />
</button>
</template>
<template #content>
<div class="max-w-sm px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap">
{{ c.description }}
</div>
</template>
</Dropdown>
<button
v-else
type="button"
disabled
class="inline-flex items-center justify-center h-8 w-8 rounded-full text-gray-400 cursor-not-allowed"
:title="'Ni opisa'"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4" />
</button>
</FwbTableCell>
<FwbTableCell class="text-right whitespace-nowrap">
<Dropdown align="right" width="56">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
:title="'Actions'"
>
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4 text-gray-700" />
</button>
</template>
<template #content>
<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="onEdit(c)"
>
<FontAwesomeIcon :icon="faPenToSquare" class="h-4 w-4 text-gray-600" />
<span>Edit</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="openObjectsList(c)"
>
<FontAwesomeIcon :icon="faCircleInfo" 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-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="openObjectDialog(c)"
>
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" />
<span>Premet</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>
</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>
</template>
</Dropdown>
</FwbTableCell>
</FwbTableRow>
<div
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
>
<FwbTable hoverable striped class="text-sm">
<FwbTableHead
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
>
<FwbTableHeadCell
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
>Ref.
</FwbTableHeadCell>
<FwbTableHeadCell
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
>Datum začetka
</FwbTableHeadCell>
<FwbTableHeadCell
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
>Tip
</FwbTableHeadCell>
<FwbTableHeadCell
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
>Segment
</FwbTableHeadCell>
<FwbTableHeadCell
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-right"
>
Predano</FwbTableHeadCell
>
<FwbTableHeadCell
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-right"
>
Odprto</FwbTableHeadCell
>
<FwbTableHeadCell
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-center"
>
Opis</FwbTableHeadCell
>
<FwbTableHeadCell class="w-px" />
</FwbTableHead>
<FwbTableBody>
<template v-for="(c, i) in contracts" :key="c.uuid || i">
<FwbTableRow>
<FwbTableCell>{{ c.reference }}</FwbTableCell>
<FwbTableCell>{{ formatDate(c.start_date) }}</FwbTableCell>
<FwbTableCell>{{ c?.type?.name }}</FwbTableCell>
<FwbTableCell>
<div class="flex items-center gap-2">
<span class="text-gray-700">{{
contractActiveSegment(c)?.name || "-"
}}</span>
<Dropdown width="64" align="left">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-7 w-7 rounded-full hover:bg-gray-100"
:class="{
'opacity-50 cursor-not-allowed':
!segments || segments.length === 0,
}"
:title="
segments && segments.length
? 'Change segment'
: 'No segments available for this case'
"
>
<FontAwesomeIcon
:icon="faPenToSquare"
class="h-4 w-4 text-gray-600"
/>
</button>
</template>
<template #content>
<div class="py-1">
<template v-if="segments && segments.length">
<button
v-for="s in segments"
:key="s.id"
type="button"
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
@click="askChangeSegment(c, s.id)"
>
<span>{{ s.name }}</span>
</button>
</template>
<template v-else>
<template v-if="all_segments && all_segments.length">
<div class="px-3 py-2 text-xs text-gray-500">
Ni segmentov v tem primeru. Dodaj in nastavi segment:
</div>
<button
v-for="s in all_segments"
:key="s.id"
type="button"
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
@click="askChangeSegment(c, s.id, true)"
>
<span>{{ s.name }}</span>
</button>
</template>
<template v-else>
<div class="px-3 py-2 text-sm text-gray-500">
No segments configured.
</div>
</template>
</template>
</div>
</template>
</Dropdown>
</div>
</FwbTableCell>
<FwbTableCell class="text-right">{{
Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(c?.account?.initial_amount ?? 0)
}}</FwbTableCell>
<FwbTableCell class="text-right">{{
Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(c?.account?.balance_amount ?? 0)
}}</FwbTableCell>
<FwbTableCell class="text-center">
<div class="inline-flex items-center justify-center gap-0.5">
<Dropdown width="64" align="left">
<template #trigger>
<button
type="button"
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"
>
<FontAwesomeIcon
:icon="faCircleInfo"
class="h-4 w-4"
:class="hasDesc(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 whitespace-pre-wrap"
>
{{ c.description }}
</div>
</template>
</Dropdown>
<!-- Promise date indicator -->
<Dropdown width="64" align="left">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-5 w-5 rounded-full hover:bg-gray-100 focus:outline-none"
:title="
getPromiseDate(c)
? 'Obljubljen datum plačila'
: 'Ni obljubljenega datuma'
"
:disabled="!getPromiseDate(c)"
>
<FontAwesomeIcon
:icon="faClock"
class="h-4 w-4"
:class="promiseColorClass(c)"
/>
</button>
</template>
<template #content>
<div class="px-3 py-2 text-sm text-gray-700">
<div class="flex items-center gap-2">
<FontAwesomeIcon
:icon="faClock"
class="h-4 w-4"
:class="promiseColorClass(c)"
/>
<span class="font-medium">Obljubljeno plačilo</span>
</div>
<div class="mt-1">
<span class="text-gray-500">Datum:</span>
<span class="ml-1">{{ formatDate(getPromiseDate(c)) }}</span>
</div>
<div class="mt-1" v-if="promiseStatus(c) === 'future'">
<span class="text-green-600">V prihodnosti</span>
</div>
<div class="mt-1" v-else-if="promiseStatus(c) === 'today'">
<span class="text-yellow-600">Danes</span>
</div>
<div class="mt-1" v-else-if="promiseStatus(c) === 'past'">
<span class="text-red-600">Zapadlo</span>
</div>
<div class="mt-1 text-gray-500" v-else>
Ni nastavljenega datuma.
</div>
</div>
</template>
</Dropdown>
</div>
</FwbTableCell>
<FwbTableCell class="text-right whitespace-nowrap">
<Dropdown align="right" width="56">
<template #trigger>
<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'"
>
<FontAwesomeIcon
:icon="faEllipsisVertical"
class="h-4 w-4 text-gray-700"
/>
</button>
</template>
</FwbTableBody>
</FwbTable>
<div v-if="!contracts || contracts.length === 0" class="p-6 text-center text-sm text-gray-500">No contracts.</div>
<template #content>
<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="onEdit(c)"
>
<FontAwesomeIcon
:icon="faPenToSquare"
class="h-4 w-4 text-gray-600"
/>
<span>Edit</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="openObjectsList(c)"
>
<FontAwesomeIcon :icon="faCircleInfo" 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-gray-700 hover:bg-gray-50 flex items-center gap-2"
@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>
</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>
</template>
</Dropdown>
</FwbTableCell>
</FwbTableRow>
</template>
</FwbTableBody>
</FwbTable>
<div
v-if="!contracts || contracts.length === 0"
class="p-6 text-center text-sm text-gray-500"
>
No contracts.
</div>
<!-- Confirm change segment -->
<div v-if="confirmChange.show" 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-sm">
<div class="text-sm text-gray-800">
Ali želite spremeniti segment za pogodbo <span class="font-medium">{{ confirmChange.contract?.reference }}</span>?
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="closeConfirm">Prekliči</button>
<button class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" @click="doChangeSegment">Potrdi</button>
</div>
</div>
</div>
<CaseObjectCreateDialog
:show="showObjectDialog"
@close="closeObjectDialog"
:client_case="client_case"
:contract="selectedContract"
/>
<CaseObjectsDialog
:show="showObjectsList"
@close="closeObjectsList"
:client_case="client_case"
:contract="selectedContract"
/>
</template>
</div>
<!-- Confirm change segment -->
<div
v-if="confirmChange.show"
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-sm">
<div class="text-sm text-gray-800">
Ali želite spremeniti segment za pogodbo
<span class="font-medium">{{ confirmChange.contract?.reference }}</span
>?
</div>
<div class="mt-4 flex justify-end gap-2">
<button
class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300"
@click="closeConfirm"
>
Prekliči
</button>
<button
class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
@click="doChangeSegment"
>
Potrdi
</button>
</div>
</div>
</div>
<CaseObjectCreateDialog
:show="showObjectDialog"
@close="closeObjectDialog"
:client_case="client_case"
:contract="selectedContract"
/>
<CaseObjectsDialog
:show="showObjectsList"
@close="closeObjectsList"
:client_case="client_case"
:contract="selectedContract"
/>
</template>
+18 -6
View File
@@ -24,6 +24,7 @@ const props = defineProps({
contracts: Array,
activities: Object,
contract_types: Array,
account_types: { type: Array, default: () => [] },
actions: Array,
types: Object,
documents: Array,
@@ -42,15 +43,19 @@ const onUploaded = () => {
const viewer = ref({ open: false, src: '', title: '' });
const openViewer = (doc) => {
const kind = classifyDocument(doc)
const isContractDoc = (doc?.documentable_type || '').toLowerCase().includes('contract')
if (kind === 'preview') {
const url = route('clientCase.document.view', { client_case: props.client_case.uuid, document: doc.uuid })
viewer.value = { open: true, src: url, title: doc.original_name || doc.name };
const url = isContractDoc && doc.contract_uuid
? route('contract.document.view', { contract: doc.contract_uuid, document: doc.uuid })
: route('clientCase.document.view', { client_case: props.client_case.uuid, document: doc.uuid })
viewer.value = { open: true, src: url, title: doc.original_name || doc.name }
} else {
const url = route('clientCase.document.download', { client_case: props.client_case.uuid, document: doc.uuid })
// immediate download: navigate to URL
const url = isContractDoc && doc.contract_uuid
? route('contract.document.download', { contract: doc.contract_uuid, document: doc.uuid })
: route('clientCase.document.download', { client_case: props.client_case.uuid, document: doc.uuid })
window.location.href = url
}
};
}
const closeViewer = () => { viewer.value.open = false; viewer.value.src = ''; };
const clientDetails = ref(false);
@@ -245,7 +250,12 @@ const submitAttachSegment = () => {
<DocumentsTable
:documents="documents"
@view="openViewer"
:download-url-builder="doc => route('clientCase.document.download', { client_case: client_case.uuid, document: doc.uuid })"
:download-url-builder="doc => {
const isContractDoc = (doc?.documentable_type || '').toLowerCase().includes('contract')
return isContractDoc && doc.contract_uuid
? route('contract.document.download', { contract: doc.contract_uuid, document: doc.uuid })
: route('clientCase.document.download', { client_case: client_case.uuid, document: doc.uuid })
}"
/>
</div>
</div>
@@ -256,6 +266,7 @@ const submitAttachSegment = () => {
@close="closeUpload"
@uploaded="onUploaded"
:post-url="route('clientCase.document.store', client_case)"
:contracts="contracts"
/>
<DocumentViewerDialog :show="viewer.open" :src="viewer.src" :title="viewer.title" @close="closeViewer" />
</AppLayout>
@@ -263,6 +274,7 @@ const submitAttachSegment = () => {
:show="drawerCreateContract"
@close="closeDrawer"
:types="contract_types"
:account_types="account_types"
:client_case="client_case"
:contract="contractEditing"
/>
+202
View File
@@ -0,0 +1,202 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { Link, useForm } from '@inertiajs/vue3'
import { computed, ref } from 'vue'
const props = defineProps({
setting: Object,
contracts: Array,
users: Array,
assignments: Object,
});
const form = useForm({
contract_uuid: null,
assigned_user_id: null,
start_date: null,
end_date: null,
});
// Format helpers (Slovenian formatting)
function formatDate(value) {
if (!value) { return '-'; }
const d = new Date(value);
if (isNaN(d)) { return value; }
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}`;
}
function formatCurrencyEUR(value) {
if (value === null || value === undefined) { return '-'; }
const n = Number(value);
if (isNaN(n)) { return String(value); }
// Thousands separator as dot, decimal as comma, with suffix
return n.toLocaleString('sl-SI', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
}
function assign(contract) {
form.contract_uuid = contract.uuid
// minimal UX: if no user selected yet, just post will fail with error; page can be enhanced later with dropdown.
form.post(route('fieldjobs.assign'))
}
function cancelAssignment(contract) {
const payload = { contract_uuid: contract.uuid }
form.transform(() => payload).post(route('fieldjobs.cancel'))
}
function isAssigned(contract) {
return !!(props.assignments && props.assignments[contract.uuid])
}
function assignedTo(contract) {
return props.assignments?.[contract.uuid]?.assigned_to?.name || null
}
function assignedBy(contract) {
return props.assignments?.[contract.uuid]?.assigned_by?.name || null
}
// removed window.open behavior; default SPA navigation via Inertia Link
// Derived lists
const unassignedContracts = computed(() => {
return (props.contracts || []).filter(c => !isAssigned(c))
})
const assignedContracts = computed(() => {
return (props.contracts || []).filter(c => isAssigned(c))
})
// Filter for assigned table
const assignedFilterUserId = ref('')
const assignedContractsFiltered = computed(() => {
const list = assignedContracts.value
if (!assignedFilterUserId.value) {
return list
}
return list.filter(c => {
const uid = props.assignments?.[c.uuid]?.assigned_to?.id
return String(uid) === String(assignedFilterUserId.value)
})
})
</script>
<template>
<AppLayout title="Dodeljevanje terenskih opravil">
<template #header></template>
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div v-if="!setting" class="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded p-4 mb-6">
Nastavitev za terenska opravila ni najdena. Najprej jo ustvarite v Nastavitve Nastavitve terenskih opravil.
</div>
<!-- Unassigned (Assignable) Contracts -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6 mb-8">
<h2 class="text-xl font-semibold mb-4">Pogodbe (nedodeljene)</h2>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Dodeli uporabniku</label>
<select v-model="form.assigned_user_id" class="border rounded px-3 py-2 w-full max-w-xs">
<option :value="null" disabled>Izberite uporabnika</option>
<option v-for="u in users || []" :key="u.id" :value="u.id">{{ u.name }}</option>
</select>
<div v-if="form.errors.assigned_user_id" class="text-red-600 text-sm mt-1">{{ form.errors.assigned_user_id }}</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">Sklic</th>
<th class="py-2 pr-4">Stranka</th>
<th class="py-2 pr-4">Vrsta</th>
<th class="py-2 pr-4">Začetek</th>
<th class="py-2 pr-4">Konec</th>
<th class="py-2 pr-4">Stanje</th>
<th class="py-2 pr-4">Dejanje</th>
</tr>
</thead>
<tbody>
<tr v-for="c in unassignedContracts" :key="c.uuid" class="border-b last:border-0">
<td class="py-2 pr-4">{{ c.reference }}</td>
<td class="py-2 pr-4">
<Link
v-if="c.client_case?.uuid"
:href="route('clientCase.show', { client_case: c.client_case.uuid })"
class="text-indigo-600 hover:underline"
>
{{ c.client_case?.person?.full_name || 'Primer stranke' }}
</Link>
<span v-else>{{ c.client_case?.person?.full_name || '-' }}</span>
</td>
<td class="py-2 pr-4">{{ c.type?.name }}</td>
<td class="py-2 pr-4">{{ formatDate(c.start_date) }}</td>
<td class="py-2 pr-4">{{ formatDate(c.end_date) }}</td>
<td class="py-2 pr-4">{{ formatCurrencyEUR(c.account?.balance_amount) }}</td>
<td class="py-2 pr-4 flex items-center gap-2">
<button
class="px-3 py-1 text-sm rounded bg-indigo-600 text-white"
@click="assign(c)"
>Dodeli</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Assigned Contracts -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Dodeljene pogodbe</h2>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-700">Filter po uporabniku</label>
<select v-model="assignedFilterUserId" class="border rounded px-3 py-2">
<option value="">Vsi</option>
<option v-for="u in users || []" :key="u.id" :value="u.id">{{ u.name }}</option>
</select>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">Sklic</th>
<th class="py-2 pr-4">Stranka</th>
<th class="py-2 pr-4">Dodeljeno dne</th>
<th class="py-2 pr-4">Dodeljeno komu</th>
<th class="py-2 pr-4">Stanje</th>
<th class="py-2 pr-4">Dejanje</th>
</tr>
</thead>
<tbody>
<tr v-for="c in assignedContractsFiltered" :key="c.uuid" class="border-b last:border-0">
<td class="py-2 pr-4">{{ c.reference }}</td>
<td class="py-2 pr-4">
<Link
v-if="c.client_case?.uuid"
:href="route('clientCase.show', { client_case: c.client_case.uuid })"
class="text-indigo-600 hover:underline"
>
{{ c.client_case?.person?.full_name || 'Primer stranke' }}
</Link>
<span v-else>{{ c.client_case?.person?.full_name || '-' }}</span>
</td>
<td class="py-2 pr-4">{{ formatDate(props.assignments?.[c.uuid]?.assigned_at) }}</td>
<td class="py-2 pr-4">{{ assignedTo(c) || '-' }}</td>
<td class="py-2 pr-4">{{ formatCurrencyEUR(c.account?.balance_amount) }}</td>
<td class="py-2 pr-4">
<button class="px-3 py-1 text-sm rounded bg-red-600 text-white" @click="cancelAssignment(c)">Prekliči</button>
</td>
</tr>
<tr v-if="assignedContractsFiltered.length === 0">
<td colspan="6" class="py-4 text-gray-500">Ni dodeljenih pogodb za izbran filter.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
+339
View File
@@ -0,0 +1,339 @@
<script setup>
import AppPhoneLayout from '@/Layouts/AppPhoneLayout.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
import PersonDetailPhone from '@/Components/PersonDetailPhone.vue';
// Removed table-based component for phone; render a list instead
// import DocumentsTable from '@/Components/DocumentsTable.vue';
import DocumentViewerDialog from '@/Components/DocumentViewerDialog.vue';
import { classifyDocument } from '@/Services/documents';
import { reactive, ref, computed } from 'vue';
import DialogModal from '@/Components/DialogModal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import TextInput from '@/Components/TextInput.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import BasicButton from '@/Components/buttons/BasicButton.vue';
import { useForm } from '@inertiajs/vue3';
import ActivityDrawer from '@/Pages/Cases/Partials/ActivityDrawer.vue';
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
const props = defineProps({
client: Object,
client_case: Object,
contracts: Array,
documents: Array,
types: Object,
actions: Array,
activities: Array,
});
const viewer = reactive({ open: false, src: '', title: '' });
function openViewer(doc) {
const kind = classifyDocument(doc);
const isContractDoc = (doc?.documentable_type || '').toLowerCase().includes('contract');
if (kind === 'preview') {
const url = isContractDoc && doc.contract_uuid
? route('contract.document.view', { contract: doc.contract_uuid, document: doc.uuid })
: route('clientCase.document.view', { client_case: props.client_case.uuid, document: doc.uuid });
viewer.open = true; viewer.src = url; viewer.title = doc.original_name || doc.name;
} else {
const url = isContractDoc && doc.contract_uuid
? route('contract.document.download', { contract: doc.contract_uuid, document: doc.uuid })
: route('clientCase.document.download', { client_case: props.client_case.uuid, document: doc.uuid });
window.location.href = url;
}
}
function closeViewer() { viewer.open = false; viewer.src = ''; }
function formatAmount(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 });
}
// Activity drawer state
const drawerAddActivity = ref(false);
const activityContractUuid = ref(null);
const openDrawerAddActivity = (c = null) => {
activityContractUuid.value = c?.uuid ?? null;
drawerAddActivity.value = true;
};
const closeDrawer = () => { drawerAddActivity.value = false; };
// Document upload state
const docDialogOpen = ref(false);
const docForm = useForm({
file: null,
name: '',
description: '',
is_public: true,
contract_uuid: null,
});
const onPickDocument = (e) => {
const f = e?.target?.files?.[0];
if (f) { docForm.file = f; }
};
const openDocDialog = (c = null) => {
docForm.contract_uuid = c?.uuid ?? null;
docDialogOpen.value = true;
};
const closeDocDialog = () => { docDialogOpen.value = false; };
const submitDocument = () => {
if (!docForm.file) { return; }
docForm.post(route('clientCase.document.store', { client_case: props.client_case.uuid }), {
forceFormData: true,
onSuccess: () => {
closeDocDialog();
docForm.reset('file', 'name', 'description', 'is_public', 'contract_uuid');
},
});
};
const selectedContract = computed(() => {
if (!docForm.contract_uuid) return null;
return props.contracts?.find(c => c.uuid === docForm.contract_uuid) || null;
});
// Complete flow
const confirmComplete = ref(false);
const submitComplete = () => {
// POST to phone.case.complete and redirect handled by server
// Use a small form post via Inertia
const form = useForm({});
form.post(route('phone.case.complete', { client_case: props.client_case.uuid }), {
onFinish: () => { confirmComplete.value = false; },
});
};
</script>
<template>
<AppPhoneLayout :title="`Primer: ${client_case?.person?.full_name || ''}`">
<template #header>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<a :href="route('phone.index')" class="text-sm text-blue-600 hover:underline shrink-0"> Nazaj</a>
<h2 class="font-semibold text-xl text-gray-800 truncate">{{ client_case?.person?.full_name }}</h2>
</div>
<div class="shrink-0">
<button
type="button"
class="px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700"
@click="confirmComplete = true"
>Zaključi</button>
</div>
</div>
</template>
<div class="py-4 sm:py-6">
<div class="mx-auto max-w-5xl px-2 sm:px-4">
<!-- Client details (account holder) -->
<div class="bg-white rounded-lg shadow border overflow-hidden">
<div class="p-3 sm:p-4">
<SectionTitle>
<template #title>Stranka</template>
</SectionTitle>
<div class="mt-2">
<PersonDetailPhone :types="types" :person="client.person" default-tab="phones" />
</div>
</div>
</div>
<!-- Person (case person) -->
<div class="bg-white rounded-lg shadow border overflow-hidden">
<div class="p-3 sm:p-4">
<SectionTitle>
<template #title>Primer - oseba</template>
</SectionTitle>
<div class="mt-2">
<PersonDetailPhone :types="types" :person="client_case.person" default-tab="phones" />
</div>
</div>
</div>
<!-- Contracts assigned to me -->
<div class="mt-4 sm:mt-6 bg-white rounded-lg shadow border overflow-hidden">
<div class="p-3 sm:p-4">
<SectionTitle>
<template #title>Pogodbe</template>
</SectionTitle>
<div class="mt-3 space-y-3">
<div
v-for="c in contracts"
:key="c.uuid || c.id"
class="rounded border p-3 sm:p-4"
>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900">{{ c.reference || c.uuid }}</p>
<p class="text-sm text-gray-600">Tip: {{ c.type?.name || '—' }}</p>
</div>
<div class="text-right">
<div class="space-y-2">
<p v-if="c.account" class="text-sm text-gray-700">Odprto: {{ formatAmount(c.account.balance_amount) }} </p>
<button
type="button"
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 w-full sm:w-auto"
@click="openDrawerAddActivity(c)"
>+ Aktivnost</button>
<button
type="button"
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 w-full sm:w-auto"
@click="openDocDialog(c)"
>+ Dokument</button>
</div>
</div>
</div>
<div v-if="c.last_object" class="mt-2 text-sm text-gray-700">
<p class="font-medium">Predmet:</p>
<p>
<span class="text-gray-900">{{ c.last_object.name || c.last_object.reference }}</span>
<span v-if="c.last_object.type" class="ml-2 text-gray-500">({{ c.last_object.type }})</span>
</p>
<p v-if="c.last_object.description" class="text-gray-600 mt-1">{{ c.last_object.description }}</p>
</div>
</div>
<p v-if="!contracts?.length" class="text-sm text-gray-600">Ni pogodbenih obveznosti dodeljenih vam za ta primer.</p>
</div>
</div>
</div>
<!-- Documents (case + assigned contracts) -->
<div class="mt-4 sm:mt-6 bg-white rounded-lg shadow border overflow-hidden">
<div class="p-3 sm:p-4">
<div class="flex items-center justify-between">
<SectionTitle>
<template #title>Dokumenti</template>
</SectionTitle>
<button
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
@click="openDocDialog()"
>Dodaj</button>
</div>
<div class="mt-3 divide-y">
<div
v-for="d in documents"
:key="d.uuid || d.id"
class="py-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="font-medium text-gray-900 truncate">{{ d.name || d.original_name }}</div>
<div class="text-xs text-gray-500 mt-0.5">
<span v-if="d.contract_reference">Pogodba: {{ d.contract_reference }}</span>
<span v-else>Primer</span>
<span v-if="d.created_at" class="ml-2">· {{ new Date(d.created_at).toLocaleDateString('sl-SI') }}</span>
</div>
<div v-if="d.description" class="text-gray-600 text-sm mt-1 line-clamp-2">{{ d.description }}</div>
</div>
<div class="shrink-0 flex flex-col items-end gap-2">
<button
type="button"
class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-800"
@click="openViewer(d)"
>Ogled</button>
<a
class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-800"
:href="(() => { const isC = (d?.documentable_type || '').toLowerCase().includes('contract'); return isC && d.contract_uuid ? route('contract.document.download', { contract: d.contract_uuid, document: d.uuid }) : route('clientCase.document.download', { client_case: client_case.uuid, document: d.uuid }); })()"
>Prenesi</a>
</div>
</div>
</div>
<div v-if="!documents?.length" class="text-gray-600 text-sm py-2">Ni dokumentov.</div>
</div>
</div>
</div>
<!-- Activities -->
<div class="mt-4 sm:mt-6 bg-white rounded-lg shadow border overflow-hidden">
<div class="p-3 sm:p-4">
<div class="flex items-center justify-between">
<SectionTitle>
<template #title>Aktivnosti</template>
</SectionTitle>
<button
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
@click="openDrawerAddActivity()"
>Nova</button>
</div>
<div class="mt-2 divide-y">
<div v-for="a in activities" :key="a.id" class="py-2 text-sm">
<div class="flex items-center justify-between">
<div class="text-gray-800">{{ a.action?.name }}<span v-if="a.decision"> {{ a.decision?.name }}</span></div>
<div class="text-right text-gray-500">
<div v-if="a.contract">Pogodba: {{ a.contract.reference }}</div>
<div class="text-xs" v-if="a.created_at || a.user || a.user_name">
<span v-if="a.created_at">{{ new Date(a.created_at).toLocaleDateString('sl-SI') }}</span>
<span v-if="(a.user && a.user.name) || a.user_name" class="ml-1">· {{ a.user?.name || a.user_name }}</span>
</div>
</div>
</div>
<div v-if="a.note" class="text-gray-600">{{ a.note }}</div>
<div class="text-gray-500">
<span v-if="a.due_date">Zapadlost: {{ a.due_date }}</span>
<span v-if="a.amount != null" class="ml-2">Znesek: {{ formatAmount(a.amount) }} </span>
</div>
</div>
<div v-if="!activities?.length" class="text-gray-600 py-2">Ni aktivnosti.</div>
</div>
</div>
</div>
</div>
</div>
<DocumentViewerDialog :show="viewer.open" :src="viewer.src" :title="viewer.title" @close="closeViewer" />
<ActivityDrawer :show="drawerAddActivity" @close="closeDrawer" :client_case="client_case" :actions="actions" :contract-uuid="activityContractUuid" />
<ConfirmationModal :show="confirmComplete" @close="confirmComplete = false">
<template #title>Potrditev</template>
<template #content>
Ali ste prepričani da želite že zaključit stranko?
</template>
<template #footer>
<button type="button" class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200" @click="confirmComplete = false">Prekliči</button>
<button type="button" class="px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700 ml-2" @click="submitComplete">Potrdi</button>
</template>
</ConfirmationModal>
<!-- Upload Document Modal -->
<DialogModal :show="docDialogOpen" @close="closeDocDialog">
<template #title>Dodaj dokument</template>
<template #content>
<div class="space-y-4">
<div v-if="selectedContract" class="text-sm text-gray-700">
Dokument bo dodan k pogodbi: <span class="font-medium">{{ selectedContract.reference || selectedContract.uuid }}</span>
</div>
<div>
<InputLabel for="docFile" value="Datoteka" />
<input id="docFile" type="file" class="mt-1 block w-full" @change="onPickDocument" />
<div v-if="docForm.errors.file" class="text-sm text-red-600 mt-1">{{ docForm.errors.file }}</div>
</div>
<div>
<InputLabel for="docName" value="Ime" />
<TextInput id="docName" v-model="docForm.name" class="mt-1 block w-full" />
<div v-if="docForm.errors.name" class="text-sm text-red-600 mt-1">{{ docForm.errors.name }}</div>
</div>
<div>
<InputLabel for="docDesc" value="Opis" />
<TextInput id="docDesc" v-model="docForm.description" class="mt-1 block w-full" />
<div v-if="docForm.errors.description" class="text-sm text-red-600 mt-1">{{ docForm.errors.description }}</div>
</div>
<div class="flex items-center gap-2">
<input id="docPublic" type="checkbox" v-model="docForm.is_public" />
<InputLabel for="docPublic" value="Javno" />
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<button type="button" class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200" @click="closeDocDialog">Prekliči</button>
<button type="button" class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" :disabled="docForm.processing || !docForm.file" @click="submitDocument">Naloži</button>
</div>
</template>
</DialogModal>
</AppPhoneLayout>
</template>
<style scoped>
</style>
+109
View File
@@ -0,0 +1,109 @@
<script setup>
import AppPhoneLayout from '@/Layouts/AppPhoneLayout.vue';
import { computed, ref } from 'vue';
const props = defineProps({
jobs: { type: Array, default: () => [] },
});
const items = computed(() => props.jobs || []);
// Search filter (contract reference or person full name)
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);
});
});
function formatDateDMY(d) {
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';
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 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 (Number.isNaN(num)) return String(val);
return num.toLocaleString('sl-SI', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
</script>
<template>
<AppPhoneLayout title="Phone">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">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>
<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 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>
</div>
<div class="mt-2">
<p class="text-base sm:text-lg font-semibold text-gray-800">
{{ job.contract?.client_case?.person?.full_name || '—' }}
</p>
<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 || '—' }}
</p>
<p>
<span class="font-medium">Telefon:</span>
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || '—' }}
</p>
</div>
<div class="mt-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>
</div>
</div>
</template>
<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>
</div>
</div>
</div>
</AppPhoneLayout>
</template>
+225 -4
View File
@@ -15,6 +15,8 @@ const props = defineProps({
});
const showCreate = ref(false);
const showEdit = ref(false);
const editingId = ref(null);
const segmentOptions = ref([]);
const decisionOptions = ref([]);
@@ -26,8 +28,11 @@ onMounted(() => {
const form = useForm({
segment_id: null,
initial_decision_id: null,
asign_decision_id: null,
assign_decision_id: null,
complete_decision_id: null,
cancel_decision_id: null,
return_segment_id: null,
queue_segment_id: null,
});
const openCreate = () => {
@@ -46,6 +51,45 @@ const store = () => {
onSuccess: () => closeCreate(),
});
};
const editForm = useForm({
segment_id: null,
initial_decision_id: null,
assign_decision_id: null,
complete_decision_id: null,
cancel_decision_id: null,
return_segment_id: null,
queue_segment_id: null,
});
const openEdit = (row) => {
editingId.value = row.id;
editForm.segment_id = row.segment_id ?? row.segment?.id ?? null;
editForm.initial_decision_id = row.initial_decision_id ?? row.initial_decision?.id ?? row.initialDecision?.id ?? null;
editForm.assign_decision_id = row.assign_decision_id ?? row.assign_decision?.id ?? row.assignDecision?.id ?? null;
editForm.complete_decision_id = row.complete_decision_id ?? row.complete_decision?.id ?? row.completeDecision?.id ?? null;
editForm.cancel_decision_id = row.cancel_decision_id ?? row.cancel_decision?.id ?? row.cancelDecision?.id ?? null;
editForm.return_segment_id = row.return_segment_id ?? row.return_segment?.id ?? row.returnSegment?.id ?? null;
editForm.queue_segment_id = row.queue_segment_id ?? row.queue_segment?.id ?? row.queueSegment?.id ?? null;
showEdit.value = true;
};
const closeEdit = () => {
showEdit.value = false;
editingId.value = null;
editForm.reset();
editForm.clearErrors();
};
const update = () => {
if (!editingId.value) {
return;
}
editForm.put(route('settings.fieldjob.update', { setting: editingId.value }), {
preserveScroll: true,
onSuccess: () => closeEdit(),
});
};
</script>
<template>
@@ -100,7 +144,7 @@ const store = () => {
<InputLabel for="assignDecision" value="Assign Decision" />
<multiselect
id="assignDecision"
v-model="form.asign_decision_id"
v-model="form.assign_decision_id"
:options="decisionOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
@@ -108,7 +152,7 @@ const store = () => {
:append-to-body="true"
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="form.errors.asign_decision_id" class="mt-1" />
<InputError :message="form.errors.assign_decision_id" class="mt-1" />
</div>
<div class="mt-2">
@@ -125,6 +169,51 @@ const store = () => {
/>
<InputError :message="form.errors.complete_decision_id" class="mt-1" />
</div>
<div class="mt-2">
<InputLabel for="cancelDecision" value="Cancel Decision" />
<multiselect
id="cancelDecision"
v-model="form.cancel_decision_id"
:options="decisionOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select cancel decision (optional)"
:append-to-body="true"
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="form.errors.cancel_decision_id" class="mt-1" />
</div>
<div class="mt-2">
<InputLabel for="returnSegment" value="Return Segment" />
<multiselect
id="returnSegment"
v-model="form.return_segment_id"
:options="segmentOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select return segment (optional)"
:append-to-body="true"
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="form.errors.return_segment_id" class="mt-1" />
</div>
<div class="mt-2">
<InputLabel for="queueSegment" value="Queue Segment" />
<multiselect
id="queueSegment"
v-model="form.queue_segment_id"
:options="segmentOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select queue segment (optional)"
:append-to-body="true"
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="form.errors.queue_segment_id" class="mt-1" />
</div>
</div>
<div class="flex justify-end gap-2 mt-6">
@@ -134,6 +223,126 @@ const store = () => {
</form>
</template>
</DialogModal>
<DialogModal :show="showEdit" @close="closeEdit">
<template #title>
Edit Field Job Setting
</template>
<template #content>
<form @submit.prevent="update">
<div class="grid grid-cols-1 gap-4">
<div>
<InputLabel for="edit-segment" value="Segment" />
<multiselect
id="edit-segment"
v-model="editForm.segment_id"
:options="segmentOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select segment"
:append-to-body="true"
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="editForm.errors.segment_id" class="mt-1" />
</div>
<div>
<InputLabel for="edit-initialDecision" value="Initial Decision" />
<multiselect
id="edit-initialDecision"
v-model="editForm.initial_decision_id"
:options="decisionOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select initial decision"
:append-to-body="true"
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="editForm.errors.initial_decision_id" class="mt-1" />
</div>
<div>
<InputLabel for="edit-assignDecision" value="Assign Decision" />
<multiselect
id="edit-assignDecision"
v-model="editForm.assign_decision_id"
:options="decisionOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select assign decision"
:append-to-body="true"
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="editForm.errors.assign_decision_id" class="mt-1" />
</div>
<div class="mt-2">
<InputLabel for="edit-completeDecision" value="Complete Decision" />
<multiselect
id="edit-completeDecision"
v-model="editForm.complete_decision_id"
:options="decisionOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select complete decision"
:append-to-body="true"
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="editForm.errors.complete_decision_id" class="mt-1" />
</div>
<div class="mt-2">
<InputLabel for="edit-cancelDecision" value="Cancel Decision" />
<multiselect
id="edit-cancelDecision"
v-model="editForm.cancel_decision_id"
:options="decisionOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select cancel decision (optional)"
:append-to-body="true"
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="editForm.errors.cancel_decision_id" class="mt-1" />
</div>
<div class="mt-2">
<InputLabel for="edit-returnSegment" value="Return Segment" />
<multiselect
id="edit-returnSegment"
v-model="editForm.return_segment_id"
:options="segmentOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select return segment (optional)"
:append-to-body="true"
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="editForm.errors.return_segment_id" class="mt-1" />
</div>
<div class="mt-2">
<InputLabel for="edit-queueSegment" value="Queue Segment" />
<multiselect
id="edit-queueSegment"
v-model="editForm.queue_segment_id"
:options="segmentOptions.map(o=>o.id)"
:multiple="false"
:searchable="true"
placeholder="Select queue segment (optional)"
:append-to-body="true"
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
/>
<InputError :message="editForm.errors.queue_segment_id" class="mt-1" />
</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button type="button" @click="closeEdit" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300">Cancel</button>
<PrimaryButton :disabled="editForm.processing">Save</PrimaryButton>
</div>
</form>
</template>
</DialogModal>
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
@@ -142,6 +351,10 @@ const store = () => {
<th class="py-2 pr-4">Initial Decision</th>
<th class="py-2 pr-4">Assign Decision</th>
<th class="py-2 pr-4">Complete Decision</th>
<th class="py-2 pr-4">Cancel Decision</th>
<th class="py-2 pr-4">Return Segment</th>
<th class="py-2 pr-4">Queue Segment</th>
<th class="py-2 pr-4">Actions</th>
</tr>
</thead>
<tbody>
@@ -149,8 +362,16 @@ const store = () => {
<td class="py-2 pr-4">{{ row.id }}</td>
<td class="py-2 pr-4">{{ row.segment?.name }}</td>
<td class="py-2 pr-4">{{ row.initial_decision?.name || row.initialDecision?.name }}</td>
<td class="py-2 pr-4">{{ row.asign_decision?.name || row.asignDecision?.name }}</td>
<td class="py-2 pr-4">{{ row.assign_decision?.name || row.assignDecision?.name }}</td>
<td class="py-2 pr-4">{{ row.complete_decision?.name || row.completeDecision?.name }}</td>
<td class="py-2 pr-4">{{ row.cancel_decision?.name || row.cancelDecision?.name }}</td>
<td class="py-2 pr-4">{{ row.return_segment?.name || row.returnSegment?.name }}</td>
<td class="py-2 pr-4">{{ row.queue_segment?.name || row.queueSegment?.name }}</td>
<td class="py-2 pr-4">
<button @click="openEdit(row)" class="px-3 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700">
Edit
</button>
</td>
</tr>
</tbody>
</table>