production #1

Merged
sipo merged 45 commits from production into master 2026-01-27 18:02:44 +00:00
14 changed files with 1141 additions and 626 deletions
Showing only changes of commit 80948d2944 - Show all commits

Binary file not shown.

View File

@ -4,15 +4,18 @@
use App\Http\Requests\StoreContractRequest;
use App\Http\Requests\UpdateContractRequest;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
use App\Models\Segment;
use App\Services\Documents\DocumentStreamService;
use App\Services\ReferenceDataCache;
use App\Services\Sms\SmsService;
use Exception;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
@ -30,6 +33,16 @@ public function __construct(
public function index(ClientCase $clientCase, Request $request)
{
$search = $request->input('search');
$from = $this->normalizeDate($request->input('from'));
$to = $this->normalizeDate($request->input('to'));
$clientFilter = collect(explode(',', (string) $request->input('clients')))
->filter()
->map(fn ($value) => (int) $value)
->filter(fn ($value) => $value > 0)
->unique()
->values();
$perPage = $this->resolvePerPage($request);
$query = $clientCase::query()
->select('client_cases.*')
@ -39,7 +52,6 @@ public function index(ClientCase $clientCase, Request $request)
->groupBy('client_cases.id');
})
->where('client_cases.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('contracts', function ($join) {
$join->on('contracts.client_case_id', '=', 'client_cases.id')
->whereNull('contracts.deleted_at');
@ -49,11 +61,18 @@ public function index(ClientCase $clientCase, Request $request)
->where('contract_segment.active', true);
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->when($clientFilter->isNotEmpty(), function ($que) use ($clientFilter) {
$que->whereIn('client_cases.client_id', $clientFilter->all());
})
->when($from, function ($que) use ($from) {
$que->whereDate('client_cases.created_at', '>=', $from);
})
->when($to, function ($que) use ($to) {
$que->whereDate('client_cases.created_at', '<=', $to);
})
->groupBy('client_cases.id')
->addSelect([
// Count of active contracts (a contract is considered active if it has an active pivot in contract_segment)
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
// Sum of balances for accounts of 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'),
])
->with(['person.client', 'client.person'])
@ -61,12 +80,49 @@ public function index(ClientCase $clientCase, Request $request)
return Inertia::render('Cases/Index', [
'client_cases' => $query
->paginate($request->integer('perPage', 15), ['*'], 'clientCasesPage')
->paginate($perPage, ['*'], 'clientCasesPage')
->withQueryString(),
'filters' => $request->only(['search']),
'filters' => [
'search' => $search,
'from' => $from,
'to' => $to,
'clients' => $clientFilter->map(fn ($value) => (string) $value)->all(),
'perPage' => $perPage,
],
'clients' => Client::query()
->select(['clients.id', 'person.full_name as name'])
->join('person', 'person.id', '=', 'clients.person_id')
->orderBy('person.full_name')
->get()
->map(fn ($client) => [
'id' => (int) $client->id,
'name' => (string) ($client->name ?? ''),
]),
]);
}
private function resolvePerPage(Request $request): int
{
$allowed = [10, 15, 25, 50, 100];
$perPage = (int) $request->integer('perPage', 15);
return in_array($perPage, $allowed, true) ? $perPage : 15;
}
private function normalizeDate(?string $value): ?string
{
if (! $value) {
return null;
}
try {
return Carbon::parse($value)->toDateString();
} catch (\Throwable) {
return null;
}
}
/**
* Show the form for creating a new resource.
*/
@ -1031,7 +1087,7 @@ public function emergencyCreatePerson(ClientCase $clientCase, Request $request)
if ($existing && ! $existing->trashed()) {
return back()->with('flash', [
'type' => 'info',
'message' => 'Person already exists emergency creation not needed.',
'message' => 'Person already exists ÔÇô emergency creation not needed.',
]);
}
@ -1136,10 +1192,10 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
if (! empty($validated['sender_id'])) {
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
if (! $sender) {
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
}
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
}
}
if (! $profile) {
@ -1182,7 +1238,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
}
// Create an activity before sending
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
$activityData = [
'note' => $activityNote,
'user_id' => optional($request->user())->id,
@ -1220,7 +1276,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
activityId: $activity?->id,
);
return back()->with('success', 'SMS je bil dodan v čakalno vrsto.');
return back()->with('success', 'SMS je bil dodan v ─Źakalno vrsto.');
} catch (\Throwable $e) {
\Log::warning('SMS enqueue failed', [
'error' => $e->getMessage(),
@ -1228,7 +1284,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
'phone_id' => $phone_id,
]);
return back()->with('error', 'SMS ni bil dodan v čakalno vrsto.');
return back()->with('error', 'SMS ni bil dodan v ─Źakalno vrsto.');
}
}

304
package-lock.json generated
View File

@ -5,21 +5,21 @@
"packages": {
"": {
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/vue-fontawesome": "^3.0.8",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.1.2",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@internationalized/date": "^3.9.0",
"@heroicons/vue": "^2.2.0",
"@internationalized/date": "^3.10.0",
"@tanstack/vue-table": "^8.21.3",
"@unovis/ts": "^1.6.2",
"@unovis/vue": "^1.6.2",
"@vee-validate/zod": "^4.15.1",
"@vuepic/vue-datepicker": "^11.0.2",
"@vueuse/core": "^14.0.0",
"apexcharts": "^4.0.0",
"@vuepic/vue-datepicker": "^11.0.3",
"@vueuse/core": "^14.1.0",
"apexcharts": "^4.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@ -28,37 +28,37 @@
"material-design-icons-iconfont": "^6.7.0",
"preline": "^2.7.0",
"quill": "^1.3.7",
"reka-ui": "^2.6.0",
"tailwind-merge": "^3.3.1",
"reka-ui": "^2.6.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-inner-border": "^0.2.0",
"v-calendar": "^3.1.2",
"vee-validate": "^4.15.1",
"vue-currency-input": "^3.2.1",
"vue-multiselect": "^3.1.0",
"vue-search-input": "^1.1.16",
"vue-multiselect": "^3.4.0",
"vue-search-input": "^1.1.19",
"vue-sonner": "^2.0.9",
"vue3-apexcharts": "^1.7.0",
"vue3-apexcharts": "^1.10.0",
"vuedraggable": "^4.1.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@inertiajs/vue3": "2.0",
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/typography": "^0.5.10",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.16",
"axios": "^1.7.4",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.10.3",
"@vitejs/plugin-vue": "^6.0.2",
"autoprefixer": "^10.4.22",
"axios": "^1.13.2",
"laravel-vite-plugin": "^2.0.1",
"postcss": "^8.4.32",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.16",
"typescript": "^5.9.3",
"vite": "^7.1.7",
"vite": "^7.2.7",
"vue": "^3.3.13",
"vue-tsc": "^3.1.5"
"vue-tsc": "^3.1.8"
}
},
"node_modules/@alloc/quick-lru": {
@ -1472,9 +1472,9 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
"integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1484,37 +1484,37 @@
"lightningcss": "1.30.2",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.17"
"tailwindcss": "4.1.18"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz",
"integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.17",
"@tailwindcss/oxide-darwin-arm64": "4.1.17",
"@tailwindcss/oxide-darwin-x64": "4.1.17",
"@tailwindcss/oxide-freebsd-x64": "4.1.17",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.17",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.17",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.17",
"@tailwindcss/oxide-linux-x64-musl": "4.1.17",
"@tailwindcss/oxide-wasm32-wasi": "4.1.17",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.17",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.17"
"@tailwindcss/oxide-android-arm64": "4.1.18",
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
"@tailwindcss/oxide-darwin-x64": "4.1.18",
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz",
"integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
"cpu": [
"arm64"
],
@ -1529,9 +1529,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz",
"integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
"cpu": [
"arm64"
],
@ -1546,9 +1546,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz",
"integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
"cpu": [
"x64"
],
@ -1563,9 +1563,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz",
"integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
"cpu": [
"x64"
],
@ -1580,9 +1580,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz",
"integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
"cpu": [
"arm"
],
@ -1597,9 +1597,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz",
"integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
"cpu": [
"arm64"
],
@ -1614,9 +1614,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz",
"integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
"cpu": [
"arm64"
],
@ -1631,9 +1631,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz",
"integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
"cpu": [
"x64"
],
@ -1648,9 +1648,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz",
"integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
"cpu": [
"x64"
],
@ -1665,9 +1665,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz",
"integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@ -1683,10 +1683,10 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.6.0",
"@emnapi/runtime": "^1.6.0",
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.0.7",
"@napi-rs/wasm-runtime": "^1.1.0",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.4.0"
},
@ -1695,7 +1695,7 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.6.0",
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
@ -1706,7 +1706,7 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.6.0",
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
@ -1726,14 +1726,14 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.7",
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
@ -1755,9 +1755,9 @@
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz",
"integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
"cpu": [
"arm64"
],
@ -1772,9 +1772,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz",
"integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
"cpu": [
"x64"
],
@ -1789,17 +1789,17 @@
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz",
"integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
"integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.17",
"@tailwindcss/oxide": "4.1.17",
"@tailwindcss/node": "4.1.18",
"@tailwindcss/oxide": "4.1.18",
"postcss": "^8.4.41",
"tailwindcss": "4.1.17"
"tailwindcss": "4.1.18"
}
},
"node_modules/@tailwindcss/typography": {
@ -1829,9 +1829,9 @@
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
"version": "3.13.13",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.13.tgz",
"integrity": "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==",
"license": "MIT",
"funding": {
"type": "github",
@ -1858,12 +1858,12 @@
}
},
"node_modules/@tanstack/vue-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz",
"integrity": "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==",
"version": "3.13.13",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.13.tgz",
"integrity": "sha512-Cf2xIEE8nWAfsX0N5nihkPYMeQRT+pHt4NEkuP8rNCn6lVnLDiV8rC8IeIxbKmQC0yPnj4SIBLwXYVf86xxKTQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.12"
"@tanstack/virtual-core": "3.13.13"
},
"funding": {
"type": "github",
@ -2208,9 +2208,9 @@
}
},
"node_modules/@types/node": {
"version": "24.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"version": "24.10.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.3.tgz",
"integrity": "sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2414,30 +2414,30 @@
}
},
"node_modules/@volar/language-core": {
"version": "2.4.23",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.23.tgz",
"integrity": "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==",
"version": "2.4.26",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.26.tgz",
"integrity": "sha512-hH0SMitMxnB43OZpyF1IFPS9bgb2I3bpCh76m2WEK7BE0A0EzpYsRp0CCH2xNKshr7kacU5TQBLYn4zj7CG60A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/source-map": "2.4.23"
"@volar/source-map": "2.4.26"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.23",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.23.tgz",
"integrity": "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==",
"version": "2.4.26",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.26.tgz",
"integrity": "sha512-JJw0Tt/kSFsIRmgTQF4JSt81AUSI1aEye5Zl65EeZ8H35JHnTvFGmpDOBn5iOxd48fyGE+ZvZBp5FcgAy/1Qhw==",
"dev": true,
"license": "MIT"
},
"node_modules/@volar/typescript": {
"version": "2.4.23",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.23.tgz",
"integrity": "sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==",
"version": "2.4.26",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.26.tgz",
"integrity": "sha512-N87ecLD48Sp6zV9zID/5yuS1+5foj0DfuYGdQ6KHj/IbKvyKv1zNX6VCmnKYwtmHadEO6mFc2EKISiu3RDPAvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.23",
"@volar/language-core": "2.4.26",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
@ -2526,13 +2526,13 @@
}
},
"node_modules/@vue/language-core": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.5.tgz",
"integrity": "sha512-FMcqyzWN+sYBeqRMWPGT2QY0mUasZMVIuHvmb5NT3eeqPrbHBYtCP8JWEUCDCgM+Zr62uuWY/qoeBrPrzfa78w==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.8.tgz",
"integrity": "sha512-PfwAW7BLopqaJbneChNL6cUOTL3GL+0l8paYP5shhgY5toBNidWnMXWM+qDwL7MC9+zDtzCF2enT8r6VPu64iw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.23",
"@volar/language-core": "2.4.26",
"@vue/compiler-dom": "^3.5.0",
"@vue/shared": "^3.5.0",
"alien-signals": "^3.0.0",
@ -2764,9 +2764,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
"integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.6.tgz",
"integrity": "sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -2774,18 +2774,18 @@
}
},
"node_modules/birpc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz",
"integrity": "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/browserslist": {
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
"integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@ -2803,11 +2803,11 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
"electron-to-chromium": "^1.5.249",
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.1.4"
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@ -2873,9 +2873,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001757",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
"version": "1.0.30001760",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz",
"integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==",
"dev": true,
"funding": [
{
@ -3627,9 +3627,9 @@
"license": "ISC"
},
"node_modules/electron-to-chromium": {
"version": "1.5.263",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.263.tgz",
"integrity": "sha512-DrqJ11Knd+lo+dv+lltvfMDLU27g14LMdH2b0O3Pio4uk0x+z7OR+JrmyacTPN2M8w3BrZ7/RTwG3R9B7irPlg==",
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
@ -5413,9 +5413,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
@ -5561,9 +5561,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
"integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",
"integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==",
"dev": true,
"funding": [
{
@ -5655,9 +5655,9 @@
}
},
"node_modules/vite": {
"version": "7.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz",
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"version": "7.2.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5849,14 +5849,14 @@
}
},
"node_modules/vue-tsc": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.1.5.tgz",
"integrity": "sha512-L/G9IUjOWhBU0yun89rv8fKqmKC+T0HfhrFjlIml71WpfBv9eb4E9Bev8FMbyueBIU9vxQqbd+oOsVcDa5amGw==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.1.8.tgz",
"integrity": "sha512-deKgwx6exIHeZwF601P1ktZKNF0bepaSN4jBU3AsbldPx9gylUc1JDxYppl82yxgkAgaz0Y0LCLOi+cXe9HMYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/typescript": "2.4.23",
"@vue/language-core": "3.1.5"
"@volar/typescript": "2.4.26",
"@vue/language-core": "3.1.8"
},
"bin": {
"vue-tsc": "bin/vue-tsc.js"

View File

@ -9,37 +9,37 @@
"devDependencies": {
"@inertiajs/vue3": "2.0",
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/typography": "^0.5.10",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.16",
"axios": "^1.7.4",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.10.3",
"@vitejs/plugin-vue": "^6.0.2",
"autoprefixer": "^10.4.22",
"axios": "^1.13.2",
"laravel-vite-plugin": "^2.0.1",
"postcss": "^8.4.32",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.16",
"typescript": "^5.9.3",
"vite": "^7.1.7",
"vite": "^7.2.7",
"vue": "^3.3.13",
"vue-tsc": "^3.1.5"
"vue-tsc": "^3.1.8"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/vue-fontawesome": "^3.0.8",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.1.2",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@internationalized/date": "^3.9.0",
"@heroicons/vue": "^2.2.0",
"@internationalized/date": "^3.10.0",
"@tanstack/vue-table": "^8.21.3",
"@unovis/ts": "^1.6.2",
"@unovis/vue": "^1.6.2",
"@vee-validate/zod": "^4.15.1",
"@vuepic/vue-datepicker": "^11.0.2",
"@vueuse/core": "^14.0.0",
"apexcharts": "^4.0.0",
"@vuepic/vue-datepicker": "^11.0.3",
"@vueuse/core": "^14.1.0",
"apexcharts": "^4.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@ -48,17 +48,17 @@
"material-design-icons-iconfont": "^6.7.0",
"preline": "^2.7.0",
"quill": "^1.3.7",
"reka-ui": "^2.6.0",
"tailwind-merge": "^3.3.1",
"reka-ui": "^2.6.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-inner-border": "^0.2.0",
"v-calendar": "^3.1.2",
"vee-validate": "^4.15.1",
"vue-currency-input": "^3.2.1",
"vue-multiselect": "^3.1.0",
"vue-search-input": "^1.1.16",
"vue-multiselect": "^3.4.0",
"vue-search-input": "^1.1.19",
"vue-sonner": "^2.0.9",
"vue3-apexcharts": "^1.7.0",
"vue3-apexcharts": "^1.10.0",
"vuedraggable": "^4.1.0",
"zod": "^3.25.76"
}

View File

@ -441,12 +441,18 @@ function doServerRequest(overrides = {}) {
});
const url = route(props.routeName, props.routeParams || {});
router.get(url, q, {
const onlyProps = Array.isArray(props.onlyProps) ? props.onlyProps : [];
const inertiaOptions = {
preserveScroll: props.preserveScroll,
preserveState: props.preserveState,
replace: true,
only: props.onlyProps.length ? props.onlyProps : undefined,
});
};
if (onlyProps.length > 0) {
inertiaOptions.only = onlyProps;
}
router.get(url, q, inertiaOptions);
}
// Row key helper

View File

@ -1,5 +1,5 @@
<script setup>
import { onMounted, onUnmounted, ref, watch, computed } from "vue";
import { onMounted, onUnmounted, ref, watch, computed, h } from "vue";
import { Head, Link, router, usePage } from "@inertiajs/vue3";
import ApplicationMark from "@/Components/ApplicationMark.vue";
import Banner from "@/Components/Banner.vue";
@ -23,6 +23,22 @@ import {
faMap,
faGear,
} from "@fortawesome/free-solid-svg-icons";
import { MenuIcon } from "lucide-vue-next";
import { SearchIcon } from "lucide-vue-next";
import { ChevronDownIcon } from "lucide-vue-next";
import { LayoutDashboardIcon } from "lucide-vue-next";
import { FileChartColumn } from "lucide-vue-next";
import { UsersIcon } from "lucide-vue-next";
import { FoldersIcon } from "lucide-vue-next";
import { LayoutIcon } from "lucide-vue-next";
import { ImportIcon } from "lucide-vue-next";
import { FilePlusIcon } from "lucide-vue-next";
import { ListIndentIncreaseIcon } from "lucide-vue-next";
import { MapIcon } from "lucide-vue-next";
import { SettingsIcon } from "lucide-vue-next";
import { ShieldUserIcon } from "lucide-vue-next";
import { SmartphoneIcon } from "lucide-vue-next";
import { TabletSmartphoneIcon } from "lucide-vue-next";
const props = defineProps({
title: String,
@ -115,12 +131,14 @@ const rawMenuGroups = [
items: [
{
key: "dashboard",
icon: LayoutDashboardIcon,
title: "Nadzorna plošča",
routeName: "dashboard",
active: ["dashboard"],
},
{
key: "reports",
icon: FileChartColumn,
title: "Poročila",
routeName: "reports.index",
active: ["reports.index", "reports.show"],
@ -133,18 +151,21 @@ const rawMenuGroups = [
items: [
{
key: "clients",
icon: UsersIcon,
title: "Naročniki",
routeName: "client",
active: ["client", "client.*"],
},
{
key: "cases",
icon: FoldersIcon,
title: "Primeri",
routeName: "clientCase",
active: ["clientCase", "clientCase.*"],
},
{
key: "segments",
icon: LayoutIcon,
title: "Segmenti",
routeName: "segments.index",
active: ["segments.index"],
@ -157,18 +178,21 @@ const rawMenuGroups = [
items: [
{
key: "imports",
icon: ImportIcon,
title: "Uvozi",
routeName: "imports.index",
active: ["imports.index", "imports.*"],
},
{
key: "import-templates",
icon: ListIndentIncreaseIcon,
title: "Uvozne predloge",
routeName: "importTemplates.index",
active: ["importTemplates.index"],
},
{
key: "import-templates-new",
icon: FilePlusIcon,
title: "Nova uvozna predloga",
routeName: "importTemplates.create",
active: ["importTemplates.create"],
@ -181,6 +205,7 @@ const rawMenuGroups = [
items: [
{
key: "fieldjobs",
icon: MapIcon,
title: "Terenske naloge",
routeName: "fieldjobs.index",
active: ["fieldjobs.index"],
@ -195,6 +220,7 @@ const rawMenuGroups = [
items: [
{
key: "settings",
icon: SettingsIcon,
title: "Nastavitve",
routeName: "settings",
active: ["settings", "settings.*"],
@ -204,6 +230,7 @@ const rawMenuGroups = [
// We'll filter it out below if not authorized.
{
key: "admin-panel",
icon: ShieldUserIcon,
title: "Administrator",
routeName: "admin.index",
active: ["admin.index", "admin.users.index", "admin.permissions.create"],
@ -247,21 +274,6 @@ const menuGroups = computed(() => {
);
});
// Icon map for menu keys -> FontAwesome icon definitions
const menuIconMap = {
dashboard: faGaugeHigh,
segments: faLayerGroup,
clients: faUserGroup,
cases: faFolderOpen,
imports: faFileImport,
"import-templates": faTableList,
"import-templates-new": faFileCirclePlus,
fieldjobs: faMap,
settings: faGear,
"admin-panel": faUserGroup,
reports: faTableList,
};
function isActive(patterns) {
try {
return patterns?.some((p) => route().current(p));
@ -289,7 +301,7 @@ function isActive(patterns) {
<aside
:class="[
sidebarCollapsed ? 'w-16' : 'w-64',
'bg-white border-r border-gray-200 transition-all duration-300 ease-in-out z-50',
'bg-sidebar text-sidebar-foreground border-r border-sidebar-border transition-all duration-300 ease-in-out z-50',
// Off-canvas behavior on mobile; sticky fixed-like sidebar on desktop
isMobile
? 'fixed inset-y-0 left-0 transform shadow-strong ' +
@ -297,7 +309,9 @@ function isActive(patterns) {
: 'sticky top-0 h-screen overflow-y-auto',
]"
>
<div class="h-16 px-4 flex items-center justify-between border-b border-gray-200 bg-white">
<div
class="h-16 px-4 flex items-center justify-between border-b border-sidebar-border bg-sidebar"
>
<Link
:href="route('dashboard')"
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
@ -305,7 +319,7 @@ function isActive(patterns) {
<ApplicationMark />
<span
v-if="!sidebarCollapsed"
class="text-sm font-semibold text-gray-900 transition-opacity"
class="text-sm font-semibold text-sidebar-foreground transition-opacity"
>
Teren
</span>
@ -316,7 +330,7 @@ function isActive(patterns) {
<li v-for="group in menuGroups" :key="group.label">
<div
v-if="!sidebarCollapsed"
class="px-4 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-gray-400"
class="px-4 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-sidebar-foreground/60"
>
{{ group.label }}
</div>
@ -327,19 +341,15 @@ function isActive(patterns) {
:class="[
'flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-all duration-150',
isActive(item.active)
? 'bg-primary-50 text-primary-700 font-medium shadow-sm'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900',
? 'bg-sidebar-primary/15 text-sidebar-primary font-medium shadow-sm'
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
]"
:title="item.title"
>
<!-- Unified FontAwesome icon rendering -->
<FontAwesomeIcon
v-if="menuIconMap[item.key]"
:icon="menuIconMap[item.key]"
:class="[
'w-5 h-5 flex-shrink-0 transition-colors',
isActive(item.active) ? 'text-primary-600' : 'text-gray-500',
]"
<component
v-if="item.icon"
:is="item.icon"
class="w-5 h-5 shrink-0 transition-colors"
/>
<!-- Title -->
<span
@ -361,7 +371,7 @@ function isActive(patterns) {
<div class="flex-1 flex flex-col min-w-0">
<!-- Top bar -->
<div
class="h-16 bg-white border-b border-gray-200 px-4 flex items-center justify-between sticky top-0 z-30 backdrop-blur-sm bg-white/95 shadow-sm"
class="h-16 border-b border-gray-200 px-4 flex items-center justify-between sticky top-0 z-30 backdrop-blur-sm bg-white/95 shadow-sm"
>
<div class="flex items-center gap-3">
<!-- Sidebar toggle -->
@ -373,42 +383,11 @@ function isActive(patterns) {
aria-label="Toggle sidebar"
>
<!-- Hamburger (Bars) icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
<MenuIcon />
</Button>
<!-- Search trigger -->
<Button
variant="outline"
size="default"
@click="openSearch"
class="gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z"
/>
</svg>
<Button variant="outline" size="default" @click="openSearch" class="gap-2">
<SearchIcon />
<span class="hidden sm:inline text-sm font-medium">Globalni iskalnik</span>
<kbd
class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border border-gray-300 bg-gray-100 text-gray-600 font-medium"
@ -428,7 +407,7 @@ function isActive(patterns) {
title="Phone"
>
<Link :href="route('phone.index')">
<FontAwesomeIcon :icon="faMobileScreenButton" class="h-5 w-5" />
<TabletSmartphoneIcon />
</Link>
</Button>
<div class="ms-3 relative">
@ -446,27 +425,9 @@ function isActive(patterns) {
</button>
<span v-else class="inline-flex">
<Button
variant="outline"
size="default"
type="button"
class="gap-2"
>
<Button variant="outline" size="default" type="button" class="gap-2">
{{ $page.props.auth.user.name }}
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
<ChevronDownIcon />
</Button>
</span>
</template>
@ -495,10 +456,7 @@ function isActive(patterns) {
</div>
<!-- Page Heading -->
<header
v-if="$slots.header"
class="bg-white border-b border-gray-200 shadow-sm"
>
<header v-if="$slots.header" class="bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
<Breadcrumbs
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"

View File

@ -16,6 +16,14 @@ import {
faClipboardList,
faCircleCheck,
} from "@fortawesome/free-solid-svg-icons";
import {
ChevronDownIcon,
ClipboardCheckIcon,
ClipboardListIcon,
MenuIcon,
MonitorIcon,
SearchIcon,
} from "lucide-vue-next";
const props = defineProps({
title: String,
@ -136,7 +144,9 @@ const closeSearch = () => (searchOpen.value = false);
: 'sticky top-0 h-screen overflow-y-auto',
]"
>
<div class="h-16 px-4 flex items-center justify-between border-b border-gray-200 bg-white">
<div
class="h-16 px-4 flex items-center justify-between border-b border-sidebar-border bg-sidebar"
>
<Link
:href="route('phone.index')"
class="flex items-center gap-2 hover:opacity-80 transition-opacity"
@ -144,7 +154,7 @@ const closeSearch = () => (searchOpen.value = false);
<ApplicationMark />
<span
v-if="showLabels"
class="text-sm font-semibold text-gray-900 transition-opacity"
class="text-sm font-semibold text-sidebar-foreground transition-opacity"
>
Teren
</span>
@ -160,21 +170,12 @@ const closeSearch = () => (searchOpen.value = false);
'flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-all duration-150',
route().current('phone.index') ||
(route().current('phone.case') && !isCompletedMode)
? 'bg-primary-50 text-primary-700 font-medium shadow-sm'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900',
? 'bg-sidebar-primary/15 text-sidebar-primary font-medium shadow-sm'
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
]"
title="Opravila"
>
<FontAwesomeIcon
:icon="faClipboardList"
:class="[
'w-5 h-5 flex-shrink-0 transition-colors',
route().current('phone.index') ||
(route().current('phone.case') && !isCompletedMode)
? 'text-primary-600'
: 'text-gray-500',
]"
/>
<ClipboardListIcon class="w-5 h-5 shrink-0 transition-colors" />
<span
v-if="showLabels"
class="truncate transition-opacity"
@ -196,21 +197,12 @@ const closeSearch = () => (searchOpen.value = false);
'flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-all duration-150',
route().current('phone.completed') ||
(route().current('phone.case') && isCompletedMode)
? 'bg-primary-50 text-primary-700 font-medium shadow-sm'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900',
? 'bg-sidebar-primary/15 text-sidebar-primary font-medium shadow-sm'
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
]"
title="Zaključeno danes"
>
<FontAwesomeIcon
:icon="faCircleCheck"
:class="[
'w-5 h-5 flex-shrink-0 transition-colors',
route().current('phone.completed') ||
(route().current('phone.case') && isCompletedMode)
? 'text-primary-600'
: 'text-gray-500',
]"
/>
<ClipboardCheckIcon class="w-5 h-5 shrink-0 transition-colors" />
<span
v-if="showLabels"
class="truncate transition-opacity"
@ -243,42 +235,11 @@ const closeSearch = () => (searchOpen.value = false);
:title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'"
aria-label="Toggle sidebar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
<MenuIcon />
</Button>
<!-- Search trigger -->
<Button
variant="outline"
size="default"
@click="openSearch"
class="gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z"
/>
</svg>
<Button variant="outline" size="default" @click="openSearch" class="gap-2">
<SearchIcon />
<span class="hidden sm:inline text-sm font-medium">Globalni iskalnik</span>
<kbd
class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border border-gray-300 bg-gray-100 text-gray-600 font-medium"
@ -288,7 +249,7 @@ const closeSearch = () => (searchOpen.value = false);
</div>
<!-- Notifications + User drop menu + Desktop switch button -->
<div class="flex items-center">
<NotificationsBell class="mr-2" />
<NotificationsBell />
<!-- Desktop page quick access button -->
<Button
variant="ghost"
@ -298,7 +259,7 @@ const closeSearch = () => (searchOpen.value = false);
title="Desktop"
>
<Link :href="route('clientCase')">
<FontAwesomeIcon :icon="faDesktop" class="h-5 w-5" />
<MonitorIcon />
</Link>
</Button>
<div class="ms-3 relative">
@ -316,27 +277,9 @@ const closeSearch = () => (searchOpen.value = false);
</button>
<span v-else class="inline-flex">
<Button
variant="outline"
size="default"
type="button"
class="gap-2"
>
<Button variant="outline" size="default" type="button" class="gap-2">
{{ $page.props.auth.user.name }}
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
<ChevronDownIcon />
</Button>
</span>
</template>
@ -365,10 +308,7 @@ const closeSearch = () => (searchOpen.value = false);
</div>
<!-- Page Heading -->
<header
v-if="$slots.header"
class="bg-white border-b border-gray-200 shadow-sm"
>
<header v-if="$slots.header" class="bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
<Breadcrumbs
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"

View File

@ -4,6 +4,9 @@ import { usePage, Link, router } from "@inertiajs/vue3";
import Dropdown from "@/Components/Dropdown.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faBell } from "@fortawesome/free-solid-svg-icons";
import { BellIcon } from "lucide-vue-next";
import { Badge } from "@/Components/ui/badge";
import { Button } from "@/Components/ui/button";
const page = usePage();
const due = computed(
@ -73,7 +76,7 @@ function markRead(item) {
// Rollback on failure
items.value.splice(idx, 0, removed);
},
preserveScroll: true
preserveScroll: true,
}
);
}
@ -86,18 +89,17 @@ function markRead(item) {
:content-classes="['p-0', 'bg-white', 'max-h-96', 'overflow-hidden']"
>
<template #trigger>
<button
type="button"
class="relative inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100"
aria-label="Notifications"
>
<FontAwesomeIcon :icon="faBell" class="w-5 h-5" />
<span
<Button variant="ghost" size="default" class="relative">
<BellIcon />
<Badge
v-if="count"
class="absolute -top-1 -right-1 inline-flex items-center justify-center h-5 min-w-[1.25rem] px-1 rounded-full text-[11px] bg-red-600 text-white"
>{{ count }}</span
class="absolute -top-1 -right-1 h-5 min-w-5 inline-flex items-center justify-center rounded-full px-1 font-mono tabular-nums text-accent"
variant="destructive"
>
</button>
{{ count }}
</Badge>
</Button>
</template>
<template #content>

View File

@ -2,44 +2,87 @@
import AppLayout from "@/Layouts/AppLayout.vue";
import SectionTitle from "@/Components/SectionTitle.vue";
import { Link, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { computed, ref } from "vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faFolderOpen } from "@fortawesome/free-solid-svg-icons";
import Pagination from "@/Components/Pagination.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { FolderOpenIcon } from "lucide-vue-next";
import { Filter, FolderOpenIcon } from "lucide-vue-next";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { fmtCurrency, fmtDateDMY } from "@/Utilities/functions";
import { Button } from "@/Components/ui/button";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
import InputLabel from "@/Components/InputLabel.vue";
import { Input } from "@/Components/ui/input";
import DateRangePicker from "@/Components/DateRangePicker.vue";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
const props = defineProps({
client_cases: Object,
filters: Object,
clients: {
type: Array,
default: () => [],
},
});
// Initial search for DataTable toolbar
const search = ref(props.filters?.search || "");
const dateRange = ref({
start: props.filters?.from || null,
end: props.filters?.to || null,
});
const selectedClients = ref(
Array.isArray(props.filters?.clients)
? props.filters.clients.map((value) => String(value))
: []
);
const filterPopoverOpen = ref(false);
// Format helpers
const fmtCurrency = (v) => {
const n = Number(v ?? 0);
try {
return new Intl.NumberFormat("sl-SI", { style: "currency", currency: "EUR" }).format(
n
);
} catch (e) {
return `${n.toFixed(2)}`;
const appliedFilterCount = computed(() => {
let count = 0;
if (search.value?.trim()) count += 1;
if (dateRange.value?.start || dateRange.value?.end) count += 1;
if (selectedClients.value.length) count += 1;
return count;
});
function applyFilters() {
filterPopoverOpen.value = false;
const params = {};
const searchParams = new URLSearchParams(window.location.search);
const currentPerPage = searchParams.get("perPage");
if (currentPerPage) {
params.perPage = currentPerPage;
}
if (search.value && search.value.trim() !== "") {
params.search = search.value.trim();
}
if (dateRange.value?.start) {
params.from = dateRange.value.start;
}
if (dateRange.value?.end) {
params.to = dateRange.value.end;
}
if (selectedClients.value.length > 0) {
params.clients = selectedClients.value.join(",");
}
};
const fmtDateDMY = (v) => {
if (!v) return "-";
const d = new Date(v);
if (isNaN(d)) return "-";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
return `${dd}.${mm}.${yyyy}`;
};
router.get(route("clientCase"), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
}
function clearFilters() {
dateRange.value = { start: null, end: null };
selectedClients.value = [];
search.value = "";
applyFilters();
}
</script>
<template>
<AppLayout title="Client cases">
@ -80,13 +123,99 @@ const fmtDateDMY = (v) => {
},
]"
:data="client_cases.data || []"
:meta="client_cases"
route-name="clientCase"
page-param-name="clientCasesPage"
per-page-param-name="perPage"
:page-size="client_cases.per_page"
:page-size-options="[10, 15, 25, 50, 100]"
:show-pagination="false"
:show-toolbar="true"
:hoverable="true"
row-key="uuid"
empty-text="Ni najdenih primerov."
>
<template #toolbar-filters>
<AppPopover
v-model:open="filterPopoverOpen"
align="start"
content-class="w-[400px]"
>
<template #trigger>
<Button variant="outline" size="sm" class="gap-2">
<Filter class="h-4 w-4" />
Filtri
<span
v-if="appliedFilterCount > 0"
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
>
{{ appliedFilterCount }}
</span>
</Button>
</template>
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium text-sm">Filtri primerov</h4>
<p class="text-sm text-muted-foreground">
Izberite parametre za zožanje prikaza primerov.
</p>
</div>
<div class="space-y-3">
<div class="space-y-1.5">
<InputLabel>Iskanje</InputLabel>
<Input
v-model="search"
type="text"
placeholder="Išči po imenu, davčni številki ..."
/>
</div>
<div class="space-y-1.5">
<InputLabel>Datumski obseg (ustvarjeno)</InputLabel>
<DateRangePicker
v-model="dateRange"
format="dd.MM.yyyy"
placeholder="Izberi datume"
/>
</div>
<div class="space-y-1.5">
<InputLabel>Stranke</InputLabel>
<AppMultiSelect
v-model="selectedClients"
:items="
(props.clients || []).map((client) => ({
value: String(client.id),
label: client.name,
}))
"
placeholder="Vse stranke"
search-placeholder="Išči stranko..."
empty-text="Ni strank"
chip-variant="secondary"
/>
</div>
<div class="flex justify-end gap-2 pt-2 border-t">
<Button
type="button"
variant="outline"
size="sm"
:disabled="
!dateRange?.start &&
!dateRange?.end &&
selectedClients.length === 0 &&
search === ''
"
@click="clearFilters"
>
Počisti
</Button>
<Button type="button" size="sm" @click="applyFilters">
Uporabi
</Button>
</div>
</div>
</div>
</AppPopover>
</template>
<template #cell-nu="{ row }">
{{ row.person?.nu || "-" }}
</template>

View File

@ -364,7 +364,7 @@ const copyToClipboard = async (text) => {
</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[400px]" align="start">
<PopoverContent class="w-100" align="start">
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium text-sm">Filtri aktivnosti</h4>

View File

@ -2,6 +2,17 @@
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link } from "@inertiajs/vue3";
import { computed, ref } from "vue";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import { Search } from "lucide-vue-next";
import { fmtCurrency } from "@/Utilities/functions";
const props = defineProps({
segments: Array,
@ -26,74 +37,111 @@ const filtered = computed(() => {
});
});
function formatCurrencyEUR(value) {
if (value === null || value === undefined) {
return "-";
}
const n = Number(value);
if (isNaN(n)) {
return String(value);
}
return (
n.toLocaleString("sl-SI", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +
" €"
);
}
const totalBalance = computed(() =>
filtered.value.reduce((sum, segment) => sum + Number(segment.total_balance ?? 0), 0)
);
</script>
<template>
<AppLayout title="Segmenti">
<template #header></template>
<div class="pt-12">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-4 mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1"
<div class="py-8">
<div class="max-w-6xl mx-auto space-y-6 px-4 sm:px-6 lg:px-8">
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-2xl font-semibold tracking-tight">Segmenti</CardTitle>
<CardDescription>
Pregled vseh aktivnih segmentov in njihovih ključnih kazalnikov.
</CardDescription>
</CardHeader>
<CardContent>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="rounded-lg border bg-muted/40 p-4">
<p class="text-xs uppercase text-muted-foreground">Število segmentov</p>
<p class="text-2xl font-semibold">{{ filtered.length }}</p>
</div>
<div class="rounded-lg border bg-muted/40 p-4">
<p class="text-xs uppercase text-muted-foreground">Skupaj pogodb</p>
<p class="text-2xl font-semibold">
{{
filtered.reduce((sum, s) => sum + Number(s.contracts_count ?? 0), 0)
}}
</p>
</div>
<div class="rounded-lg border bg-muted/40 p-4">
<p class="text-xs uppercase text-muted-foreground">Skupaj stanj</p>
<p class="text-2xl font-semibold">{{ fmtCurrency(totalBalance) }}</p>
</div>
</div>
<div class="mt-6">
<label class="text-sm font-medium text-muted-foreground"
>Iskanje (segment ali opis)</label
>
<input
<div class="relative mt-2 max-w-md">
<Search
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
v-model="search"
type="text"
class="border rounded px-3 py-2 w-full max-w-xl"
placeholder="Išči po nazivu segmenta ali opisu"
class="pl-9"
/>
</div>
</div>
</CardContent>
</Card>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4">Aktivni segmenti</h2>
<Card>
<CardHeader class="pb-3">
<CardTitle>Aktivni segmenti</CardTitle>
<CardDescription
>Rezultati so razporejeni v kartice. Klik na segment vodi na
podrobnosti.</CardDescription
>
</CardHeader>
<CardContent>
<div
v-if="filtered.length"
class="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
>
<div
<Card
v-for="s in filtered"
:key="s.id"
class="border rounded-lg p-4 shadow-sm hover:shadow transition bg-white"
>
<div class="flex items-start justify-between mb-2">
<h3 class="text-base font-semibold text-gray-900">
<Link :href="route('segments.show', s.id)" class="hover:underline">{{
s.name
}}</Link>
</h3>
<span
class="inline-flex items-center text-xs px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-100"
class="border border-border/70 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md"
>
<CardHeader class="space-y-1 pb-2">
<div class="flex items-start justify-between gap-2">
<CardTitle class="text-lg font-semibold">
<Link :href="route('segments.show', s.id)" class="hover:underline">
{{ s.name }}
</Link>
</CardTitle>
<Badge variant="secondary" class="text-xs font-medium">
{{ s.contracts_count ?? 0 }} pogodb
</span>
</Badge>
</div>
<p class="text-sm text-gray-600 min-h-[1.25rem]">
{{ s.description || "" }}
</p>
<div class="mt-4 flex items-center justify-between">
<div class="text-sm text-gray-500">Vsota stanj</div>
<div class="text-sm font-medium text-gray-900">
{{ formatCurrencyEUR(s.total_balance) }}
<CardDescription class="min-h-[1.5rem]">{{
s.description || ""
}}</CardDescription>
</CardHeader>
<CardContent>
<dl class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<dt class="text-muted-foreground">Vsota stanj</dt>
<dd class="font-medium">{{ fmtCurrency(s.total_balance) }}</dd>
</div>
<div class="flex items-center justify-between">
<dt class="text-muted-foreground">Aktivne pogodbe</dt>
<dd class="font-medium">{{ s.contracts_count ?? 0 }}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
</div>
<div v-else class="text-gray-500">Ni aktivnih segmentov.</div>
</div>
<div v-else class="text-sm text-muted-foreground">Ni aktivnih segmentov.</div>
</CardContent>
</Card>
</div>
</div>
</AppLayout>

View File

@ -1,10 +1,35 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { ref, computed, watch } from "vue";
import { ref, computed } from "vue";
import axios from "axios";
import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import DialogModal from "@/Components/DialogModal.vue";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Checkbox } from "@/Components/ui/checkbox";
import { Switch } from "@/Components/ui/switch";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/Components/ui/command";
import {
Filter,
ChevronsUpDown,
Check,
FileDown,
LayoutIcon,
UsersIcon,
} from "lucide-vue-next";
import { cn } from "@/lib/utils";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { CardTitle } from "@/Components/ui/card";
const props = defineProps({
segment: Object,
@ -17,16 +42,18 @@ const urlParams = new URLSearchParams(window.location.search);
const search = ref(urlParams.get("search") || "");
const initialClient = urlParams.get("client") || urlParams.get("client_id") || "";
const selectedClient = ref(initialClient);
const filterPopoverOpen = ref(false);
const clientComboboxOpen = ref(false);
// Column definitions for the server-driven table
const columns = [
{ key: "reference", label: "Pogodba", sortable: true },
{ key: "client_case", label: "Primer" },
{ key: "client", label: "Stranka" },
{ key: "type", label: "Vrsta" },
{ key: "start_date", label: "Začetek", sortable: true },
{ key: "end_date", label: "Konec", sortable: true },
{ key: "account", label: "Stanje", align: "right" },
{ key: "reference", label: "Pogodba", sortable: false },
{ key: "client_case", label: "Primer", sortable: false },
{ key: "client", label: "Stranka", sortable: false },
{ key: "type", label: "Vrsta", sortable: false },
{ key: "start_date", label: "Začetek", sortable: false },
{ key: "end_date", label: "Konec", sortable: false },
{ key: "account", label: "Stanje", align: "right", sortable: false },
];
const exportDialogOpen = ref(false);
@ -35,6 +62,21 @@ const exportColumns = ref(columns.map((col) => col.key));
const exportError = ref("");
const isExporting = ref(false);
const hasActiveFilters = computed(() => {
return Boolean(search.value?.trim()) || Boolean(selectedClient.value);
});
const appliedFilterCount = computed(() => {
let count = 0;
if (search.value?.trim()) {
count += 1;
}
if (selectedClient.value) {
count += 1;
}
return count;
});
const contractsCurrentPage = computed(() => props.contracts?.current_page ?? 1);
const contractsPerPage = computed(() => props.contracts?.per_page ?? 15);
const totalContracts = computed(
@ -43,12 +85,28 @@ const totalContracts = computed(
const currentPageCount = computed(() => props.contracts?.data?.length ?? 0);
const allColumnsSelected = computed(() => exportColumns.value.length === columns.length);
const exportDisabled = computed(() => exportColumns.value.length === 0 || isExporting.value);
const exportDisabled = computed(
() => exportColumns.value.length === 0 || isExporting.value
);
function toggleAllColumns(checked) {
exportColumns.value = checked ? columns.map((col) => col.key) : [];
}
function handleColumnToggle(key, checked) {
if (checked) {
if (!exportColumns.value.includes(key)) {
exportColumns.value = [...exportColumns.value, key];
}
} else {
exportColumns.value = exportColumns.value.filter((col) => col !== key);
}
}
function setExportScopeFromSwitch(checked) {
exportScope.value = checked ? "all" : "current";
}
function openExportDialog() {
exportDialogOpen.value = true;
exportError.value = "";
@ -126,18 +184,68 @@ const selectedClientName = computed(() => {
return match?.label || "";
});
// React to client selection changes by visiting the same route with updated query
watch(selectedClient, (val) => {
const query = { search: search.value };
if (val) {
query.client = val;
function isClientFilterEmpty() {
return !selectedClient.value;
}
function selectClient(value) {
selectedClient.value = value ?? "";
}
function closeClientCombobox() {
clientComboboxOpen.value = false;
}
function isClientValueSelected(value) {
return selectedClient.value === value;
}
function handleClientSelect(value) {
selectClient(value);
closeClientCombobox();
}
function buildQueryParams() {
const params = {};
const searchParams = new URLSearchParams(window.location.search);
for (const [key, value] of searchParams.entries()) {
params[key] = value;
}
const trimmedSearch = search.value.trim();
if (trimmedSearch) {
params.search = trimmedSearch;
} else {
delete params.search;
}
if (selectedClient.value) {
params.client = selectedClient.value;
} else {
delete params.client;
}
params.page = 1;
return params;
}
function applyFilters() {
const query = buildQueryParams();
router.get(
route("segments.show", { segment: props.segment?.id ?? props.segment }),
query,
{ preserveState: true, preserveScroll: true, only: ["contracts"], replace: true }
);
});
filterPopoverOpen.value = false;
}
function clearFilters() {
search.value = "";
selectedClient.value = "";
applyFilters();
}
function formatDate(value) {
if (!value) {
@ -207,64 +315,191 @@ function extractFilenameFromHeaders(headers) {
<template>
<AppLayout :title="`Segment: ${segment?.name || ''}`">
<template #header> </template>
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-4">
<h2 class="text-lg">{{ segment.name }}</h2>
<div class="text-sm text-gray-600 mb-4">{{ segment?.description }}</div>
<div class="py-6">
<div class="max-w-7xl mx-auto space-y-6 px-4 sm:px-6 lg:px-8">
<AppCard
title=""
padding="none"
class="p-5! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<div>
<p class="text-sm uppercase text-muted-foreground">Segment</p>
<h2 class="text-2xl font-semibold text-foreground">{{ segment.name }}</h2>
</div>
<p class="text-sm text-muted-foreground">
{{ segment?.description || "Ni opisa za izbran segment." }}
</p>
<div class="mt-4 grid gap-4 sm:grid-cols-3">
<div class="rounded-lg border bg-muted/40 p-4">
<p class="text-xs uppercase text-muted-foreground">Pogodbe (stran)</p>
<p class="text-2xl font-semibold">{{ currentPageCount }}</p>
</div>
<div class="rounded-lg border bg-muted/40 p-4">
<p class="text-xs uppercase text-muted-foreground">Skupaj pogodb</p>
<p class="text-2xl font-semibold">{{ totalContracts }}</p>
</div>
<div class="rounded-lg border bg-muted/40 p-4">
<p class="text-xs uppercase text-muted-foreground">Izbrana stranka</p>
<p class="text-base font-medium">
{{ selectedClientName || "Vse" }}
</p>
</div>
</div>
</AppCard>
<!-- Filters -->
<div class="mb-4 flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">Stranka</label>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<select
v-model="selectedClient"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
>
<option value="">Vse stranke</option>
<option
v-for="opt in clientOptions"
:key="opt.value || opt.label"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
<button
type="button"
class="text-sm text-gray-600 hover:text-gray-900"
@click="selectedClient = ''"
v-if="selectedClient"
>
Počisti
</button>
<UsersIcon size="18" />
<CardTitle class="uppercase">Pogodbe</CardTitle>
</div>
</div>
</div>
<DataTableServer
</template>
<DataTable
:columns="columns"
:rows="contracts?.data || []"
:data="contracts?.data || []"
:meta="contracts || {}"
v-model:search="search"
route-name="segments.show"
:route-params="{ segment: segment?.id ?? segment }"
:query="{ client: selectedClient || undefined }"
:only-props="['contracts']"
:page-size-options="[10, 25, 50]"
empty-text="Ni pogodb v tem segmentu."
:page-size="contracts?.per_page ?? 15"
:page-size-options="[10, 15, 25, 50]"
row-key="uuid"
empty-text="Ni pogodb v tem segmentu."
per-page-param-name="per_page"
>
<template #toolbar-extra>
<button
type="button"
class="inline-flex items-center rounded-md border border-indigo-200 bg-white px-3 py-2 text-sm font-medium text-indigo-700 shadow-sm hover:bg-indigo-50"
<template #toolbar-filters>
<div class="flex flex-wrap items-center gap-2">
<Popover v-model:open="filterPopoverOpen">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="gap-2">
<Filter class="h-4 w-4" />
Filtri
<span
v-if="hasActiveFilters"
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
>
{{ appliedFilterCount }}
</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-105" align="start">
<div class="space-y-4">
<div class="space-y-1">
<h4 class="text-sm font-medium">Filtri pogodb</h4>
<p class="text-sm text-muted-foreground">
Zoži prikaz pogodb po iskalnem nizu in stranki.
</p>
</div>
<div class="space-y-3">
<div class="space-y-1.5">
<label class="text-sm font-medium">Iskanje</label>
<Input
v-model="search"
type="text"
placeholder="Išči po referenci, vrsti ..."
/>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium">Stranka</label>
<Popover v-model:open="clientComboboxOpen">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="clientComboboxOpen"
class="w-full justify-between"
>
{{ selectedClientName || "Vse stranke" }}
<ChevronsUpDown
class="ml-2 h-4 w-4 shrink-0 opacity-50"
/>
</Button>
</PopoverTrigger>
<PopoverContent class="w-full p-0" align="start">
<Command>
<CommandInput placeholder="Išči stranko..." />
<CommandList>
<CommandEmpty>Ni zadetkov.</CommandEmpty>
<CommandGroup>
<CommandItem
value="vse"
@select="handleClientSelect('')"
>
<Check
:class="
cn(
'mr-2 h-4 w-4',
isClientFilterEmpty
? 'opacity-100'
: 'opacity-0'
)
"
/>
Vse stranke
</CommandItem>
<CommandItem
v-for="client in clientOptions"
:key="client.value || client.label"
:value="client.label"
@select="handleClientSelect(client.value)"
>
<Check
:class="
cn(
'mr-2 h-4 w-4',
isClientValueSelected(client.value)
? 'opacity-100'
: 'opacity-0'
)
"
/>
{{ client.label }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<div class="flex items-center justify-between border-t pt-2">
<Button
v-if="hasActiveFilters"
variant="ghost"
size="sm"
class="gap-2"
@click="clearFilters"
>
Počisti
</Button>
<div v-else></div>
<Button size="sm" class="gap-2" @click="applyFilters">
Uporabi
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="sm"
class="gap-2"
@click="openExportDialog"
>
<FileDown class="h-4 w-4" />
Izvozi v Excel
</button>
</Button>
</div>
</template>
<!-- Primer (client_case) cell with link when available -->
<template #cell-client_case="{ row }">
<Link
v-if="row.client_case?.uuid"
@ -280,120 +515,131 @@ function extractFilenameFromHeaders(headers) {
</Link>
<span v-else>{{ row.client_case?.person?.full_name || "-" }}</span>
</template>
<!-- Stranka (client) name -->
<template #cell-client="{ row }">
{{ row.client?.person?.full_name || "-" }}
</template>
<!-- Vrsta (type) -->
<template #cell-type="{ row }">
{{ row.type?.name || "-" }}
</template>
<!-- Dates formatted -->
<template #cell-start_date="{ row }">
{{ formatDate(row.start_date) }}
</template>
<template #cell-end_date="{ row }">
{{ formatDate(row.end_date) }}
</template>
<!-- Account balance formatted -->
<template #cell-account="{ row }">
<div class="text-right">
{{ formatCurrency(row.account?.balance_amount) }}
</div>
</template>
</DataTableServer>
</DataTable>
</AppCard>
</div>
</div>
<DialogModal :show="exportDialogOpen" max-width="3xl" @close="closeExportDialog">
<template #title>
<div>
<h3 class="text-lg font-semibold">Izvoz v Excel</h3>
<p class="text-sm text-gray-500">Izberi stolpce in obseg podatkov za izvoz.</p>
<div class="space-y-1">
<h3 class="text-lg font-semibold leading-6 text-foreground">Izvoz v Excel</h3>
<p class="text-sm text-muted-foreground">
Izberi stolpce in obseg podatkov za izvoz.
</p>
</div>
</template>
<template #content>
<form id="segment-export-form" class="space-y-6" @submit.prevent="submitExport">
<div>
<span class="text-sm font-semibold text-gray-700">Obseg podatkov</span>
<div class="mt-2 space-y-2">
<label class="flex items-center gap-2 text-sm text-gray-700">
<input
type="radio"
name="scope"
value="current"
class="text-indigo-600"
v-model="exportScope"
<form id="segment-export-form" class="space-y-5" @submit.prevent="submitExport">
<div class="space-y-3 rounded-lg border bg-muted/40 p-4">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-sm font-medium text-foreground">Obseg podatkov</p>
<p class="text-sm text-muted-foreground">
Preklopi, ali izvoziš samo trenutni pogled ali celoten segment.
</p>
</div>
<div
class="flex items-center gap-3 rounded-md bg-background px-3 py-2 shadow-sm"
>
<span class="text-xs font-medium text-muted-foreground">Stran</span>
<Switch
:model-value="exportScope === 'all'"
@update:modelValue="setExportScopeFromSwitch"
aria-label="Preklopi obseg izvoza"
/>
Trenutna stran ({{ currentPageCount }} zapisov)
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input
type="radio"
name="scope"
value="all"
class="text-indigo-600"
v-model="exportScope"
/>
Celoten segment ({{ totalContracts }} zapisov)
</label>
<span class="text-xs font-medium text-muted-foreground">Vse</span>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<div class="rounded-lg border bg-background p-3 shadow-sm">
<p class="text-sm font-semibold text-foreground">Trenutna stran</p>
<p class="text-xs text-muted-foreground">
{{ currentPageCount }} zapisov
</p>
</div>
<div class="rounded-lg border bg-background p-3 shadow-sm">
<p class="text-sm font-semibold text-foreground">Celoten segment</p>
<p class="text-xs text-muted-foreground">{{ totalContracts }} zapisov</p>
</div>
</div>
</div>
<div>
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700">Stolpci</span>
<label class="flex items-center gap-2 text-xs text-gray-600">
<input
type="checkbox"
:checked="allColumnsSelected"
@change="toggleAllColumns($event.target.checked)"
/>
Označi vse
</label>
<div class="space-y-4 rounded-lg border bg-muted/40 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-sm font-medium text-foreground">Stolpci</p>
<p class="text-sm text-muted-foreground">
Izberi, katere stolpce želiš vključiti v izvoz.
</p>
</div>
<div class="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<div class="flex items-center gap-2">
<Checkbox
id="export-columns-all"
:model-value="allColumnsSelected"
@update:modelValue="toggleAllColumns"
aria-label="Označi vse stolpce"
/>
<Label for="export-columns-all" class="text-sm text-muted-foreground">
Označi vse
</Label>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<label
v-for="col in columns"
:key="col.key"
class="flex items-center gap-2 rounded border border-gray-200 px-3 py-2 text-sm"
class="flex items-start gap-3 rounded-lg border bg-background px-3 py-3 text-sm shadow-sm transition hover:border-primary/40"
:for="`export-col-${col.key}`"
>
<input
type="checkbox"
name="columns[]"
<Checkbox
:id="`export-col-${col.key}`"
:model-value="exportColumns.includes(col.key)"
:value="col.key"
v-model="exportColumns"
class="text-indigo-600"
@update:modelValue="(checked) => handleColumnToggle(col.key, checked)"
class="mt-0.5"
/>
{{ col.label }}
<div class="space-y-0.5">
<p class="font-medium text-foreground">{{ col.label }}</p>
<p class="text-xs text-muted-foreground">Vključi stolpec v datoteko.</p>
</div>
</label>
</div>
<p v-if="exportError" class="mt-2 text-sm text-red-600">{{ exportError }}</p>
<p v-if="exportError" class="text-sm text-destructive">{{ exportError }}</p>
</div>
</form>
</template>
<template #footer>
<div class="flex flex-row gap-2">
<button
type="button"
class="text-sm text-gray-600 hover:text-gray-900"
@click="closeExportDialog"
>
<Button type="button" variant="ghost" @click="closeExportDialog">
Prekliči
</button>
<button
</Button>
<Button
type="submit"
form="segment-export-form"
class="inline-flex items-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="exportDisabled"
class="gap-2"
>
<span v-if="!isExporting">Prenesi Excel</span>
<span v-else>Pripravljam ...</span>
</button>
</Button>
</div>
</template>
</DialogModal>

View File

@ -17,3 +17,24 @@ export function fmtDateTime(d) {
return String(d);
}
};
export function fmtCurrency(value) {
const n = Number(value ?? 0);
try {
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(
n
);
} catch (e) {
return `${n.toFixed(2)}`;
}
}
export function fmtDateDMY(value) {
if (!value) return "-";
const d = new Date(value);
if (isNaN(d)) return "-";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
return `${dd}.${mm}.${yyyy}`;
}

View File

@ -0,0 +1,109 @@
<?php
namespace Tests\Feature;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ClientCaseIndexFilterTest extends TestCase
{
use RefreshDatabase;
public function test_filters_cases_by_client_and_date(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$clientInclude = Client::factory()->create(['active' => true]);
$clientExclude = Client::factory()->create(['active' => true]);
$recentCase = ClientCase::factory()->create([
'client_id' => $clientInclude->id,
]);
$recentCase->forceFill([
'created_at' => now()->subDays(2),
'updated_at' => now()->subDays(2),
])->save();
$otherClientCase = ClientCase::factory()->create([
'client_id' => $clientExclude->id,
]);
$otherClientCase->forceFill([
'created_at' => now()->subDays(2),
'updated_at' => now()->subDays(2),
])->save();
$outOfRangeCase = ClientCase::factory()->create([
'client_id' => $clientInclude->id,
]);
$outOfRangeCase->forceFill([
'created_at' => now()->subDays(15),
'updated_at' => now()->subDays(15),
])->save();
$response = $this->get(route('clientCase', [
'clients' => (string) $clientInclude->id,
'from' => now()->subDays(5)->toDateString(),
'to' => now()->toDateString(),
]));
$response->assertInertia(function (Assert $page) use ($recentCase, $otherClientCase, $outOfRangeCase, $clientInclude) {
$page->component('Cases/Index');
$payload = $page->toArray()['props'];
$uuids = collect($payload['client_cases']['data'])->pluck('uuid')->all();
$this->assertContains($recentCase->uuid, $uuids);
$this->assertNotContains($otherClientCase->uuid, $uuids);
$this->assertNotContains($outOfRangeCase->uuid, $uuids);
$this->assertEquals([(string) $clientInclude->id], $payload['filters']['clients']);
});
}
public function test_filters_cases_by_search_term(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$targetCase = ClientCase::factory()->create();
$targetCase->person()->update(['full_name' => 'Special Target']);
$otherCase = ClientCase::factory()->create();
$otherCase->person()->update(['full_name' => 'Another Person']);
$response = $this->get(route('clientCase', ['search' => 'Target']));
$response->assertInertia(function (Assert $page) use ($targetCase, $otherCase) {
$page->component('Cases/Index');
$uuids = collect($page->toArray()['props']['client_cases']['data'])->pluck('uuid')->all();
$this->assertContains($targetCase->uuid, $uuids);
$this->assertNotContains($otherCase->uuid, $uuids);
});
}
public function test_respects_custom_per_page_selection(): void
{
$user = User::factory()->create();
$this->actingAs($user);
ClientCase::factory()->count(30)->create();
$response = $this->get(route('clientCase', ['perPage' => 10]));
$response->assertInertia(function (Assert $page) {
$page->component('Cases/Index');
$payload = $page->toArray()['props'];
$this->assertCount(10, $payload['client_cases']['data']);
$this->assertSame(10, $payload['client_cases']['per_page']);
$this->assertSame(10, $payload['filters']['perPage']);
});
}
}