-
+
+
- {{ val?.title || key }}
+
+
+
+
+
+ Dodatni podatki
+ {{ Object.keys(c.meta).length }}
-
- {{
- formatDateShort(val.value) || val.value || "—"
- }}
- {{
- val.value ?? "—"
- }}
- {{ val?.value ?? val ?? "—" }}
-
-
-
-
+
+
+
+
+ {{ val?.title || key }}
+
+ {{
+ formatDateShort(val.value) || val.value || "—"
+ }}
+ {{
+ val.value ?? "—"
+ }}
+ {{ val?.value ?? val ?? "—" }}
+
+
+
+
+
+
diff --git a/resources/js/Pages/Phone/Index.vue b/resources/js/Pages/Phone/Index.vue
index ac74de5..05319e6 100644
--- a/resources/js/Pages/Phone/Index.vue
+++ b/resources/js/Pages/Phone/Index.vue
@@ -4,49 +4,36 @@ import { Badge } from "@/Components/ui/badge";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
} from "@/Components/ui/select";
import { Skeleton } from "@/Components/ui/skeleton";
-import {
- Tabs,
- TabsContent,
- TabsList,
- TabsTrigger,
-} from "@/Components/ui/tabs";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
import { router } from "@inertiajs/vue3";
-import {
- computed,
- defineComponent,
- h,
- onMounted,
- onUnmounted,
- ref,
- watch,
-} from "vue";
+import { computed, defineComponent, h, onMounted, onUnmounted, ref, watch } from "vue";
import { useDebounceFn } from "@vueuse/core";
import {
- CalendarDays,
- CheckCircle2,
- ChevronRight,
- ClipboardList,
- MapPin,
- Phone,
- SlidersHorizontal,
- Wallet,
+ CalendarDays,
+ CheckCircle2,
+ ChevronRight,
+ ClipboardList,
+ MapPin,
+ Phone,
+ SlidersHorizontal,
+ Wallet,
} from "lucide-vue-next";
import { fmtDateDMY } from "@/Utilities/functions";
const props = defineProps({
- pendingJobs: { type: Object, default: null },
- processedJobs: { type: Object, default: null },
- completedJobs: { type: Object, default: null },
- clients: { type: Array, default: () => [] },
- view_mode: { type: String, default: "assigned" },
- filters: { type: Object, default: () => ({ search: "", client: "" }) },
+ pendingJobs: { type: Object, default: null },
+ processedJobs: { type: Object, default: null },
+ completedJobs: { type: Object, default: null },
+ clients: { type: Array, default: () => [] },
+ view_mode: { type: String, default: "assigned" },
+ filters: { type: Object, default: () => ({ search: "", client: "" }) },
});
const isCompleted = computed(() => props.view_mode === "completed-today");
@@ -55,43 +42,45 @@ const isCompleted = computed(() => props.view_mode === "completed-today");
const search = ref(props.filters.search || "");
const clientFilter = ref(props.filters.client || "all");
const isFiltering = ref(false);
-const showFilters = ref(!!(props.filters.search || (props.filters.client && props.filters.client !== "all")));
+const showFilters = ref(
+ !!(props.filters.search || (props.filters.client && props.filters.client !== "all"))
+);
const debouncedFilter = useDebounceFn(() => performFilter(), 500);
watch(search, () => debouncedFilter());
watch(clientFilter, () => performFilter());
function performFilter() {
- isFiltering.value = true;
- const targetRoute = isCompleted.value ? "phone.completed" : "phone.index";
- const only = isCompleted.value
- ? ["completedJobs", "filters"]
- : ["pendingJobs", "processedJobs", "filters"];
+ isFiltering.value = true;
+ const targetRoute = isCompleted.value ? "phone.completed" : "phone.index";
+ const only = isCompleted.value
+ ? ["completedJobs", "filters"]
+ : ["pendingJobs", "processedJobs", "filters"];
- router.get(
- route(targetRoute),
- {
- search: search.value || undefined,
- client: clientFilter.value !== "all" ? clientFilter.value : undefined,
- },
- {
- preserveState: true,
- preserveScroll: false,
- only,
- onSuccess: () => {
- resetLists();
- isFiltering.value = false;
- },
- onError: () => {
- isFiltering.value = false;
- },
- },
- );
+ router.get(
+ route(targetRoute),
+ {
+ search: search.value || undefined,
+ client: clientFilter.value !== "all" ? clientFilter.value : undefined,
+ },
+ {
+ preserveState: true,
+ preserveScroll: false,
+ only,
+ onSuccess: () => {
+ resetLists();
+ isFiltering.value = false;
+ },
+ onError: () => {
+ isFiltering.value = false;
+ },
+ }
+ );
}
function clearFilters() {
- search.value = "";
- clientFilter.value = "all";
+ search.value = "";
+ clientFilter.value = "all";
}
// ── Infinite scroll lists ────────────────────────────────────────────────────
@@ -116,108 +105,128 @@ const processedSentinel = ref(null);
const completedSentinel = ref(null);
function resetLists() {
- pendingList.value = props.pendingJobs?.data ?? [];
- processedList.value = props.processedJobs?.data ?? [];
- completedList.value = props.completedJobs?.data ?? [];
- pendingPage.value = props.pendingJobs?.current_page ?? 1;
- processedPage.value = props.processedJobs?.current_page ?? 1;
- completedPage.value = props.completedJobs?.current_page ?? 1;
- pendingLastPage.value = props.pendingJobs?.last_page ?? 1;
- processedLastPage.value = props.processedJobs?.last_page ?? 1;
- completedLastPage.value = props.completedJobs?.last_page ?? 1;
- clientFilter.value = props.filters.client || "all";
+ pendingList.value = props.pendingJobs?.data ?? [];
+ processedList.value = props.processedJobs?.data ?? [];
+ completedList.value = props.completedJobs?.data ?? [];
+ pendingPage.value = props.pendingJobs?.current_page ?? 1;
+ processedPage.value = props.processedJobs?.current_page ?? 1;
+ completedPage.value = props.completedJobs?.current_page ?? 1;
+ pendingLastPage.value = props.pendingJobs?.last_page ?? 1;
+ processedLastPage.value = props.processedJobs?.last_page ?? 1;
+ completedLastPage.value = props.completedJobs?.last_page ?? 1;
+ clientFilter.value = props.filters.client || "all";
}
function appendUnique(list, newItems) {
- const ids = new Set(list.value.map((i) => i.id));
- list.value.push(...newItems.filter((i) => !ids.has(i.id)));
+ const ids = new Set(list.value.map((i) => i.id));
+ list.value.push(...newItems.filter((i) => !ids.has(i.id)));
}
function buildPageUrl(pageParam, pageNum) {
- const params = new URLSearchParams(window.location.search);
- params.set(pageParam, pageNum);
- return `${window.location.pathname}?${params.toString()}`;
+ const params = new URLSearchParams(window.location.search);
+ params.set(pageParam, pageNum);
+ return `${window.location.pathname}?${params.toString()}`;
}
function loadMore(listRef, pageRef, lastPageRef, loadingRef, propKey, pageParam) {
- if (loadingRef.value) return;
- if (pageRef.value >= lastPageRef.value) return;
+ if (loadingRef.value) return;
+ if (pageRef.value >= lastPageRef.value) return;
- const nextPage = pageRef.value + 1;
- loadingRef.value = true;
+ const nextPage = pageRef.value + 1;
+ loadingRef.value = true;
- router.get(
- buildPageUrl(pageParam, nextPage),
- {},
- {
- preserveState: true,
- preserveScroll: true,
- only: [propKey],
- onSuccess: () => {
- const newData = props[propKey]?.data ?? [];
- appendUnique(listRef, newData);
- pageRef.value = nextPage;
- lastPageRef.value =
- props[propKey]?.last_page ?? lastPageRef.value;
- loadingRef.value = false;
- },
- onError: () => {
- loadingRef.value = false;
- },
- },
- );
+ router.get(
+ buildPageUrl(pageParam, nextPage),
+ {},
+ {
+ preserveState: true,
+ preserveScroll: true,
+ only: [propKey],
+ onSuccess: () => {
+ const newData = props[propKey]?.data ?? [];
+ appendUnique(listRef, newData);
+ pageRef.value = nextPage;
+ lastPageRef.value = props[propKey]?.last_page ?? lastPageRef.value;
+ loadingRef.value = false;
+ },
+ onError: () => {
+ loadingRef.value = false;
+ },
+ }
+ );
}
function makeObserver(sentinelRef, loadFn) {
- const obs = new IntersectionObserver(
- (entries) => {
- if (entries[0].isIntersecting) loadFn();
- },
- { rootMargin: "200px" },
- );
- if (sentinelRef.value) obs.observe(sentinelRef.value);
- return obs;
+ const obs = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting) loadFn();
+ },
+ { rootMargin: "200px" }
+ );
+ if (sentinelRef.value) obs.observe(sentinelRef.value);
+ return obs;
}
let observers = [];
+// ── Scroll-hide title ────────────────────────────────────────────────────────
+const scrolled = ref(false);
+let stopNavigateListener = null;
+function onScroll() {
+ if (!scrolled.value && window.scrollY > 50) {
+ scrolled.value = true;
+ } else if (scrolled.value && window.scrollY < 10) {
+ scrolled.value = false;
+ }
+}
onMounted(() => {
- observers.push(
- makeObserver(pendingSentinel, () =>
- loadMore(
- pendingList,
- pendingPage,
- pendingLastPage,
- loadingPending,
- "pendingJobs",
- "pending",
- ),
- ),
- makeObserver(processedSentinel, () =>
- loadMore(
- processedList,
- processedPage,
- processedLastPage,
- loadingProcessed,
- "processedJobs",
- "processed",
- ),
- ),
- makeObserver(completedSentinel, () =>
- loadMore(
- completedList,
- completedPage,
- completedLastPage,
- loadingCompleted,
- "completedJobs",
- "completed",
- ),
- ),
- );
+ let trackedPath = window.location.pathname;
+ stopNavigateListener = router.on("navigate", (event) => {
+ const newPath = new URL(event.detail.page.url, window.location.origin).pathname;
+ if (newPath !== trackedPath) {
+ scrolled.value = false;
+ trackedPath = newPath;
+ }
+ });
+ window.addEventListener("scroll", onScroll, { passive: true });
+ observers.push(
+ makeObserver(pendingSentinel, () =>
+ loadMore(
+ pendingList,
+ pendingPage,
+ pendingLastPage,
+ loadingPending,
+ "pendingJobs",
+ "pending"
+ )
+ ),
+ makeObserver(processedSentinel, () =>
+ loadMore(
+ processedList,
+ processedPage,
+ processedLastPage,
+ loadingProcessed,
+ "processedJobs",
+ "processed"
+ )
+ ),
+ makeObserver(completedSentinel, () =>
+ loadMore(
+ completedList,
+ completedPage,
+ completedLastPage,
+ loadingCompleted,
+ "completedJobs",
+ "completed"
+ )
+ )
+ );
});
onUnmounted(() => {
- observers.forEach((o) => o.disconnect());
+ if (stopNavigateListener) stopNavigateListener();
+ observers.forEach((o) => o.disconnect());
+ window.removeEventListener("scroll", onScroll);
});
// ── Counts ───────────────────────────────────────────────────────────────────
@@ -226,440 +235,394 @@ const processedCount = computed(() => props.processedJobs?.total ?? 0);
// ── Helpers ──────────────────────────────────────────────────────────────────
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,
- });
+ 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,
+ });
}
function getCaseUuid(job) {
- return (
- job?.contract?.client_case?.uuid ||
- job?.client_case?.uuid ||
- job?.case_uuid ||
- null
- );
+ return (
+ job?.contract?.client_case?.uuid || job?.client_case?.uuid || job?.case_uuid || null
+ );
}
function jobHref(job) {
- const uuid = getCaseUuid(job);
- if (!uuid) return null;
- return route("phone.case", {
- client_case: uuid,
- completed: isCompleted.value ? 1 : undefined,
- });
+ const uuid = getCaseUuid(job);
+ if (!uuid) return null;
+ return route("phone.case", {
+ client_case: uuid,
+ completed: isCompleted.value ? 1 : undefined,
+ });
}
// ── JobCard component ────────────────────────────────────────────────────────
const JobCard = defineComponent({
- name: "JobCard",
- props: {
- job: { type: Object, required: true },
- href: { type: String, default: null },
- accentClass: { type: String, default: "border-l-blue-500" },
- showLastActivity: { type: Boolean, default: false },
- },
- setup(p) {
- return () => {
- const j = p.job;
- const person = j.contract?.client_case?.person;
- const clientName =
- j.contract?.client_case?.client?.person?.full_name;
- const address = person?.address?.address;
- const phone = person?.phones?.[0]?.nu;
- const balance = j.contract?.account?.balance_amount;
+ name: "JobCard",
+ props: {
+ job: { type: Object, required: true },
+ href: { type: String, default: null },
+ accentClass: { type: String, default: "border-l-blue-500" },
+ showLastActivity: { type: Boolean, default: false },
+ },
+ setup(p) {
+ return () => {
+ const j = p.job;
+ const person = j.contract?.client_case?.person;
+ const clientName = j.contract?.client_case?.client?.person?.full_name;
+ const address = person?.address?.address;
+ const phone = person?.phones?.[0]?.nu;
+ const balance = j.contract?.account?.balance_amount;
- const inner = h("div", { class: `border-l-4 ${p.accentClass}` }, [
- h(
- "div",
- {
- class: "px-4 pt-4 pb-2 flex items-start justify-between gap-3",
- },
- [
- h("div", { class: "flex-1 min-w-0" }, [
- h(
- "p",
- {
- class: "font-bold text-base text-gray-900 dark:text-gray-100 truncate leading-tight",
- },
- person?.full_name || "—",
- ),
- h(
- "p",
- {
- class: "text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate",
- },
- j.contract?.reference ||
- j.contract?.uuid ||
- "—",
- ),
- clientName
- ? h(
- "p",
- {
- class: "text-xs text-indigo-600 dark:text-indigo-400 mt-0.5 truncate",
- },
- clientName,
- )
- : null,
- ]),
- j.priority
- ? h(
- "span",
- {
- class: "shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400 animate-pulse",
- },
- "Prioriteta",
- )
- : null,
- ],
- ),
- address || phone
- ? h("div", { class: "px-4 pb-3 space-y-1.5" }, [
- address
- ? h(
- "div",
- {
- class: "flex items-center gap-2 text-gray-500 dark:text-gray-400",
- },
- [
- h(MapPin, {
- class: "w-3.5 h-3.5 shrink-0 text-gray-400",
- }),
- h(
- "span",
- { class: "text-xs truncate" },
- address,
- ),
- ],
- )
- : null,
- phone
- ? h(
- "div",
- {
- class: "flex items-center gap-2 text-gray-500 dark:text-gray-400",
- },
- [
- h(Phone, {
- class: "w-3.5 h-3.5 shrink-0 text-gray-400",
- }),
- h(
- "span",
- { class: "text-xs font-medium" },
- phone,
- ),
- ],
- )
- : null,
- ])
- : null,
- balance != null
- ? h(
- "div",
- {
- class: "mx-4 mb-3 px-3 py-2 bg-red-50 dark:bg-red-950/20 rounded-lg flex items-center gap-2 border border-red-100 dark:border-red-900",
- },
- [
- h(Wallet, {
- class: "w-4 h-4 text-red-500 shrink-0",
- }),
- h(
- "span",
- {
- class: "font-bold text-red-600 dark:text-red-400 text-sm",
- },
- `${formatAmount(balance)} €`,
- ),
- h(
- "span",
- { class: "text-xs text-red-400" },
- "odprto",
- ),
- ],
- )
- : null,
- h(
- "div",
- {
- class: "px-4 py-3 border-t bg-gray-50/60 dark:bg-gray-900/40 flex items-center justify-between",
- },
- [
- h(
- "div",
- {
- class: "flex items-center gap-1.5 text-xs text-gray-400",
- },
- [
- h(CalendarDays, { class: "w-3.5 h-3.5" }),
- h(
- "span",
- {},
- p.showLastActivity && j.last_activity
- ? fmtDateDMY(j.last_activity)
- : fmtDateDMY(j.assigned_at),
- ),
- ],
- ),
- p.href
- ? h(
- "div",
- {
- class: "flex items-center gap-0.5 text-primary font-semibold text-sm",
- },
- [
- "Odpri",
- h(ChevronRight, { class: "w-4 h-4" }),
- ],
- )
- : h(
- "span",
- { class: "text-xs text-gray-400 italic" },
- "Manjka primer",
- ),
- ],
- ),
- ]);
-
- return p.href
+ const inner = h("div", { class: `border-l-4 ${p.accentClass}` }, [
+ h(
+ "div",
+ {
+ class: "px-4 pt-4 pb-2 flex items-start justify-between gap-3",
+ },
+ [
+ h("div", { class: "flex-1 min-w-0" }, [
+ h(
+ "p",
+ {
+ class:
+ "font-bold text-base text-gray-900 dark:text-gray-100 truncate leading-tight",
+ },
+ person?.full_name || "—"
+ ),
+ h(
+ "p",
+ {
+ class: "text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate",
+ },
+ j.contract?.reference || j.contract?.uuid || "—"
+ ),
+ clientName
? h(
- "a",
+ "p",
{
- href: p.href,
- class: "block rounded-xl border shadow-sm overflow-hidden bg-white dark:bg-card active:scale-[0.99] transition-transform duration-100",
+ class:
+ "text-xs text-indigo-600 dark:text-indigo-400 mt-0.5 truncate",
},
- inner,
+ clientName
+ )
+ : null,
+ ]),
+ j.priority
+ ? h(
+ "span",
+ {
+ class:
+ "shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400 animate-pulse",
+ },
+ "Prioriteta"
)
- : h(
+ : null,
+ ]
+ ),
+ address || phone
+ ? h("div", { class: "px-4 pb-3 space-y-1.5" }, [
+ address
+ ? h(
"div",
{
- class: "rounded-xl border shadow-sm overflow-hidden bg-white dark:bg-card opacity-60",
+ class: "flex items-center gap-2 text-gray-500 dark:text-gray-400",
},
- inner,
- );
- };
- },
+ [
+ h(MapPin, {
+ class: "w-3.5 h-3.5 shrink-0 text-gray-400",
+ }),
+ h("span", { class: "text-xs truncate" }, address),
+ ]
+ )
+ : null,
+ phone
+ ? h(
+ "div",
+ {
+ class: "flex items-center gap-2 text-gray-500 dark:text-gray-400",
+ },
+ [
+ h(Phone, {
+ class: "w-3.5 h-3.5 shrink-0 text-gray-400",
+ }),
+ h("span", { class: "text-xs font-medium" }, phone),
+ ]
+ )
+ : null,
+ ])
+ : null,
+ balance != null
+ ? h(
+ "div",
+ {
+ class:
+ "mx-4 mb-3 px-3 py-2 bg-red-50 dark:bg-red-950/20 rounded-lg flex items-center gap-2 border border-red-100 dark:border-red-900",
+ },
+ [
+ h(Wallet, {
+ class: "w-4 h-4 text-red-500 shrink-0",
+ }),
+ h(
+ "span",
+ {
+ class: "font-bold text-red-600 dark:text-red-400 text-sm",
+ },
+ `${formatAmount(balance)} €`
+ ),
+ h("span", { class: "text-xs text-red-400" }, "odprto"),
+ ]
+ )
+ : null,
+ h(
+ "div",
+ {
+ class:
+ "px-4 py-3 border-t bg-gray-50/60 dark:bg-gray-900/40 flex items-center justify-between",
+ },
+ [
+ h(
+ "div",
+ {
+ class: "flex items-center gap-1.5 text-xs text-gray-400",
+ },
+ [
+ h(CalendarDays, { class: "w-3.5 h-3.5" }),
+ h(
+ "span",
+ {},
+ p.showLastActivity && j.last_activity
+ ? fmtDateDMY(j.last_activity)
+ : fmtDateDMY(j.assigned_at)
+ ),
+ ]
+ ),
+ p.href
+ ? h(
+ "div",
+ {
+ class: "flex items-center gap-0.5 text-primary font-semibold text-sm",
+ },
+ ["Odpri", h(ChevronRight, { class: "w-4 h-4" })]
+ )
+ : h("span", { class: "text-xs text-gray-400 italic" }, "Manjka primer"),
+ ]
+ ),
+ ]);
+
+ return p.href
+ ? h(
+ "a",
+ {
+ href: p.href,
+ class:
+ "block rounded-xl border shadow-sm overflow-hidden bg-white dark:bg-card active:scale-[0.99] transition-transform duration-100",
+ },
+ inner
+ )
+ : h(
+ "div",
+ {
+ class:
+ "rounded-xl border shadow-sm overflow-hidden bg-white dark:bg-card opacity-60",
+ },
+ inner
+ );
+ };
+ },
});
-
-
-
- {{ isCompleted ? "Zaključeno danes" : "Terenska opravila" }}
-
-
+
+
+
+ {{ isCompleted ? "Zaključeno danes" : "Terenska opravila" }}
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Novo
-
- {{ pendingCount }}
-
-
-
-
- Obdelano
-
- {{ processedCount }}
-
-
-
-
-
-
-
-
-
-
-
-
- {{
- search || clientFilter !== 'all'
- ? "Ni zadetkov"
- : "Ni novih opravil"
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{
- search || clientFilter !== 'all'
- ? "Ni zadetkov"
- : "Ni obdelanih opravil"
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{
- search || clientFilter !== 'all'
- ? "Ni zadetkov"
- : "Danes ni zaključenih opravil"
- }}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Novo
+
+ {{ pendingCount }}
+
+
+
+
+
+
+ Obdelano
+
+ {{ processedCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ search || clientFilter !== "all" ? "Ni zadetkov" : "Ni novih opravil"
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ search || clientFilter !== "all"
+ ? "Ni zadetkov"
+ : "Ni obdelanih opravil"
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ search || clientFilter !== "all"
+ ? "Ni zadetkov"
+ : "Danes ni zaključenih opravil"
+ }}
+
+
+
+
+
+
+
+
+
+
+