Major change update laravel, inertia v2 -> v3, other changes

This commit is contained in:
Simon Pocrnjič 2026-04-19 13:47:30 +02:00
parent 92f54f7103
commit 054202dc32
15 changed files with 1280 additions and 1167 deletions

View File

@ -71,10 +71,8 @@ public function index(ClientCase $clientCase, Request $request)
$que->whereDate('client_cases.created_at', '<=', $to);
})
->groupBy('client_cases.id')
->addSelect([
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
->with(['person.client', 'client.person'])
->orderByDesc('client_cases.created_at');
@ -915,8 +913,8 @@ public function show(ClientCase $clientCase)
->get(),
'sms_senders' => \App\Models\SmsSender::query()
->select(['id', 'profile_id'])
->addSelect(\DB::raw('sname as name'))
->addSelect(\DB::raw('phone_number as phone'))
->selectRaw('sname as name')
->selectRaw('phone_number as phone')
->orderBy('sname')
->get(),
'sms_templates' => \App\Models\SmsTemplate::query()

View File

@ -40,12 +40,8 @@ public function index(Client $client, Request $request)
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('clients.id')
->addSelect([
// Number of client cases for this client that have at least one active contract
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
// Sum of account balances for active contracts
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count')
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
->with('person')
->orderByDesc('clients.created_at');
@ -89,10 +85,8 @@ public function show(Client $client, Request $request)
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('client_cases.id')
->addSelect([
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->selectRaw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count')
->selectRaw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum')
->with(['person', 'client.person'])
->where('client_cases.active', 1)
->orderByDesc('client_cases.created_at')

View File

@ -100,13 +100,13 @@ public function __invoke(SmsService $sms): Response
// Field jobs assigned today - cached
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
return FieldJob::query()
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
->whereRaw('DATE(COALESCE(assigned_at, created_at)) = ?', [$today->toDateString()])
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
->with(['contract' => function ($q) {
$q->select('id', 'uuid', 'reference', 'client_case_id')
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
}])
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
->orderByRaw('COALESCE(assigned_at, created_at) DESC')
->limit(15)
->get()
->map(function ($fj) {

View File

@ -64,6 +64,7 @@ public function index(Request $request)
'current_page' => $paginator->currentPage(),
'from' => $paginator->firstItem(),
'last_page' => $paginator->lastPage(),
'links' => $paginator->linkCollection()->toArray(),
'path' => $paginator->path(),
'per_page' => $paginator->perPage(),
'to' => $paginator->lastItem(),

View File

@ -21,6 +21,8 @@ public function update(Person $person, Request $request)
'tax_number' => 'nullable|integer',
'social_security_number' => 'nullable|integer',
'description' => 'nullable|string|max:500',
'employer' => 'nullable|string|max:255',
'birthday' => 'nullable|date',
]);
$person->update($attributes);

View File

@ -17,11 +17,6 @@ public function index(Request $request): \Inertia\Response
$search = $request->input('search');
$clientFilter = $request->input('client');
// On full page loads, always start from page 1
if (! $request->header('X-Inertia-Partial-Data')) {
$request->merge(['pending' => 1, 'processed' => 1]);
}
$eagerLoad = [
'contract' => function ($q) {
$q->with([
@ -85,8 +80,8 @@ public function index(Request $request): \Inertia\Response
->values();
return Inertia::render('Phone/Index', [
'pendingJobs' => $pendingQuery->paginate(15, pageName: 'pending'),
'processedJobs' => $processedQuery->paginate(15, pageName: 'processed'),
'pendingJobs' => Inertia::scroll(fn () => $pendingQuery->paginate(15, pageName: 'pending')),
'processedJobs' => Inertia::scroll(fn () => $processedQuery->paginate(15, pageName: 'processed')),
'clients' => $clients,
'view_mode' => 'assigned',
'filters' => [
@ -102,11 +97,6 @@ public function completedToday(Request $request): \Inertia\Response
$search = $request->input('search');
$clientFilter = $request->input('client');
// On full page loads, always start from page 1
if (! $request->header('X-Inertia-Partial-Data')) {
$request->merge(['completed' => 1]);
}
$start = now()->startOfDay();
$end = now()->endOfDay();
@ -166,7 +156,7 @@ public function completedToday(Request $request): \Inertia\Response
->values();
return Inertia::render('Phone/Index', [
'completedJobs' => $query->paginate(15, pageName: 'completed'),
'completedJobs' => Inertia::scroll(fn () => $query->paginate(15, pageName: 'completed')),
'clients' => $clients,
'view_mode' => 'completed-today',
'filters' => [

View File

@ -10,21 +10,21 @@
"barryvdh/laravel-dompdf": "^3.1",
"diglactic/laravel-breadcrumbs": "^10.0",
"http-interop/http-factory-guzzle": "^1.2",
"inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "12.0",
"inertiajs/inertia-laravel": "^3.0",
"laravel/framework": "^12.0",
"laravel/jetstream": "^5.2",
"laravel/sanctum": "^4.0",
"laravel/scout": "^10.11",
"laravel/tinker": "^2.9",
"maatwebsite/excel": "^3.1",
"meilisearch/meilisearch-php": "^1.11",
"robertboes/inertia-breadcrumbs": "dev-laravel-12",
"robertboes/inertia-breadcrumbs": "^1.0",
"tightenco/ziggy": "^2.0",
"tijsverkoyen/css-to-inline-styles": "^2.2"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/boost": "^1.1",
"laravel/boost": "^2.2",
"laravel/pint": "^1.13",
"laravel/sail": "^1.26",
"mockery/mockery": "^1.6",

1777
composer.lock generated

File diff suppressed because it is too large Load Diff

162
package-lock.json generated
View File

@ -46,7 +46,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@inertiajs/vue3": "2.0",
"@inertiajs/vue3": "^3.0",
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.18",
@ -952,26 +952,35 @@
}
},
"node_modules/@inertiajs/core": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.0.17.tgz",
"integrity": "sha512-tvYoqiouQSJrP7i7zVq61yyuEjlL96UU4nkkOWtOajXZlubGN4XrgRpnygpDk1KBO8V2yBab3oUZm+aZImwTHg==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.0.3.tgz",
"integrity": "sha512-/4sW/cfNpvujjVOZlB5UNypLGNySs7X7V8IMLNSK8+3j1KsUYGS5wpLd9EqAu8wy8RiW7PPra2rPwB6Lx/ACow==",
"dev": true,
"license": "MIT",
"dependencies": {
"axios": "^1.8.2",
"es-toolkit": "^1.34.1",
"qs": "^6.9.0"
"@jridgewell/trace-mapping": "^0.3.31",
"es-toolkit": "^1.33.0",
"laravel-precognition": "^2.0.0"
},
"peerDependencies": {
"axios": "^1.13.2"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"node_modules/@inertiajs/vue3": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-2.0.17.tgz",
"integrity": "sha512-Al0IMHQSj5aTQBLUAkljFEMCw4YRwSiOSKzN8LAbvJpKwvJFgc/wSj3wVVpr/AO9y9mz1w2mtvjnDoOzsntPLw==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-3.0.3.tgz",
"integrity": "sha512-bhJN+GS66g1tYH1p6flKkG1N8oaT5J7ZLqBkavN9mHC6bVfoQCUG6sCuA07WTDfo9tDaxU89wsSSAf4mhn3SuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@inertiajs/core": "2.0.17",
"es-toolkit": "^1.33.0"
"@inertiajs/core": "3.0.3",
"es-toolkit": "^1.33.0",
"laravel-precognition": "^2.0.0"
},
"peerDependencies": {
"vue": "^3.0.0"
@ -3804,9 +3813,9 @@
}
},
"node_modules/es-toolkit": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"dev": true,
"license": "MIT",
"workspaces": [
@ -4372,6 +4381,24 @@
"node": ">=0.10.0"
}
},
"node_modules/laravel-precognition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz",
"integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-toolkit": "^1.32.0"
},
"peerDependencies": {
"axios": "^1.4.0"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"node_modules/laravel-vite-plugin": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
@ -4875,19 +4902,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
@ -5098,22 +5112,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
@ -5361,82 +5359,6 @@
"node": ">= 0.4"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/skema": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/skema/-/skema-1.0.2.tgz",

View File

@ -7,7 +7,7 @@
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
},
"devDependencies": {
"@inertiajs/vue3": "2.0",
"@inertiajs/vue3": "^3.0",
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.18",

View File

@ -4,7 +4,7 @@ import { Card, CardContent } from "@/Components/ui/card";
const props = defineProps({
label: String,
value: [String, Number],
icon: Object,
icon: [Object, Function],
iconBg: {
type: String,
default: "bg-primary/10",

View File

@ -148,7 +148,7 @@ function formatDateTimeNoSeconds(value) {
last_page: imports?.meta?.last_page,
from: imports?.meta?.from,
to: imports?.meta?.to,
links: imports?.links,
links: imports?.meta?.links,
}"
route-name="imports.index"
:only-props="['imports']"

View File

@ -163,9 +163,7 @@ const props = defineProps({
<template>
<AppLayout title="Uvozne predloge">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uvozne predloge</h2>
</template>
<template #header> </template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">

View File

@ -56,9 +56,6 @@ import {
Download,
Eye,
Building2,
Phone,
Mail,
MapPin,
Activity,
} from "lucide-vue-next";
@ -284,16 +281,11 @@ const clientSummary = computed(() => {
<template #header>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<Button
variant="ghost"
size="sm"
@click="router.visit(route('phone.index'))"
class="shrink-0"
>
<ArrowLeft class="w-4 h-4 mr-1" />
<Button variant="outline" size="sm" @click="router.visit(route('phone.index'))">
<ArrowLeft />
Nazaj
</Button>
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-100 truncate">
<h2 class="font-semibold text-gray-800 dark:text-gray-100 truncate">
{{ client_case?.person?.full_name }}
</h2>
</div>
@ -303,7 +295,7 @@ const clientSummary = computed(() => {
variant="secondary"
class="bg-emerald-100 text-emerald-700 hover:bg-emerald-100"
>
<CheckCircle2 class="w-3 h-3 mr-1" />
<CheckCircle2 class="w-4 h-4" />
Zaključeno danes
</Badge>
<Button
@ -311,25 +303,25 @@ const clientSummary = computed(() => {
@click="confirmComplete = true"
class="bg-green-600 hover:bg-green-700"
>
<CheckCircle2 class="w-4 h-4 mr-2" />
<CheckCircle2 class="w-4 h-4" />
Zaključi
</Button>
</div>
</div>
</template>
<div class="py-4 sm:py-6">
<div class="py-4 sm:py-2">
<div class="mx-auto max-w-5xl px-2 sm:px-4 space-y-4">
<!-- Client details (account holder) -->
<Card class="gap-3">
<CardHeader>
<Card class="p-0 py-3 gap-3">
<CardHeader class="px-3 py-2">
<CardTitle class="flex items-center gap-2 text-base">
<Building2 class="w-5 h-5 text-gray-500" />
<span class="truncate">{{ clientSummary.name }}</span>
<Badge variant="secondary">Naročnik</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<CardContent class="px-3">
<Separator class="mb-4" />
<PersonDetailPhone
:types="types"
@ -340,8 +332,8 @@ const clientSummary = computed(() => {
</Card>
<!-- Person (case person) -->
<Card class="gap-3">
<CardHeader class="px-3">
<Card class="p-0 py-3 gap-3">
<CardHeader class="px-3 py-2">
<CardTitle class="flex items-center gap-2 text-base">
<User class="w-5 h-5 text-gray-500" />
<span class="truncate">{{ client_case.person.full_name }}</span>
@ -353,6 +345,13 @@ const clientSummary = computed(() => {
>
{{ client_case.person.description }}
</CardDescription>
<p
v-if="client_case?.person?.employer"
class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-1"
>
<Building2 class="w-3.5 h-3.5 shrink-0" />
{{ client_case.person.employer }}
</p>
</CardHeader>
<CardContent class="px-3">
<Separator class="mb-4" />
@ -365,21 +364,21 @@ const clientSummary = computed(() => {
</Card>
<!-- Contracts assigned to me -->
<Card class="p-0 pt-3 gap-1">
<CardHeader class="px-4">
<Card class="p-0 py-3 gap-1">
<CardHeader class="px-3 py-2 pb-0">
<CardTitle class="flex items-center gap-2">
<FileText class="w-5 h-5" />
Pogodbe
</CardTitle>
</CardHeader>
<CardContent class="p-2">
<CardContent class="p-2 space-y-1">
<Card
v-for="c in contracts"
:key="c.uuid || c.id"
class="overflow-hidden p-0 gap-3"
class="overflow-hidden p-0 gap-2"
>
<!-- Contract header: reference + type badge -->
<CardHeader class="p-3 pb-2">
<CardHeader class="p-3 pb-2 gap-0">
<div class="flex items-center flex-wrap">
<CardTitle class="text-base font-semibold">
{{ c.reference || "Šifra pogodbe ni določena" }}
@ -408,12 +407,20 @@ const clientSummary = computed(() => {
<!-- Collapsibles: description, meta, last object -->
<CardContent
v-if="
c.description || c.last_object || (c.meta && Object.keys(c.meta).length)
c.description ||
c.latest_object ||
(c.meta && Object.keys(c.meta).length)
"
class="pt-0 px-0 space-y-0"
>
<!-- Description + Meta Accordion -->
<template v-if="c.description || (c.meta && Object.keys(c.meta).length)">
<!-- Description + Meta + Latest Object Accordion -->
<template
v-if="
c.description ||
(c.meta && Object.keys(c.meta).length) ||
c.latest_object
"
>
<Separator />
<Accordion type="multiple" class="w-full">
<AccordionItem
@ -479,43 +486,48 @@ const clientSummary = computed(() => {
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</template>
<!-- Last object -->
<template v-if="c.last_object">
<Separator
class="mb-3"
<AccordionItem
v-if="c.latest_object"
value="latest_object"
class="border-b-0"
:class="
c.description || (c.meta && Object.keys(c.meta).length)
? 'mt-2'
: 'mt-0'
? 'border-t'
: ''
"
/>
<div class="py-1 space-y-1">
<p class="text-xs text-gray-400 uppercase tracking-wide font-medium">
>
<AccordionTrigger
class="px-3 py-2 text-xs font-medium uppercase tracking-wide hover:no-underline"
>
Zadnji predmet
</p>
</AccordionTrigger>
<AccordionContent class="px-3 pb-3">
<div
class="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"
>
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">
{{ c.last_object.name || c.last_object.reference }}
{{ c.latest_object.name || c.latest_object.reference }}
<span
v-if="c.last_object.type"
v-if="c.latest_object.type"
class="ml-1.5 text-xs font-normal text-gray-400"
>({{ c.last_object.type }})</span
>({{ c.latest_object.type }})</span
>
</p>
<p
v-if="c.last_object.description"
class="text-xs text-gray-500 dark:text-gray-400"
v-if="c.latest_object.description"
class="text-xs text-gray-500 dark:text-gray-400 mt-1"
>
{{ c.last_object.description }}
{{ c.latest_object.description }}
</p>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</template>
</CardContent>
<!-- Action buttons: full-width row at bottom -->
<div class="grid grid-cols-2 gap-0 border-t mt-1">
<div class="grid grid-cols-2 gap-0 border-t mt-0">
<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)"
@ -542,27 +554,27 @@ const clientSummary = computed(() => {
</Card>
<!-- Activities -->
<Card>
<CardHeader>
<Card class="p-0 py-2 gap-2">
<CardHeader class="px-3 py-2">
<div class="flex items-center justify-between">
<CardTitle class="flex items-center gap-2">
<Activity class="w-5 h-5" />
Aktivnosti
</CardTitle>
<Button size="sm" @click="openDrawerAddActivity()">
<Plus class="w-4 h-4 mr-1" />
<Plus class="w-4 h-4" />
Nova
</Button>
</div>
</CardHeader>
<CardContent class="space-y-3">
<CardContent class="space-y-1 px-2">
<Card
v-for="a in activities"
:key="a.id"
class="bg-gray-50/70 dark:bg-gray-800/50"
class="bg-gray-50/70 dark:bg-gray-800/50 p-0 py-2 gap-2"
>
<CardHeader>
<div class="flex items-start justify-between gap-3">
<CardHeader class="px-3 py-2">
<div class="flex items-start justify-between">
<CardTitle class="text-sm font-medium truncate">
{{ activityActionLine(a) || "Aktivnost" }}
</CardTitle>
@ -583,7 +595,7 @@ const clientSummary = computed(() => {
</div>
</div>
</CardHeader>
<CardContent class="pt-0 space-y-2">
<CardContent class="p-2 pt-0 space-y-2">
<div class="flex flex-wrap gap-1.5">
<Badge v-if="a.contract" variant="secondary" class="text-[10px]">
<FileText class="w-3 h-3 mr-1" />
@ -609,7 +621,10 @@ const clientSummary = computed(() => {
{{ a.status }}
</Badge>
</div>
<p v-if="a.note" class="text-sm text-gray-700 dark:text-gray-300">
<p
v-if="a.note"
class="text-sm text-gray-900 dark:text-gray-300 whitespace-pre-line rounded-lg bg-secondary dark:bg-gray-800/50 p-2"
>
{{ a.note }}
</p>
</CardContent>
@ -624,8 +639,8 @@ const clientSummary = computed(() => {
</Card>
<!-- Documents (case + assigned contracts) -->
<Card>
<CardHeader>
<Card class="p-0 py-2 gap-2">
<CardHeader class="px-3 py-2">
<div class="flex items-center justify-between">
<CardTitle class="flex items-center gap-2">
<FileText class="w-5 h-5" />
@ -649,7 +664,7 @@ const clientSummary = computed(() => {
{{ d.name || d.original_name }}
</div>
<div
class="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-2"
class="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-2 flex-wrap"
>
<Badge
v-if="d.contract_reference"
@ -659,6 +674,11 @@ const clientSummary = computed(() => {
Pogodba: {{ d.contract_reference }}
</Badge>
<Badge v-else variant="outline" class="text-[10px]"> Primer </Badge>
<span
v-if="d.mime_type"
class="text-[10px] text-gray-400 font-mono"
>{{ d.mime_type }}</span
>
<span v-if="d.created_at" class="flex items-center gap-1">
<Calendar class="w-3 h-3" />
{{ new Date(d.created_at).toLocaleDateString("sl-SI") }}

View File

@ -12,8 +12,16 @@ import {
} 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 { InfiniteScroll, router } from "@inertiajs/vue3";
import {
computed,
defineComponent,
h,
onMounted,
onUnmounted,
ref,
watch,
} from "vue";
import { useDebounceFn } from "@vueuse/core";
import {
CalendarDays,
@ -67,8 +75,10 @@ function performFilter() {
preserveState: true,
preserveScroll: false,
only,
reset: isCompleted.value
? ["completedJobs"]
: ["pendingJobs", "processedJobs"],
onSuccess: () => {
resetLists();
isFiltering.value = false;
},
onError: () => {
@ -83,92 +93,6 @@ function clearFilters() {
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 = [];
// Scroll-hide title
const scrolled = ref(false);
let stopNavigateListener = null;
@ -189,43 +113,10 @@ onMounted(() => {
}
});
window.addEventListener("scroll", onScroll, { passive: true });
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(() => {
if (stopNavigateListener) stopNavigateListener();
observers.forEach((o) => o.disconnect());
window.removeEventListener("scroll", onScroll);
});
@ -531,9 +422,11 @@ const JobCard = defineComponent({
<!-- Pending tab -->
<TabsContent value="pending" class="space-y-3">
<template v-if="pendingList.length">
<InfiniteScroll data="pendingJobs" only-next>
<template #default="{ loading }">
<template v-if="props.pendingJobs?.data?.length">
<JobCard
v-for="job in pendingList"
v-for="job in props.pendingJobs.data"
:key="job.id"
:job="job"
:href="jobHref(job)"
@ -541,7 +434,7 @@ const JobCard = defineComponent({
/>
</template>
<div
v-else-if="!loadingPending"
v-else-if="!loading"
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" />
@ -551,18 +444,22 @@ const JobCard = defineComponent({
}}
</p>
</div>
<!-- Sentinel for infinite scroll -->
<div ref="pendingSentinel" class="h-px" />
<div v-if="loadingPending" class="space-y-3">
</template>
<template #loading>
<div class="space-y-3">
<Skeleton v-for="i in 3" :key="i" class="h-36 rounded-xl" />
</div>
</template>
</InfiniteScroll>
</TabsContent>
<!-- Processed tab -->
<TabsContent value="processed" class="space-y-3">
<template v-if="processedList.length">
<InfiniteScroll data="processedJobs" only-next>
<template #default="{ loading }">
<template v-if="props.processedJobs?.data?.length">
<JobCard
v-for="job in processedList"
v-for="job in props.processedJobs.data"
:key="job.id"
:job="job"
:href="jobHref(job)"
@ -571,7 +468,7 @@ const JobCard = defineComponent({
/>
</template>
<div
v-else-if="!loadingProcessed"
v-else-if="!loading"
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" />
@ -583,20 +480,24 @@ const JobCard = defineComponent({
}}
</p>
</div>
<!-- Sentinel for infinite scroll -->
<div ref="processedSentinel" class="h-px" />
<div v-if="loadingProcessed" class="space-y-3">
</template>
<template #loading>
<div class="space-y-3">
<Skeleton v-for="i in 3" :key="i" class="h-36 rounded-xl" />
</div>
</template>
</InfiniteScroll>
</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">
<InfiniteScroll data="completedJobs" only-next>
<template #default="{ loading }">
<template v-if="props.completedJobs?.data?.length">
<JobCard
v-for="job in completedList"
v-for="job in props.completedJobs.data"
:key="job.id"
:job="job"
:href="jobHref(job)"
@ -605,7 +506,7 @@ const JobCard = defineComponent({
/>
</template>
<div
v-else-if="!loadingCompleted"
v-else-if="!loading"
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" />
@ -617,11 +518,13 @@ const JobCard = defineComponent({
}}
</p>
</div>
<!-- Sentinel for infinite scroll -->
<div ref="completedSentinel" class="h-px" />
<div v-if="loadingCompleted" class="space-y-3">
</template>
<template #loading>
<div class="space-y-3">
<Skeleton v-for="i in 3" :key="i" class="h-36 rounded-xl" />
</div>
</template>
</InfiniteScroll>
</div>
</div>
</AppPhoneLayout>