From 8f8c5c5a126565827ddffe09d1deee0be6b74ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Thu, 16 Apr 2026 23:11:49 +0200 Subject: [PATCH] Updated mobile view for field jobs --- app/Http/Controllers/PhoneViewController.php | 61 +- resources/js/Composables/useInfiniteList.js | 97 ++ resources/js/Pages/Phone/Case/Index.vue | 209 +++- resources/js/Pages/Phone/Index.vue | 1143 ++++++++++-------- 4 files changed, 896 insertions(+), 614 deletions(-) create mode 100644 resources/js/Composables/useInfiniteList.js diff --git a/app/Http/Controllers/PhoneViewController.php b/app/Http/Controllers/PhoneViewController.php index 25df4d4..49ca8bb 100644 --- a/app/Http/Controllers/PhoneViewController.php +++ b/app/Http/Controllers/PhoneViewController.php @@ -10,42 +10,40 @@ class PhoneViewController extends Controller { public function __construct(protected ReferenceDataCache $referenceCache) {} - public function index(Request $request) + + public function index(Request $request): \Inertia\Response { $userId = $request->user()->id; $search = $request->input('search'); $clientFilter = $request->input('client'); - $perPage = $request->integer('per_page', 15); - $perPage = max(1, min(100, $perPage)); - $query = FieldJob::query() + $eagerLoad = [ + 'contract' => function ($q) { + $q->with([ + 'type:id,name', + 'account', + 'clientCase.person.address.type', + 'clientCase.person.phones', + 'clientCase.client:id,uuid,person_id', + 'clientCase.client.person:id,full_name', + ]); + }, + ]; + + $baseQuery = FieldJob::query() ->where('assigned_user_id', $userId) ->whereNull('completed_at') ->whereNull('cancelled_at') - ->with([ - 'contract' => function ($q) { - $q->with([ - 'type:id,name', - 'account', - 'clientCase.person.address.type', - 'clientCase.person.phones', - 'clientCase.client:id,uuid,person_id', - 'clientCase.client.person:id,full_name', - ]); - }, - ]) - ->orderByDesc('assigned_at'); + ->with($eagerLoad); - // Apply client filter if ($clientFilter) { - $query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) { + $baseQuery->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) { $q->where('uuid', $clientFilter); }); } - // Apply search filter if ($search) { - $query->where(function ($q) use ($search) { + $baseQuery->where(function ($q) use ($search) { $q->whereHas('contract', function ($cq) use ($search) { $cq->where('reference', 'ilike', '%'.$search.'%') ->orWhereHas('clientCase.person', function ($pq) use ($search) { @@ -58,9 +56,14 @@ public function index(Request $request) }); } - $jobs = $query->paginate($perPage)->withQueryString(); + $pendingQuery = (clone $baseQuery) + ->where(fn ($q) => $q->where('added_activity', false)->orWhereNull('added_activity')) + ->orderByDesc('assigned_at'); + + $processedQuery = (clone $baseQuery) + ->where('added_activity', true) + ->orderByDesc('assigned_at'); - // Get unique clients for filter dropdown $clients = \App\Models\Client::query() ->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) { $q->where('assigned_user_id', $userId) @@ -77,7 +80,8 @@ public function index(Request $request) ->values(); return Inertia::render('Phone/Index', [ - 'jobs' => $jobs, + 'pendingJobs' => $pendingQuery->paginate(15, pageName: 'pending'), + 'processedJobs' => $processedQuery->paginate(15, pageName: 'processed'), 'clients' => $clients, 'view_mode' => 'assigned', 'filters' => [ @@ -87,13 +91,11 @@ public function index(Request $request) ]); } - public function completedToday(Request $request) + public function completedToday(Request $request): \Inertia\Response { $userId = $request->user()->id; $search = $request->input('search'); $clientFilter = $request->input('client'); - $perPage = $request->integer('per_page', 15); - $perPage = max(1, min(100, $perPage)); $start = now()->startOfDay(); $end = now()->endOfDay(); @@ -138,9 +140,6 @@ public function completedToday(Request $request) }); } - $jobs = $query->paginate($perPage)->withQueryString(); - - // Get unique clients for filter dropdown $clients = \App\Models\Client::query() ->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) { $q->where('assigned_user_id', $userId) @@ -157,7 +156,7 @@ public function completedToday(Request $request) ->values(); return Inertia::render('Phone/Index', [ - 'jobs' => $jobs, + 'completedJobs' => $query->paginate(15, pageName: 'completed'), 'clients' => $clients, 'view_mode' => 'completed-today', 'filters' => [ diff --git a/resources/js/Composables/useInfiniteList.js b/resources/js/Composables/useInfiniteList.js new file mode 100644 index 0000000..3872dd3 --- /dev/null +++ b/resources/js/Composables/useInfiniteList.js @@ -0,0 +1,97 @@ +import { ref, onMounted, onUnmounted } from "vue"; +import { router } from "@inertiajs/vue3"; + +/** + * Composable for infinite scroll with Inertia v2. + * + * @param {Function} getProp - () => the current paginator object from Inertia props + * @param {string} propName - the prop key name to reload + * @param {string} pageParam - query string parameter name for page number + * @param {Function} getRouteUrl - () => current URL to reload + */ +export function useInfiniteList(getProp, propName, pageParam, getRouteUrl) { + const items = ref([]); + const currentPage = ref(1); + const lastPage = ref(1); + const isLoadingMore = ref(false); + const sentinelRef = ref(null); + let observer = null; + + function syncFromProp() { + const prop = getProp(); + if (!prop) return; + lastPage.value = prop.last_page ?? 1; + } + + function appendFromProp() { + const prop = getProp(); + if (!prop?.data) return; + // append only new items (avoid duplicates by id) + const existingIds = new Set(items.value.map((i) => i.id)); + const newItems = prop.data.filter((i) => !existingIds.has(i.id)); + items.value.push(...newItems); + } + + function reset(initialProp) { + items.value = initialProp?.data ?? []; + currentPage.value = initialProp?.current_page ?? 1; + lastPage.value = initialProp?.last_page ?? 1; + } + + function loadMore() { + if (isLoadingMore.value) return; + if (currentPage.value >= lastPage.value) return; + + const nextPage = currentPage.value + 1; + isLoadingMore.value = true; + + const params = new URLSearchParams(window.location.search); + params.set(pageParam, nextPage); + + router.reload({ + url: `${window.location.pathname}?${params.toString()}`, + only: [propName], + preserveScroll: true, + preserveState: true, + onSuccess: () => { + appendFromProp(); + currentPage.value = nextPage; + isLoadingMore.value = false; + }, + onError: () => { + isLoadingMore.value = false; + }, + }); + } + + onMounted(() => { + observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + loadMore(); + } + }, + { rootMargin: "200px" } + ); + + if (sentinelRef.value) { + observer.observe(sentinelRef.value); + } + }); + + onUnmounted(() => { + observer?.disconnect(); + }); + + return { + items, + currentPage, + lastPage, + isLoadingMore, + sentinelRef, + reset, + syncFromProp, + appendFromProp, + loadMore, + }; +} diff --git a/resources/js/Pages/Phone/Case/Index.vue b/resources/js/Pages/Phone/Case/Index.vue index fad830c..ef05d98 100644 --- a/resources/js/Pages/Phone/Case/Index.vue +++ b/resources/js/Pages/Phone/Case/Index.vue @@ -17,6 +17,11 @@ import { import { Button } from "@/Components/ui/button"; import { Badge } from "@/Components/ui/badge"; import { Separator } from "@/Components/ui/separator"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/Components/ui/collapsible"; import { Dialog, DialogContent, @@ -41,6 +46,7 @@ import { Checkbox } from "@/Components/ui/checkbox"; import { ArrowLeft, CheckCircle2, + ChevronDown, FileText, Calendar, Euro, @@ -370,64 +376,163 @@ const clientSummary = computed(() => { - -
-
-
- - {{ c.reference || c.uuid }} - - - {{ c.type.name }} - -
-
- -
- Odprto - - {{ formatAmount(c.account.balance_amount) }} € - -
-
-
-
- - -
+ + +
+ + {{ c.reference || c.uuid }} + + + {{ c.type.name }} +
- - -
-

Zadnji predmet

-
- {{ c.last_object.name || c.last_object.reference }} - - ({{ c.last_object.type }}) - -
-
+
+
+ + Odprto - {{ c.last_object.description }} -
+ + {{ formatAmount(c.account.balance_amount) }} € + +
+ + + + + + + + + + + + + +
+ + +

[] }, - 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"); + +// ── Filters ────────────────────────────────────────────────────────────────── const search = ref(props.filters.search || ""); -const clientFilter = ref(props.filters.client || ""); -const isLoading = ref(false); +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 listNonActivity = computed(() => - (props.jobs.data || []).filter((item) => !item.added_activity) -); +const debouncedFilter = useDebounceFn(() => performFilter(), 500); +watch(search, () => debouncedFilter()); +watch(clientFilter, () => performFilter()); -const listActivity = computed(() => - (props.jobs.data || []).filter((item) => !!item.added_activity) -); +function performFilter() { + isFiltering.value = true; + const targetRoute = isCompleted.value ? "phone.completed" : "phone.index"; + const only = isCompleted.value + ? ["completedJobs", "filters"] + : ["pendingJobs", "processedJobs", "filters"]; -const debouncedSearch = useDebounceFn((value) => { - performSearch(); -}, 500); + 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; + }, + }, + ); +} -watch(search, (newValue) => { - debouncedSearch(newValue); +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", + ), + ), + ); }); -watch(clientFilter, () => { - performSearch(); +onUnmounted(() => { + observers.forEach((o) => o.disconnect()); }); -function performSearch() { - isLoading.value = true; - - router.get( - route(props.view_mode === "completed-today" ? "phone.completed" : "phone.index"), - { - search: search.value || undefined, - client: clientFilter.value || undefined, - }, - { - preserveState: true, - preserveScroll: true, - only: ["jobs", "filters"], - onFinish: () => { - isLoading.value = false; - }, - } - ); -} - -function clearSearch() { - search.value = ""; - clientFilter.value = ""; -} - -function formatDateDMY(d) { - if (!d) return "-"; - const dt = new Date(d); - if (Number.isNaN(dt.getTime())) return String(d); - const parts = new Intl.DateTimeFormat("en-GB", { - timeZone: "Europe/Ljubljana", - day: "2-digit", - month: "2-digit", - year: "numeric", - }).formatToParts(dt); - const map = Object.fromEntries(parts.map((p) => [p.type, p.value])); - return `${map.day}.${map.month}.${map.year}`; -} +// ── 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, - }); + 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 changePage(url) { - if (!url) return; - isLoading.value = true; - router.get( - url, - {}, - { - preserveState: true, - preserveScroll: false, - only: ["jobs"], - onFinish: () => { - isLoading.value = false; - }, - } - ); +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, + ); + }; + }, +}); +