666 lines
26 KiB
Vue
666 lines
26 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 = [];
|
|
|
|
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",
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
observers.forEach((o) => o.disconnect());
|
|
});
|
|
|
|
// ── 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"
|
|
>
|
|
{{ isCompleted ? "Zaključeno danes" : "Terenska opravila" }}
|
|
</h2>
|
|
</template>
|
|
|
|
<div class="pb-8">
|
|
<!-- Filter bar -->
|
|
<div
|
|
class="sticky top-16 z-20 bg-white/95 dark:bg-gray-950/95 backdrop-blur border-b px-4 py-3 space-y-3"
|
|
>
|
|
<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'" class="pb-1">
|
|
<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>
|
|
|
|
<!-- 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" class="gap-1.5">
|
|
<ClipboardList class="w-4 h-4" />
|
|
Novo
|
|
<Badge
|
|
v-if="pendingCount"
|
|
variant="secondary"
|
|
class="ml-1 h-5 px-1.5 text-xs"
|
|
>
|
|
{{ pendingCount }}
|
|
</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="processed" class="gap-1.5">
|
|
<CheckCircle2 class="w-4 h-4" />
|
|
Obdelano
|
|
<Badge
|
|
v-if="processedCount"
|
|
variant="secondary"
|
|
class="ml-1 h-5 px-1.5 text-xs"
|
|
>
|
|
{{ processedCount }}
|
|
</Badge>
|
|
</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>
|
|
|