Updated mobile view for field jobs
This commit is contained in:
parent
187cb4f127
commit
8f8c5c5a12
|
|
@ -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' => [
|
||||
|
|
|
|||
97
resources/js/Composables/useInfiniteList.js
Normal file
97
resources/js/Composables/useInfiniteList.js
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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(() => {
|
|||
<Card
|
||||
v-for="c in contracts"
|
||||
: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">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<CardTitle class="text-sm">
|
||||
{{ c.reference || c.uuid }}
|
||||
</CardTitle>
|
||||
<Badge v-if="c.type?.name" variant="secondary" class="text-[11px]">
|
||||
{{ c.type.name }}
|
||||
</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>
|
||||
<!-- Contract header: reference + type badge -->
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<CardTitle class="text-base font-semibold">
|
||||
{{ c.reference || c.uuid }}
|
||||
</CardTitle>
|
||||
<Badge v-if="c.type?.name" variant="secondary" class="text-[11px]">
|
||||
{{ c.type.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent v-if="c.last_object" class="pt-0">
|
||||
<Separator class="mb-3" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs text-gray-500 uppercase">Zadnji predmet</p>
|
||||
<div 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-2 text-xs font-normal text-gray-500"
|
||||
>
|
||||
({{ c.last_object.type }})
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="c.last_object.description"
|
||||
class="text-sm text-gray-600 dark:text-gray-400"
|
||||
|
||||
<!-- Balance row -->
|
||||
<div
|
||||
v-if="c.account"
|
||||
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"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-red-500">
|
||||
<Euro class="w-4 h-4 shrink-0" />
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-red-400"
|
||||
>Odprto</span
|
||||
>
|
||||
{{ c.last_object.description }}
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
<p
|
||||
v-if="!contracts?.length"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user