Teren-app/resources/js/Pages/Phone/Index.vue

629 lines
21 KiB
Vue

<script setup>
import AppPhoneLayout from "@/Layouts/AppPhoneLayout.vue";
import { Badge } from "@/Components/ui/badge";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import {
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 { router } from "@inertiajs/vue3";
import { computed, defineComponent, h, onMounted, onUnmounted, ref, watch } from "vue";
import { useDebounceFn } from "@vueuse/core";
import {
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: "" }) },
});
const isCompleted = computed(() => props.view_mode === "completed-today");
// ── Filters ──────────────────────────────────────────────────────────────────
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 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"];
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";
}
// ── Infinite scroll lists ────────────────────────────────────────────────────
const pendingList = ref(props.pendingJobs?.data ?? []);
const processedList = ref(props.processedJobs?.data ?? []);
const completedList = ref(props.completedJobs?.data ?? []);
const pendingPage = ref(props.pendingJobs?.current_page ?? 1);
const processedPage = ref(props.processedJobs?.current_page ?? 1);
const completedPage = ref(props.completedJobs?.current_page ?? 1);
const pendingLastPage = ref(props.pendingJobs?.last_page ?? 1);
const processedLastPage = ref(props.processedJobs?.last_page ?? 1);
const completedLastPage = ref(props.completedJobs?.last_page ?? 1);
const loadingPending = ref(false);
const loadingProcessed = ref(false);
const loadingCompleted = ref(false);
const pendingSentinel = ref(null);
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";
}
function appendUnique(list, newItems) {
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()}`;
}
function loadMore(listRef, pageRef, lastPageRef, loadingRef, propKey, pageParam) {
if (loadingRef.value) return;
if (pageRef.value >= lastPageRef.value) return;
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;
},
}
);
}
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;
}
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(() => {
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(() => {
if (stopNavigateListener) stopNavigateListener();
observers.forEach((o) => o.disconnect());
window.removeEventListener("scroll", onScroll);
});
// ── Counts ───────────────────────────────────────────────────────────────────
const pendingCount = computed(() => props.pendingJobs?.total ?? 0);
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,
});
}
function getCaseUuid(job) {
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,
});
}
// ── 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;
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
? 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
);
};
},
});
</script>
<template>
<AppPhoneLayout title="Phone">
<template #header>
<h2
class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight overflow-hidden transition-all duration-200 ease-in-out"
:class="scrolled ? 'max-h-0 opacity-0 mb-0' : 'max-h-12 opacity-100 mb-0'"
>
{{ isCompleted ? "Zaključeno danes" : "Terenska opravila" }}
</h2>
<!-- Filter bar -->
<div class="pt-2 space-y-2">
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Input
v-model="search"
type="text"
placeholder="Išči po referenci ali imenu..."
class="pr-10"
/>
<span
v-if="isFiltering"
class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin"
/>
</div>
<Button
variant="outline"
size="icon"
class="shrink-0"
:class="
showFilters || clientFilter !== 'all' ? 'bg-primary/10 border-primary' : ''
"
title="Filter po naročniku"
@click="showFilters = !showFilters"
>
<SlidersHorizontal class="w-4 h-4" />
</Button>
<Button
v-if="search || clientFilter !== 'all'"
variant="ghost"
size="sm"
class="shrink-0 text-muted-foreground"
@click="clearFilters"
>
Počisti
</Button>
</div>
<div v-if="showFilters || clientFilter !== 'all'">
<Select v-model="clientFilter">
<SelectTrigger class="w-full">
<SelectValue placeholder="Vsi naročniki" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Vsi naročniki</SelectItem>
<SelectItem
v-for="client in props.clients"
:key="client.uuid"
:value="client.uuid"
>
{{ client.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</template>
<div class="pb-8">
<!-- Assigned mode: two tabs (Novo / Obdelano) -->
<div v-if="!isCompleted" class="px-4 pt-4">
<Tabs default-value="pending" class="w-full">
<TabsList class="w-full grid grid-cols-2 mb-4">
<TabsTrigger value="pending">
<span class="inline-flex flex-row items-center gap-1">
<ClipboardList class="w-3.5 h-3.5 shrink-0" />
Novo
<Badge v-if="pendingCount" variant="secondary" class="h-4 px-1 text-xs">
{{ pendingCount }}
</Badge>
</span>
</TabsTrigger>
<TabsTrigger value="processed">
<span class="inline-flex flex-row items-center gap-1">
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
Obdelano
<Badge v-if="processedCount" variant="secondary" class="h-4 px-1 text-xs">
{{ processedCount }}
</Badge>
</span>
</TabsTrigger>
</TabsList>
<!-- Pending tab -->
<TabsContent value="pending" class="space-y-3">
<template v-if="pendingList.length">
<JobCard
v-for="job in pendingList"
:key="job.id"
:job="job"
:href="jobHref(job)"
accent-class="border-l-blue-500"
/>
</template>
<div
v-else-if="!loadingPending"
class="py-16 text-center text-gray-500 dark:text-gray-400 space-y-2"
>
<ClipboardList class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto" />
<p class="text-sm">
{{
search || clientFilter !== "all" ? "Ni zadetkov" : "Ni novih opravil"
}}
</p>
</div>
<!-- Sentinel for infinite scroll -->
<div ref="pendingSentinel" class="h-px" />
<div v-if="loadingPending" class="space-y-3">
<Skeleton v-for="i in 3" :key="i" class="h-36 rounded-xl" />
</div>
</TabsContent>
<!-- Processed tab -->
<TabsContent value="processed" class="space-y-3">
<template v-if="processedList.length">
<JobCard
v-for="job in processedList"
:key="job.id"
:job="job"
:href="jobHref(job)"
accent-class="border-l-green-500"
:show-last-activity="true"
/>
</template>
<div
v-else-if="!loadingProcessed"
class="py-16 text-center text-gray-500 dark:text-gray-400 space-y-2"
>
<CheckCircle2 class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto" />
<p class="text-sm">
{{
search || clientFilter !== "all"
? "Ni zadetkov"
: "Ni obdelanih opravil"
}}
</p>
</div>
<!-- Sentinel for infinite scroll -->
<div ref="processedSentinel" class="h-px" />
<div v-if="loadingProcessed" class="space-y-3">
<Skeleton v-for="i in 3" :key="i" class="h-36 rounded-xl" />
</div>
</TabsContent>
</Tabs>
</div>
<!-- Completed-today mode: single scroll list -->
<div v-else class="px-4 pt-4 space-y-3">
<template v-if="completedList.length">
<JobCard
v-for="job in completedList"
:key="job.id"
:job="job"
:href="jobHref(job)"
accent-class="border-l-purple-500"
:show-last-activity="true"
/>
</template>
<div
v-else-if="!loadingCompleted"
class="py-16 text-center text-gray-500 dark:text-gray-400 space-y-2"
>
<CheckCircle2 class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto" />
<p class="text-sm">
{{
search || clientFilter !== "all"
? "Ni zadetkov"
: "Danes ni zaključenih opravil"
}}
</p>
</div>
<!-- Sentinel for infinite scroll -->
<div ref="completedSentinel" class="h-px" />
<div v-if="loadingCompleted" class="space-y-3">
<Skeleton v-for="i in 3" :key="i" class="h-36 rounded-xl" />
</div>
</div>
</div>
</AppPhoneLayout>
</template>