changes notification added

This commit is contained in:
Simon Pocrnjič 2025-10-01 22:33:36 +02:00
parent db99a57030
commit 0e0912c81b
10 changed files with 994 additions and 485 deletions

View File

@ -210,6 +210,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
'contract_uuid' => 'nullable|uuid', 'contract_uuid' => 'nullable|uuid',
]); ]);
// Map contract_uuid to contract_id within the same client case, if provided // Map contract_uuid to contract_id within the same client case, if provided
$contractId = null; $contractId = null;
if (! empty($attributes['contract_uuid'])) { if (! empty($attributes['contract_uuid'])) {

View File

@ -42,6 +42,43 @@ public function share(Request $request): array
'warning' => fn () => $request->session()->get('warning'), 'warning' => fn () => $request->session()->get('warning'),
'info' => fn () => $request->session()->get('info'), 'info' => fn () => $request->session()->get('info'),
], ],
'notifications' => function () use ($request) {
try {
$user = $request->user();
if (! $user) {
return null;
}
$today = now()->toDateString();
$activities = \App\Models\Activity::query()
->with([
// Include contract uuid and reference, keep id for relation mapping, and client_case_id for nested eager load
'contract:id,uuid,reference,client_case_id',
// Include client case uuid (id required for mapping, will be hidden in JSON)
'contract.clientCase:id,uuid',
// Include account amounts; contract_id needed for relation mapping
'contract.account:contract_id,balance_amount,initial_amount',
])
->whereDate('due_date', $today)
->where('user_id', $user->id)
->orderBy('created_at')
->limit(20)
->get();
return [
'dueToday' => [
'count' => $activities->count(),
'items' => $activities,
'date' => $today,
],
];
} catch (\Throwable $e) {
return null;
}
},
]); ]);
} }
} }

603
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@
"laravel-vite-plugin": "^1.0", "laravel-vite-plugin": "^1.0",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"vite": "^5.0", "vite": "^7.1.7",
"vue": "^3.3.13" "vue": "^3.3.13"
}, },
"dependencies": { "dependencies": {
@ -27,13 +27,15 @@
"@fortawesome/vue-fontawesome": "^3.0.8", "@fortawesome/vue-fontawesome": "^3.0.8",
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@vuepic/vue-datepicker": "^9.0.3", "@internationalized/date": "^3.9.0",
"@vuepic/vue-datepicker": "^11.0.2",
"apexcharts": "^4.0.0", "apexcharts": "^4.0.0",
"flowbite": "^2.5.2", "flowbite": "^2.5.2",
"flowbite-vue": "^0.1.6", "flowbite-vue": "^0.1.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"material-design-icons-iconfont": "^6.7.0", "material-design-icons-iconfont": "^6.7.0",
"preline": "^2.7.0", "preline": "^2.7.0",
"reka-ui": "^2.5.1",
"tailwindcss-inner-border": "^0.2.0", "tailwindcss-inner-border": "^0.2.0",
"v-calendar": "^3.1.2", "v-calendar": "^3.1.2",
"vue-multiselect": "^3.1.0", "vue-multiselect": "^3.1.0",

View File

@ -7,6 +7,7 @@ import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue'; import DropdownLink from '@/Components/DropdownLink.vue';
import Breadcrumbs from '@/Components/Breadcrumbs.vue'; import Breadcrumbs from '@/Components/Breadcrumbs.vue';
import GlobalSearch from './Partials/GlobalSearch.vue'; import GlobalSearch from './Partials/GlobalSearch.vue';
import NotificationsBell from './Partials/NotificationsBell.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faMobileScreenButton } from '@fortawesome/free-solid-svg-icons'; import { faMobileScreenButton } from '@fortawesome/free-solid-svg-icons';
@ -108,6 +109,8 @@ watch(
{ immediate: true } { immediate: true }
); );
// No automatic daily notifications
</script> </script>
<template> <template>
@ -241,8 +244,9 @@ watch(
<kbd class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border bg-gray-50">Ctrl K</kbd> <kbd class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border bg-gray-50">Ctrl K</kbd>
</button> </button>
</div> </div>
<!-- User drop menu ---> <!-- Notifications + User drop menu --->
<div class="flex items-center"> <div class="flex items-center">
<NotificationsBell class="mr-2" />
<!-- Phone page quick access button --> <!-- 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"> <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" /> <FontAwesomeIcon :icon="faMobileScreenButton" class="h-5 w-5" />

View File

@ -1,13 +1,14 @@
<script setup> <script setup>
import { onMounted, onUnmounted, ref, watch, computed } from 'vue'; import { onMounted, onUnmounted, ref, watch, computed } from "vue";
import { Head, Link, router, usePage } from '@inertiajs/vue3'; import { Head, Link, router, usePage } from "@inertiajs/vue3";
import ApplicationMark from '@/Components/ApplicationMark.vue'; import ApplicationMark from "@/Components/ApplicationMark.vue";
import Banner from '@/Components/Banner.vue'; import Banner from "@/Components/Banner.vue";
import Dropdown from '@/Components/Dropdown.vue'; import Dropdown from "@/Components/Dropdown.vue";
import DropdownLink from '@/Components/DropdownLink.vue'; import DropdownLink from "@/Components/DropdownLink.vue";
import GlobalSearch from './Partials/GlobalSearch.vue'; import GlobalSearch from "./Partials/GlobalSearch.vue";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import NotificationsBell from "./Partials/NotificationsBell.vue";
import { faDesktop } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faDesktop } from "@fortawesome/free-solid-svg-icons";
const props = defineProps({ const props = defineProps({
title: String, title: String,
@ -19,12 +20,12 @@ const hasSavedSidebarPref = ref(false);
const isMobile = ref(false); const isMobile = ref(false);
const mobileSidebarOpen = ref(false); const mobileSidebarOpen = ref(false);
function applyAutoCollapse() { function applyAutoCollapse() {
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
isMobile.value = window.innerWidth < 1024; isMobile.value = window.innerWidth < 1024;
sidebarCollapsed.value = isMobile.value; sidebarCollapsed.value = isMobile.value;
} }
function handleResize() { function handleResize() {
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
isMobile.value = window.innerWidth < 1024; isMobile.value = window.innerWidth < 1024;
if (!isMobile.value) mobileSidebarOpen.value = false; if (!isMobile.value) mobileSidebarOpen.value = false;
} }
@ -32,20 +33,22 @@ function handleResize() {
} }
onMounted(() => { onMounted(() => {
try { try {
const saved = localStorage.getItem('sidebarCollapsed'); const saved = localStorage.getItem("sidebarCollapsed");
if (saved !== null) { if (saved !== null) {
hasSavedSidebarPref.value = true; hasSavedSidebarPref.value = true;
sidebarCollapsed.value = saved === '1'; sidebarCollapsed.value = saved === "1";
} else { } else {
applyAutoCollapse(); applyAutoCollapse();
} }
} catch {} } catch {}
window.addEventListener('resize', handleResize); window.addEventListener("resize", handleResize);
}); });
onUnmounted(() => window.removeEventListener('resize', handleResize)); onUnmounted(() => window.removeEventListener("resize", handleResize));
watch(sidebarCollapsed, (v) => { watch(sidebarCollapsed, (v) => {
if (!hasSavedSidebarPref.value) return; if (!hasSavedSidebarPref.value) return;
try { localStorage.setItem('sidebarCollapsed', v ? '1' : '0'); } catch {} try {
localStorage.setItem("sidebarCollapsed", v ? "1" : "0");
} catch {}
}); });
function toggleSidebar() { function toggleSidebar() {
@ -61,20 +64,20 @@ function handleSidebarToggleClick() {
} }
const logout = () => { const logout = () => {
router.post(route('logout')); router.post(route("logout"));
}; };
// Flash toast notifications (same as AppLayout for consistency) // Flash toast notifications (same as AppLayout for consistency)
const page = usePage(); const page = usePage();
const flash = computed(() => page.props.flash || {}); const flash = computed(() => page.props.flash || {});
const showToast = ref(false); const showToast = ref(false);
const toastMessage = ref(''); const toastMessage = ref("");
const toastType = ref('success'); const toastType = ref("success");
watch( watch(
() => [flash.value.success, flash.value.error, flash.value.warning, flash.value.info], () => [flash.value.success, flash.value.error, flash.value.warning, flash.value.info],
([s, e, w, i]) => { ([s, e, w, i]) => {
const message = s || e || w || i; const message = s || e || w || i;
const type = s ? 'success' : e ? 'error' : w ? 'warning' : i ? 'info' : null; const type = s ? "success" : e ? "error" : w ? "warning" : i ? "info" : null;
if (message && type) { if (message && type) {
toastMessage.value = message; toastMessage.value = message;
toastType.value = type; toastType.value = type;
@ -90,6 +93,7 @@ const searchOpen = ref(false);
const openSearch = () => (searchOpen.value = true); const openSearch = () => (searchOpen.value = true);
const closeSearch = () => (searchOpen.value = false); const closeSearch = () => (searchOpen.value = false);
// No automatic daily notifications
</script> </script>
<template> <template>
@ -100,16 +104,23 @@ const closeSearch = () => (searchOpen.value = false);
<div class="min-h-screen bg-gray-100 flex"> <div class="min-h-screen bg-gray-100 flex">
<!-- Mobile backdrop --> <!-- Mobile backdrop -->
<div v-if="isMobile && mobileSidebarOpen" class="fixed inset-0 z-40 bg-black/30" @click="mobileSidebarOpen=false"></div> <div
v-if="isMobile && mobileSidebarOpen"
class="fixed inset-0 z-40 bg-black/30"
@click="mobileSidebarOpen = false"
></div>
<!-- Sidebar --> <!-- Sidebar -->
<aside :class="[ <aside
:class="[
sidebarCollapsed ? 'w-16' : 'w-64', sidebarCollapsed ? 'w-16' : 'w-64',
'bg-white border-r border-gray-200 transition-all duration-200 z-50', 'bg-white border-r border-gray-200 transition-all duration-200 z-50',
isMobile isMobile
? ('fixed inset-y-0 left-0 transform ' + (mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full')) ? 'fixed inset-y-0 left-0 transform ' +
: 'sticky top-0 h-screen overflow-y-auto' (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"> <div class="h-16 px-4 flex items-center justify-between border-b">
<Link :href="route('phone.index')" class="flex items-center gap-2"> <Link :href="route('phone.index')" class="flex items-center gap-2">
<ApplicationMark class="h-8 w-auto" /> <ApplicationMark class="h-8 w-auto" />
@ -120,10 +131,30 @@ const closeSearch = () => (searchOpen.value = false);
<ul class="space-y-1"> <ul class="space-y-1">
<!-- Single phone link only --> <!-- Single phone link only -->
<li> <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"> <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 --> <!-- 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"> <svg
<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" /> 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> </svg>
<span v-if="!sidebarCollapsed">Opravila</span> <span v-if="!sidebarCollapsed">Opravila</span>
</Link> </Link>
@ -135,7 +166,9 @@ const closeSearch = () => (searchOpen.value = false);
<!-- Main column --> <!-- Main column -->
<div class="flex-1 flex flex-col min-w-0"> <div class="flex-1 flex flex-col min-w-0">
<!-- Top bar --> <!-- 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="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"> <div class="flex items-center gap-2">
<!-- Sidebar toggle --> <!-- Sidebar toggle -->
<button <button
@ -144,47 +177,107 @@ const closeSearch = () => (searchOpen.value = false);
:title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'" :title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'"
aria-label="Toggle sidebar" 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"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> 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> </svg>
</button> </button>
<!-- Search trigger --> <!-- 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"> <button
<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"> @click="openSearch"
<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" /> 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> </svg>
<span class="hidden sm:inline">Globalni iskalnik</span> <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> <kbd
class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border bg-gray-50"
>Ctrl K</kbd
>
</button> </button>
</div> </div>
<!-- User drop menu + Desktop switch button --> <!-- Notifications + User drop menu + Desktop switch button -->
<div class="flex items-center"> <div class="flex items-center">
<NotificationsBell class="mr-2" />
<!-- Desktop page quick access button --> <!-- 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"> <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" /> <FontAwesomeIcon :icon="faDesktop" class="h-5 w-5" />
</Link> </Link>
<div class="ms-3 relative"> <div class="ms-3 relative">
<Dropdown align="right" width="48"> <Dropdown align="right" width="48">
<template #trigger> <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"> <button
<img class="h-8 w-8 rounded-full object-cover" :src="$page.props.auth.user.profile_photo_url" :alt="$page.props.auth.user.name"> 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> </button>
<span v-else class="inline-flex rounded-md"> <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"> <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 }} {{ $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"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /> 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> </svg>
</button> </button>
</span> </span>
</template> </template>
<template #content> <template #content>
<div class="block px-4 py-2 text-xs text-gray-400">Nastavitve računa</div> <div class="block px-4 py-2 text-xs text-gray-400">
Nastavitve računa
</div>
<DropdownLink :href="route('profile.show')">Profil</DropdownLink> <DropdownLink :href="route('profile.show')">Profil</DropdownLink>
<DropdownLink v-if="$page.props.jetstream.hasApiFeatures" :href="route('api-tokens.index')">API Tokens</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" />
@ -212,7 +305,7 @@ const closeSearch = () => (searchOpen.value = false);
</div> </div>
<!-- Global Search Modal --> <!-- Global Search Modal -->
<GlobalSearch :open="searchOpen" @update:open="(v)=>searchOpen=v" /> <GlobalSearch :open="searchOpen" @update:open="(v) => (searchOpen = v)" />
<!-- Simple Toast --> <!-- Simple Toast -->
<transition name="fade"> <transition name="fade">

View File

@ -0,0 +1,107 @@
<script setup>
import { computed, onMounted } from "vue";
import { usePage, Link } from "@inertiajs/vue3";
import Dropdown from "@/Components/Dropdown.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faBell } from "@fortawesome/free-solid-svg-icons";
const page = usePage();
const due = computed(
() => page.props.notifications?.dueToday || { count: 0, items: [], date: null }
);
function fmtDate(d) {
if (!d) return "";
try {
return new Date(d).toLocaleDateString("sl-SI");
} catch {
return String(d);
}
}
function fmtEUR(value) {
if (value === null || value === undefined) {
return "—";
}
const num = typeof value === "string" ? Number(value) : value;
if (Number.isNaN(num)) {
return String(value);
}
// de-DE locale: dot thousands, comma decimals, trailing Euro symbol
const formatted = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(num);
// Replace non-breaking space with normal space for consistency
return formatted.replace("\u00A0", " ");
}
onMounted(() => {
console.log(due.value);
});
</script>
<template>
<Dropdown
align="right"
width="72"
:content-classes="['py-1', 'bg-white', 'max-h-96', 'overflow-auto']"
>
<template #trigger>
<button
type="button"
class="relative inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100"
aria-label="Notifications"
>
<FontAwesomeIcon :icon="faBell" class="w-5 h-5" />
<span
v-if="due.count"
class="absolute -top-1 -right-1 inline-flex items-center justify-center h-5 min-w-[1.25rem] px-1 rounded-full text-[11px] bg-red-600 text-white"
>{{ due.count }}</span
>
</button>
</template>
<template #content>
<div class="px-3 py-2 text-xs text-gray-400 border-b">Obljube zapadejo jutri</div>
<!-- Scrollable content area with max height -->
<div class="max-h-96 overflow-y-auto">
<div v-if="!due.count" class="px-3 py-3 text-sm text-gray-500">
Ni zapadlih aktivnosti danes.
</div>
<ul v-else class="divide-y">
<li
v-for="item in due.items"
:key="item.id"
class="px-3 py-2 text-sm flex items-start gap-2"
>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-800 truncate">
Pogodba:
<Link
v-if="item.contract?.client_case?.uuid"
:href="
route('clientCase.show', {
client_case: item.contract.client_case.uuid,
})
"
class="text-indigo-600 hover:text-indigo-700 hover:underline"
>
{{ item.contract?.reference || "—" }}
</Link>
<span v-else>{{ item.contract?.reference || "" }}</span>
</div>
<div class="text-gray-600 truncate">
{{ fmtEUR(item.contract?.account?.balance_amount) }}
</div>
</div>
<div class="text-xs text-gray-500 whitespace-nowrap">
{{ fmtDate(item.due_date) }}
</div>
</li>
</ul>
</div>
</template>
</Dropdown>
</template>

View File

@ -1,22 +1,18 @@
<script setup> <script setup>
import ActionMessage from '@/Components/ActionMessage.vue'; import ActionMessage from "@/Components/ActionMessage.vue";
import BasicButton from '@/Components/buttons/BasicButton.vue'; import BasicButton from "@/Components/buttons/BasicButton.vue";
import DialogModal from '@/Components/DialogModal.vue'; import DialogModal from "@/Components/DialogModal.vue";
import InputLabel from '@/Components/InputLabel.vue'; import InputLabel from "@/Components/InputLabel.vue";
import DatePickerField from '@/Components/DatePickerField.vue'; import DatePickerField from "@/Components/DatePickerField.vue";
import PrimaryButton from '@/Components/PrimaryButton.vue'; import TextInput from "@/Components/TextInput.vue";
import SectionTitle from '@/Components/SectionTitle.vue'; import { useForm } from "@inertiajs/vue3";
import TextInput from '@/Components/TextInput.vue'; import { FwbTextarea } from "flowbite-vue";
import { PlusIcon } from '@/Utilities/Icons'; import { ref, watch } from "vue";
import { useForm } from '@inertiajs/vue3';
import { FwbTextarea } from 'flowbite-vue';
import { ref, watch } from 'vue';
const props = defineProps({ const props = defineProps({
show: { show: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
client_case: Object, client_case: Object,
actions: Array, actions: Array,
@ -28,16 +24,16 @@ const decisions = ref(props.actions[0].decisions);
console.log(props.actions); console.log(props.actions);
const emit = defineEmits(['close']); const emit = defineEmits(["close"]);
const close = () => { const close = () => {
emit('close'); emit("close");
} };
const form = useForm({ const form = useForm({
due_date: null, due_date: null,
amount: null, amount: null,
note: '', note: "",
action_id: props.actions[0].id, action_id: props.actions[0].id,
decision_id: props.actions[0].decisions[0].id, decision_id: props.actions[0].decisions[0].id,
contract_uuid: props.contractUuid, contract_uuid: props.contractUuid,
@ -55,46 +51,48 @@ watch(
() => form.due_date, () => form.due_date,
(due_date) => { (due_date) => {
if (due_date) { if (due_date) {
let date = new Date(form.due_date).toISOString().split('T')[0]; let date = new Date(form.due_date).toLocaleDateString("en-CA");
console.table({ old: due_date, new: date }); console.table({ old: due_date, new: date });
} }
} }
); );
// keep contract_uuid synced if the prop changes while the drawer is open // keep contract_uuid synced if the prop changes while the drawer is open
watch( watch(
() => props.contractUuid, () => props.contractUuid,
(cu) => { form.contract_uuid = cu || null; } (cu) => {
form.contract_uuid = cu || null;
}
); );
const store = () => { const store = async () => {
console.table({ console.table({
due_date: form.due_date, due_date: form.due_date,
action_id: form.action_id, action_id: form.action_id,
decision_id: form.decision_id, decision_id: form.decision_id,
amount: form.amount, amount: form.amount,
note: form.note note: form.note,
}); });
form.post(route('clientCase.activity.store', props.client_case), {
onBefore: () => { form
if (form.due_date) { .transform((data) => ({
form.due_date = new Date(form.due_date).toISOString().split('T')[0]; ...data,
} due_date: new Date(data.due_date).toLocaleDateString("en-CA"),
}, }))
.post(route("clientCase.activity.store", props.client_case), {
onSuccess: () => { onSuccess: () => {
close(); close();
// Preserve selected contract across submissions; reset only user-editable fields // Preserve selected contract across submissions; reset only user-editable fields
form.reset('due_date', 'amount', 'note'); form.reset("due_date", "amount", "note");
}, },
onError: (errors) => { onError: (errors) => {
console.log('Validation or server error:', errors); console.log("Validation or server error:", errors);
}, },
onFinish: () => { onFinish: () => {
console.log('Request finished processing.') console.log("Request finished processing.");
} },
}); });
} };
// When the drawer opens, always sync the current contractUuid into the form, // When the drawer opens, always sync the current contractUuid into the form,
// even if the value hasn't changed (prevents stale/null contract_uuid after reset) // even if the value hasn't changed (prevents stale/null contract_uuid after reset)
@ -106,7 +104,6 @@ watch(
} }
} }
); );
</script> </script>
<template> <template>
<DialogModal :show="show" @close="close"> <DialogModal :show="show" @close="close">
@ -117,7 +114,10 @@ watch(
<InputLabel for="activityAction" value="Akcija" /> <InputLabel for="activityAction" value="Akcija" />
<select <select
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
id="activityAction" ref="activityActionSelect" v-model="form.action_id"> id="activityAction"
ref="activityActionSelect"
v-model="form.action_id"
>
<option v-for="a in actions" :value="a.id">{{ a.name }}</option> <option v-for="a in actions" :value="a.id">{{ a.name }}</option>
<!-- ... --> <!-- ... -->
</select> </select>
@ -126,29 +126,56 @@ watch(
<InputLabel for="activityDecision" value="Odločitev" /> <InputLabel for="activityDecision" value="Odločitev" />
<select <select
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
id="activityDecision" ref="activityDecisionSelect" v-model="form.decision_id"> id="activityDecision"
ref="activityDecisionSelect"
v-model="form.decision_id"
>
<option v-for="d in decisions" :value="d.id">{{ d.name }}</option> <option v-for="d in decisions" :value="d.id">{{ d.name }}</option>
<!-- ... --> <!-- ... -->
</select> </select>
</div> </div>
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<FwbTextarea label="Opomba" id="activityNote" ref="activityNoteTextarea" v-model="form.note" <FwbTextarea
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" /> label="Opomba"
id="activityNote"
ref="activityNoteTextarea"
v-model="form.note"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
/>
</div> </div>
<DatePickerField id="activityDueDate" label="Datum zapadlosti" v-model="form.due_date" <DatePickerField
format="dd.MM.yyyy" :enable-time-picker="false" :auto-position="true" :teleport-target="'body'" id="activityDueDate"
:inline="false" :auto-apply="false" :fixed="false" :close-on-auto-apply="true" label="Datum zapadlosti"
:close-on-scroll="true" /> v-model="form.due_date"
format="dd.MM.yyyy"
:enable-time-picker="false"
:auto-position="true"
:teleport-target="'body'"
:inline="false"
:auto-apply="false"
:fixed="false"
:close-on-auto-apply="true"
:close-on-scroll="true"
/>
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<InputLabel for="activityAmount" value="Znesek" /> <InputLabel for="activityAmount" value="Znesek" />
<TextInput id="activityAmount" ref="activityAmountinput" v-model="form.amount" type="number" <TextInput
class="mt-1 block w-full" autocomplete="0.00" /> id="activityAmount"
ref="activityAmountinput"
v-model="form.amount"
type="number"
class="mt-1 block w-full"
autocomplete="0.00"
/>
</div> </div>
<div class="flex justify-end mt-4"> <div class="flex justify-end mt-4">
<ActionMessage :on="form.recentlySuccessful" class="me-3"> <ActionMessage :on="form.recentlySuccessful" class="me-3">
Shranjuje. Shranjuje.
</ActionMessage> </ActionMessage>
<BasicButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing"> <BasicButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Shrani Shrani
</BasicButton> </BasicButton>
</div> </div>

View File

@ -12,6 +12,7 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import VCalendar from 'v-calendar'; import VCalendar from 'v-calendar';
import 'v-calendar/style.css'; import 'v-calendar/style.css';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createInertiaApp({ createInertiaApp({

View File

@ -5,7 +5,7 @@
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(Tests\TestCase::class, RefreshDatabase::class);
it('returns client cases when searching by contract reference', function () { it('returns client cases when searching by contract reference', function () {
// Arrange: create a user and authenticate // Arrange: create a user and authenticate