Updated mobile view for field jobs

This commit is contained in:
Simon Pocrnjič 2026-04-16 23:11:49 +02:00
parent 187cb4f127
commit 8f8c5c5a12
4 changed files with 896 additions and 614 deletions

View File

@ -10,42 +10,40 @@
class PhoneViewController extends Controller class PhoneViewController extends Controller
{ {
public function __construct(protected ReferenceDataCache $referenceCache) {} public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Request $request)
public function index(Request $request): \Inertia\Response
{ {
$userId = $request->user()->id; $userId = $request->user()->id;
$search = $request->input('search'); $search = $request->input('search');
$clientFilter = $request->input('client'); $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) ->where('assigned_user_id', $userId)
->whereNull('completed_at') ->whereNull('completed_at')
->whereNull('cancelled_at') ->whereNull('cancelled_at')
->with([ ->with($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',
]);
},
])
->orderByDesc('assigned_at');
// Apply client filter
if ($clientFilter) { if ($clientFilter) {
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) { $baseQuery->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
$q->where('uuid', $clientFilter); $q->where('uuid', $clientFilter);
}); });
} }
// Apply search filter
if ($search) { if ($search) {
$query->where(function ($q) use ($search) { $baseQuery->where(function ($q) use ($search) {
$q->whereHas('contract', function ($cq) use ($search) { $q->whereHas('contract', function ($cq) use ($search) {
$cq->where('reference', 'ilike', '%'.$search.'%') $cq->where('reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($pq) use ($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() $clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) { ->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
$q->where('assigned_user_id', $userId) $q->where('assigned_user_id', $userId)
@ -77,7 +80,8 @@ public function index(Request $request)
->values(); ->values();
return Inertia::render('Phone/Index', [ return Inertia::render('Phone/Index', [
'jobs' => $jobs, 'pendingJobs' => $pendingQuery->paginate(15, pageName: 'pending'),
'processedJobs' => $processedQuery->paginate(15, pageName: 'processed'),
'clients' => $clients, 'clients' => $clients,
'view_mode' => 'assigned', 'view_mode' => 'assigned',
'filters' => [ '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; $userId = $request->user()->id;
$search = $request->input('search'); $search = $request->input('search');
$clientFilter = $request->input('client'); $clientFilter = $request->input('client');
$perPage = $request->integer('per_page', 15);
$perPage = max(1, min(100, $perPage));
$start = now()->startOfDay(); $start = now()->startOfDay();
$end = now()->endOfDay(); $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() $clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) { ->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
$q->where('assigned_user_id', $userId) $q->where('assigned_user_id', $userId)
@ -157,7 +156,7 @@ public function completedToday(Request $request)
->values(); ->values();
return Inertia::render('Phone/Index', [ return Inertia::render('Phone/Index', [
'jobs' => $jobs, 'completedJobs' => $query->paginate(15, pageName: 'completed'),
'clients' => $clients, 'clients' => $clients,
'view_mode' => 'completed-today', 'view_mode' => 'completed-today',
'filters' => [ 'filters' => [

View File

@ -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,
};
}

View File

@ -17,6 +17,11 @@ import {
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
import { Separator } from "@/Components/ui/separator"; import { Separator } from "@/Components/ui/separator";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/Components/ui/collapsible";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -41,6 +46,7 @@ import { Checkbox } from "@/Components/ui/checkbox";
import { import {
ArrowLeft, ArrowLeft,
CheckCircle2, CheckCircle2,
ChevronDown,
FileText, FileText,
Calendar, Calendar,
Euro, Euro,
@ -370,64 +376,163 @@ const clientSummary = computed(() => {
<Card <Card
v-for="c in contracts" v-for="c in contracts"
:key="c.uuid || c.id" :key="c.uuid || c.id"
class="border-l-4 border-l-indigo-500" class="border-l-4 border-l-indigo-500 overflow-hidden"
> >
<CardHeader class="pb-3"> <!-- Contract header: reference + type badge -->
<div class="flex items-start justify-between gap-3"> <CardHeader class="pb-2">
<div class="min-w-0 flex-1"> <div class="flex items-center gap-2 flex-wrap">
<div class="flex items-center gap-2 flex-wrap"> <CardTitle class="text-base font-semibold">
<CardTitle class="text-sm"> {{ c.reference || c.uuid }}
{{ c.reference || c.uuid }} </CardTitle>
</CardTitle> <Badge v-if="c.type?.name" variant="secondary" class="text-[11px]">
<Badge v-if="c.type?.name" variant="secondary" class="text-[11px]"> {{ c.type.name }}
{{ c.type.name }} </Badge>
</Badge>
</div>
<div v-if="c.account" class="mt-3 flex items-center gap-2">
<Euro class="w-4 h-4 text-gray-400" />
<div class="flex items-baseline gap-2">
<span class="text-xs text-gray-500 uppercase">Odprto</span>
<span
class="text-lg font-semibold text-gray-900 dark:text-gray-100"
>
{{ formatAmount(c.account.balance_amount) }}
</span>
</div>
</div>
</div>
<div class="flex flex-col gap-2 shrink-0">
<Button size="sm" @click="openDrawerAddActivity(c)">
<Plus class="w-4 h-4 mr-1" />
Aktivnost
</Button>
<Button size="sm" variant="secondary" @click="openDocDialog(c)">
<Upload class="w-4 h-4 mr-1" />
Dokument
</Button>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent v-if="c.last_object" class="pt-0">
<Separator class="mb-3" /> <!-- Balance row -->
<div class="space-y-1"> <div
<p class="text-xs text-gray-500 uppercase">Zadnji predmet</p> v-if="c.account"
<div class="text-sm font-medium text-gray-800 dark:text-gray-200"> class="mx-4 mb-3 rounded-xl bg-red-50 dark:bg-red-950/20 border border-red-100 dark:border-red-900 px-4 py-3 flex items-center justify-between"
{{ c.last_object.name || c.last_object.reference }} >
<span <div class="flex items-center gap-2 text-red-500">
v-if="c.last_object.type" <Euro class="w-4 h-4 shrink-0" />
class="ml-2 text-xs font-normal text-gray-500" <span class="text-xs font-medium uppercase tracking-wide text-red-400"
> >Odprto</span
({{ c.last_object.type }})
</span>
</div>
<div
v-if="c.last_object.description"
class="text-sm text-gray-600 dark:text-gray-400"
> >
{{ c.last_object.description }}
</div>
</div> </div>
<span
class="text-2xl font-bold text-red-600 dark:text-red-400 tabular-nums"
>
{{ formatAmount(c.account.balance_amount) }}
</span>
</div>
<!-- Collapsibles: description, meta, last object -->
<CardContent
v-if="
c.description || c.last_object || (c.meta && Object.keys(c.meta).length)
"
class="pt-0 px-4 space-y-0"
>
<!-- Description -->
<template v-if="c.description">
<Separator class="mb-3" />
<Collapsible>
<CollapsibleTrigger
class="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 group w-full py-1"
>
<ChevronDown
class="w-3.5 h-3.5 transition-transform duration-200 group-data-[state=open]:rotate-180 shrink-0"
/>
<span class="uppercase tracking-wide font-medium">Opis</span>
</CollapsibleTrigger>
<CollapsibleContent>
<p
class="mt-1.5 mb-2 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line rounded-lg bg-gray-50 dark:bg-gray-800/50 px-3 py-2.5"
>
{{ c.description }}
</p>
</CollapsibleContent>
</Collapsible>
</template>
<!-- Meta -->
<template v-if="c.meta && Object.keys(c.meta).length">
<Separator class="mb-3" :class="c.description ? 'mt-2' : 'mt-0'" />
<Collapsible>
<CollapsibleTrigger
class="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 group w-full py-1"
>
<ChevronDown
class="w-3.5 h-3.5 transition-transform duration-200 group-data-[state=open]:rotate-180 shrink-0"
/>
<span class="uppercase tracking-wide font-medium"
>Dodatni podatki</span
>
<span class="ml-auto text-gray-400 font-normal">{{
Object.keys(c.meta).length
}}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div
class="mt-1.5 mb-2 divide-y divide-gray-100 dark:divide-gray-700 rounded-lg border border-gray-100 dark:border-gray-700 overflow-hidden"
>
<div
v-for="(val, key) in c.meta"
:key="key"
class="flex items-center justify-between gap-3 px-3 py-2 bg-white dark:bg-gray-900 even:bg-gray-50/60 dark:even:bg-gray-800/40"
>
<span
class="text-xs text-gray-500 dark:text-gray-400 shrink-0"
>{{ val?.title || key }}</span
>
<span
class="text-xs font-semibold text-gray-800 dark:text-gray-200 text-right"
>
<template v-if="val?.type === 'date'">{{
formatDateShort(val.value) || val.value || "—"
}}</template>
<template v-else-if="val?.type === 'number'">{{
val.value ?? "—"
}}</template>
<template v-else>{{ val?.value ?? val ?? "" }}</template>
</span>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</template>
<!-- Last object -->
<template v-if="c.last_object">
<Separator
class="mb-3"
:class="
c.description || (c.meta && Object.keys(c.meta).length)
? 'mt-2'
: 'mt-0'
"
/>
<div class="py-1 space-y-1">
<p class="text-xs text-gray-400 uppercase tracking-wide font-medium">
Zadnji predmet
</p>
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">
{{ c.last_object.name || c.last_object.reference }}
<span
v-if="c.last_object.type"
class="ml-1.5 text-xs font-normal text-gray-400"
>({{ c.last_object.type }})</span
>
</p>
<p
v-if="c.last_object.description"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ c.last_object.description }}
</p>
</div>
</template>
</CardContent> </CardContent>
<!-- Action buttons: full-width row at bottom -->
<div class="grid grid-cols-2 gap-0 border-t mt-1">
<button
class="flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary hover:bg-primary/5 active:bg-primary/10 transition-colors border-r"
@click="openDrawerAddActivity(c)"
>
<Plus class="w-4 h-4" />
Aktivnost
</button>
<button
class="flex items-center justify-center gap-2 py-3 text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 active:bg-gray-100 transition-colors"
@click="openDocDialog(c)"
>
<Upload class="w-4 h-4" />
Dokument
</button>
</div>
</Card> </Card>
<p <p
v-if="!contracts?.length" v-if="!contracts?.length"

File diff suppressed because it is too large Load Diff