Updated mobile view for field jobs
This commit is contained in:
parent
187cb4f127
commit
8f8c5c5a12
|
|
@ -10,19 +10,14 @@
|
||||||
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 = [
|
||||||
->where('assigned_user_id', $userId)
|
|
||||||
->whereNull('completed_at')
|
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->with([
|
|
||||||
'contract' => function ($q) {
|
'contract' => function ($q) {
|
||||||
$q->with([
|
$q->with([
|
||||||
'type:id,name',
|
'type:id,name',
|
||||||
|
|
@ -33,19 +28,22 @@ public function index(Request $request)
|
||||||
'clientCase.client.person:id,full_name',
|
'clientCase.client.person:id,full_name',
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
])
|
];
|
||||||
->orderByDesc('assigned_at');
|
|
||||||
|
$baseQuery = FieldJob::query()
|
||||||
|
->where('assigned_user_id', $userId)
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->with($eagerLoad);
|
||||||
|
|
||||||
// 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' => [
|
||||||
|
|
|
||||||
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 { 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-sm">
|
<CardTitle class="text-base font-semibold">
|
||||||
{{ 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>
|
||||||
<div v-if="c.account" class="mt-3 flex items-center gap-2">
|
</CardHeader>
|
||||||
<Euro class="w-4 h-4 text-gray-400" />
|
|
||||||
<div class="flex items-baseline gap-2">
|
<!-- Balance row -->
|
||||||
<span class="text-xs text-gray-500 uppercase">Odprto</span>
|
<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
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
class="text-lg font-semibold text-gray-900 dark:text-gray-100"
|
class="text-2xl font-bold text-red-600 dark:text-red-400 tabular-nums"
|
||||||
>
|
>
|
||||||
{{ formatAmount(c.account.balance_amount) }} €
|
{{ formatAmount(c.account.balance_amount) }} €
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<!-- Collapsibles: description, meta, last object -->
|
||||||
<div class="flex flex-col gap-2 shrink-0">
|
<CardContent
|
||||||
<Button size="sm" @click="openDrawerAddActivity(c)">
|
v-if="
|
||||||
<Plus class="w-4 h-4 mr-1" />
|
c.description || c.last_object || (c.meta && Object.keys(c.meta).length)
|
||||||
Aktivnost
|
"
|
||||||
</Button>
|
class="pt-0 px-4 space-y-0"
|
||||||
<Button size="sm" variant="secondary" @click="openDocDialog(c)">
|
>
|
||||||
<Upload class="w-4 h-4 mr-1" />
|
<!-- Description -->
|
||||||
Dokument
|
<template v-if="c.description">
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent v-if="c.last_object" class="pt-0">
|
|
||||||
<Separator class="mb-3" />
|
<Separator class="mb-3" />
|
||||||
<div class="space-y-1">
|
<Collapsible>
|
||||||
<p class="text-xs text-gray-500 uppercase">Zadnji predmet</p>
|
<CollapsibleTrigger
|
||||||
<div class="text-sm font-medium text-gray-800 dark:text-gray-200">
|
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 }}
|
{{ c.last_object.name || c.last_object.reference }}
|
||||||
<span
|
<span
|
||||||
v-if="c.last_object.type"
|
v-if="c.last_object.type"
|
||||||
class="ml-2 text-xs font-normal text-gray-500"
|
class="ml-1.5 text-xs font-normal text-gray-400"
|
||||||
|
>({{ c.last_object.type }})</span
|
||||||
>
|
>
|
||||||
({{ c.last_object.type }})
|
</p>
|
||||||
</span>
|
<p
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="c.last_object.description"
|
v-if="c.last_object.description"
|
||||||
class="text-sm text-gray-600 dark:text-gray-400"
|
class="text-xs text-gray-500 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
{{ c.last_object.description }}
|
{{ c.last_object.description }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
Loading…
Reference in New Issue
Block a user