From 3b284fa4bdd788a9fade9f80a326d6165ffef428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Thu, 20 Nov 2025 18:11:43 +0100 Subject: [PATCH] Changes to UI and other stuff --- .github/copilot-instructions.md | 36 +- app/Helpers/LZStringHelper.php | 224 ++++ app/Http/Controllers/ClientCaseContoller.php | 181 +-- app/Http/Resources/ActivityCollection.php | 26 + app/Http/Resources/ContractCollection.php | 19 + app/Http/Resources/DocumentCollection.php | 21 + app/Models/Activity.php | 65 ++ app/Models/Contract.php | 18 + app/Services/ClientCaseDataService.php | 181 +++ composer.lock | 2 +- package-lock.json | 33 + package.json | 1 + .../js/Components/DataTable/DataTable.vue | 1031 +++++++---------- .../DataTable/DataTableColumnHeader.vue | 72 ++ .../js/Components/DataTable/DataTableNew.vue | 703 +++++++++++ .../js/Components/DataTable/DataTableNew2.vue | 601 ++++++++++ .../js/Components/DataTable/DataTableOld.vue | 884 ++++++++++++++ .../DataTable/DataTablePagination.vue | 95 ++ .../Components/DataTable/DataTableToolbar.vue | 438 +++---- .../DataTable/DataTableToolbarExample.vue | 86 ++ .../DataTable/DataTableViewOptions.vue | 50 + .../js/Components/DataTable/MIGRATION.md | 291 +++++ resources/js/Components/DataTable/README.md | 390 +++++++ .../js/Components/DataTable/TableActions.vue | 54 +- .../Components/DataTable/columns-example.js | 267 +++++ .../DocumentsTable/DocumentsTable.vue | 160 +-- resources/js/Components/Pagination.vue | 388 +++---- .../PersonInfo/PersonInfoAddressesTab.vue | 86 +- .../PersonInfo/PersonInfoEmailsTab.vue | 90 +- .../Components/PersonInfo/PersonInfoGrid.vue | 37 +- .../PersonInfo/PersonInfoPersonTab.vue | 3 +- .../PersonInfo/PersonInfoPhonesTab.vue | 105 +- .../PersonInfo/PersonInfoSmsDialog.vue | 21 +- .../PersonInfo/PersonInfoTrrTab.vue | 88 +- resources/js/Components/ui/button/index.js | 9 +- resources/js/Components/ui/card/Card.vue | 17 + .../js/Components/ui/card/CardContent.vue | 13 + .../js/Components/ui/card/CardDescription.vue | 13 + .../js/Components/ui/card/CardFooter.vue | 13 + .../js/Components/ui/card/CardHeader.vue | 13 + resources/js/Components/ui/card/CardTitle.vue | 13 + resources/js/Components/ui/card/index.js | 6 + .../js/Components/ui/command/Command.vue | 109 ++ .../Components/ui/command/CommandDialog.vue | 26 + .../js/Components/ui/command/CommandEmpty.vue | 30 + .../js/Components/ui/command/CommandGroup.vue | 53 + .../js/Components/ui/command/CommandInput.vue | 43 + .../js/Components/ui/command/CommandItem.vue | 86 ++ .../js/Components/ui/command/CommandList.vue | 26 + .../ui/command/CommandSeparator.vue | 24 + .../Components/ui/command/CommandShortcut.vue | 17 + resources/js/Components/ui/command/index.js | 16 + resources/js/Components/ui/dialog/Dialog.vue | 4 +- .../js/Components/ui/dialog/DialogOverlay.vue | 29 + .../js/Components/ui/dialog/DialogTrigger.vue | 2 +- .../ui/dropdown-menu/DropdownMenuItem.vue | 2 +- .../ui/dropdown-menu/DropdownMenuTrigger.vue | 2 +- .../Components/ui/input-group/InputGroup.vue | 33 + .../ui/input-group/InputGroupAddon.vue | 32 + resources/js/Components/ui/input/Input.vue | 5 +- .../Components/ui/pagination/Pagination.vue | 33 + .../ui/pagination/PaginationContent.vue | 24 + .../ui/pagination/PaginationEllipsis.vue | 27 + .../ui/pagination/PaginationFirst.vue | 36 + .../ui/pagination/PaginationItem.vue | 35 + .../ui/pagination/PaginationLast.vue | 36 + .../ui/pagination/PaginationNext.vue | 36 + .../ui/pagination/PaginationPrevious.vue | 36 + .../js/Components/ui/pagination/index.js | 8 + .../js/Components/ui/separator/Separator.vue | 4 +- .../js/Components/ui/tabs/TabsTrigger.vue | 2 +- .../js/Components/ui/textarea/Textarea.vue | 3 +- .../js/Components/ui/tooltip/Tooltip.vue | 22 + .../Components/ui/tooltip/TooltipContent.vue | 51 + .../Components/ui/tooltip/TooltipProvider.vue | 18 + .../Components/ui/tooltip/TooltipTrigger.vue | 15 + resources/js/Components/ui/tooltip/index.js | 4 + resources/js/Pages/Cases/Index.vue | 44 +- .../js/Pages/Cases/Partials/ActivityTable.vue | 792 ++++++++++--- .../js/Pages/Cases/Partials/ContractTable.vue | 572 +++++---- resources/js/Pages/Cases/Show.vue | 202 ++-- resources/js/Pages/Client/Index.vue | 395 +++---- .../js/Pages/Examples/DataTableExample.vue | 219 ++++ resources/js/Pages/Testing/Index.vue | 45 +- resources/js/Utilities/functions.js | 19 + resources/js/lib/utils.js | 12 + tests/Feature/Feature/ClientCaseShowTest.php | 129 +++ 87 files changed, 7872 insertions(+), 2330 deletions(-) create mode 100644 app/Helpers/LZStringHelper.php create mode 100644 app/Http/Resources/ActivityCollection.php create mode 100644 app/Http/Resources/ContractCollection.php create mode 100644 app/Http/Resources/DocumentCollection.php create mode 100644 app/Services/ClientCaseDataService.php create mode 100644 resources/js/Components/DataTable/DataTableColumnHeader.vue create mode 100644 resources/js/Components/DataTable/DataTableNew.vue create mode 100644 resources/js/Components/DataTable/DataTableNew2.vue create mode 100644 resources/js/Components/DataTable/DataTableOld.vue create mode 100644 resources/js/Components/DataTable/DataTablePagination.vue create mode 100644 resources/js/Components/DataTable/DataTableToolbarExample.vue create mode 100644 resources/js/Components/DataTable/DataTableViewOptions.vue create mode 100644 resources/js/Components/DataTable/MIGRATION.md create mode 100644 resources/js/Components/DataTable/README.md create mode 100644 resources/js/Components/DataTable/columns-example.js create mode 100644 resources/js/Components/ui/card/Card.vue create mode 100644 resources/js/Components/ui/card/CardContent.vue create mode 100644 resources/js/Components/ui/card/CardDescription.vue create mode 100644 resources/js/Components/ui/card/CardFooter.vue create mode 100644 resources/js/Components/ui/card/CardHeader.vue create mode 100644 resources/js/Components/ui/card/CardTitle.vue create mode 100644 resources/js/Components/ui/card/index.js create mode 100644 resources/js/Components/ui/command/Command.vue create mode 100644 resources/js/Components/ui/command/CommandDialog.vue create mode 100644 resources/js/Components/ui/command/CommandEmpty.vue create mode 100644 resources/js/Components/ui/command/CommandGroup.vue create mode 100644 resources/js/Components/ui/command/CommandInput.vue create mode 100644 resources/js/Components/ui/command/CommandItem.vue create mode 100644 resources/js/Components/ui/command/CommandList.vue create mode 100644 resources/js/Components/ui/command/CommandSeparator.vue create mode 100644 resources/js/Components/ui/command/CommandShortcut.vue create mode 100644 resources/js/Components/ui/command/index.js create mode 100644 resources/js/Components/ui/dialog/DialogOverlay.vue create mode 100644 resources/js/Components/ui/input-group/InputGroup.vue create mode 100644 resources/js/Components/ui/input-group/InputGroupAddon.vue create mode 100644 resources/js/Components/ui/pagination/Pagination.vue create mode 100644 resources/js/Components/ui/pagination/PaginationContent.vue create mode 100644 resources/js/Components/ui/pagination/PaginationEllipsis.vue create mode 100644 resources/js/Components/ui/pagination/PaginationFirst.vue create mode 100644 resources/js/Components/ui/pagination/PaginationItem.vue create mode 100644 resources/js/Components/ui/pagination/PaginationLast.vue create mode 100644 resources/js/Components/ui/pagination/PaginationNext.vue create mode 100644 resources/js/Components/ui/pagination/PaginationPrevious.vue create mode 100644 resources/js/Components/ui/pagination/index.js create mode 100644 resources/js/Components/ui/tooltip/Tooltip.vue create mode 100644 resources/js/Components/ui/tooltip/TooltipContent.vue create mode 100644 resources/js/Components/ui/tooltip/TooltipProvider.vue create mode 100644 resources/js/Components/ui/tooltip/TooltipTrigger.vue create mode 100644 resources/js/Components/ui/tooltip/index.js create mode 100644 resources/js/Pages/Examples/DataTableExample.vue create mode 100644 resources/js/Utilities/functions.js create mode 100644 tests/Feature/Feature/ClientCaseShowTest.php diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 30545f3..b523e3b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -22,7 +22,7 @@ ## Foundational Context - pestphp/pest (PEST) - v3 - phpunit/phpunit (PHPUNIT) - v11 - @inertiajs/vue3 (INERTIA) - v2 -- tailwindcss (TAILWINDCSS) - v3 +- tailwindcss (TAILWINDCSS) - v4 - vue (VUE) - v3 @@ -359,11 +359,39 @@ ### Dark Mode - If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. -=== tailwindcss/v3 rules === +=== tailwindcss/v4 rules === -## Tailwind 3 +## Tailwind 4 -- Always use Tailwind CSS v3 - verify you're using only classes supported by this version. +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + - @tailwind base; + - @tailwind components; + - @tailwind utilities; + + @import "tailwindcss"; + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | === tests rules === diff --git a/app/Helpers/LZStringHelper.php b/app/Helpers/LZStringHelper.php new file mode 100644 index 0000000..186efa3 --- /dev/null +++ b/app/Helpers/LZStringHelper.php @@ -0,0 +1,224 @@ + $getNextValue(0), 'position' => $resetValue, 'index' => 1]; + + for ($i = 0; $i < 3; $i++) { + $dictionary[$i] = chr($i); + } + + $bits = 0; + $maxpower = pow(2, 2); + $power = 1; + + while ($power != $maxpower) { + $resb = $data['val'] & $data['position']; + $data['position'] >>= 1; + + if ($data['position'] == 0) { + $data['position'] = $resetValue; + $data['val'] = $getNextValue($data['index']++); + } + + $bits |= ($resb > 0 ? 1 : 0) * $power; + $power <<= 1; + } + + $next = $bits; + + switch ($next) { + case 0: + $bits = 0; + $maxpower = pow(2, 8); + $power = 1; + + while ($power != $maxpower) { + $resb = $data['val'] & $data['position']; + $data['position'] >>= 1; + + if ($data['position'] == 0) { + $data['position'] = $resetValue; + $data['val'] = $getNextValue($data['index']++); + } + + $bits |= ($resb > 0 ? 1 : 0) * $power; + $power <<= 1; + } + + $c = chr($bits); + break; + + case 1: + $bits = 0; + $maxpower = pow(2, 16); + $power = 1; + + while ($power != $maxpower) { + $resb = $data['val'] & $data['position']; + $data['position'] >>= 1; + + if ($data['position'] == 0) { + $data['position'] = $resetValue; + $data['val'] = $getNextValue($data['index']++); + } + + $bits |= ($resb > 0 ? 1 : 0) * $power; + $power <<= 1; + } + + $c = chr($bits); + break; + + case 2: + return ''; + } + + $dictionary[$dictSize++] = $c; + $w = $c; + $result[] = $c; + + while (true) { + if ($data['index'] > $length) { + return ''; + } + + $bits = 0; + $maxpower = pow(2, $numBits); + $power = 1; + + while ($power != $maxpower) { + $resb = $data['val'] & $data['position']; + $data['position'] >>= 1; + + if ($data['position'] == 0) { + $data['position'] = $resetValue; + $data['val'] = $getNextValue($data['index']++); + } + + $bits |= ($resb > 0 ? 1 : 0) * $power; + $power <<= 1; + } + + $c = $bits; + + switch ($c) { + case 0: + $bits = 0; + $maxpower = pow(2, 8); + $power = 1; + + while ($power != $maxpower) { + $resb = $data['val'] & $data['position']; + $data['position'] >>= 1; + + if ($data['position'] == 0) { + $data['position'] = $resetValue; + $data['val'] = $getNextValue($data['index']++); + } + + $bits |= ($resb > 0 ? 1 : 0) * $power; + $power <<= 1; + } + + $dictionary[$dictSize++] = chr($bits); + $c = $dictSize - 1; + $enlargeIn--; + break; + + case 1: + $bits = 0; + $maxpower = pow(2, 16); + $power = 1; + + while ($power != $maxpower) { + $resb = $data['val'] & $data['position']; + $data['position'] >>= 1; + + if ($data['position'] == 0) { + $data['position'] = $resetValue; + $data['val'] = $getNextValue($data['index']++); + } + + $bits |= ($resb > 0 ? 1 : 0) * $power; + $power <<= 1; + } + + $dictionary[$dictSize++] = chr($bits); + $c = $dictSize - 1; + $enlargeIn--; + break; + + case 2: + return implode('', $result); + } + + if ($enlargeIn == 0) { + $enlargeIn = pow(2, $numBits); + $numBits++; + } + + if (isset($dictionary[$c])) { + $entry = $dictionary[$c]; + } else { + if ($c === $dictSize) { + $entry = $w.$w[0]; + } else { + return null; + } + } + + $result[] = $entry; + + $dictionary[$dictSize++] = $w.$entry[0]; + $enlargeIn--; + + $w = $entry; + + if ($enlargeIn == 0) { + $enlargeIn = pow(2, $numBits); + $numBits++; + } + } + } +} diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index 327e8a1..bb1a6e1 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -20,7 +20,8 @@ class ClientCaseContoller extends Controller { public function __construct( protected ReferenceDataCache $referenceCache, - protected DocumentStreamService $documentStream + protected DocumentStreamService $documentStream, + protected \App\Services\ClientCaseDataService $caseDataService ) {} /** @@ -29,7 +30,7 @@ public function __construct( public function index(ClientCase $clientCase, Request $request) { $search = $request->input('search'); - + $query = $clientCase::query() ->select('client_cases.*') ->when($search, function ($que) use ($search) { @@ -60,7 +61,7 @@ public function index(ClientCase $clientCase, Request $request) return Inertia::render('Cases/Index', [ 'client_cases' => $query - ->paginate($request->integer('perPage', 15), ['*'], 'client-cases-page') + ->paginate($request->integer('perPage', 15), ['*'], 'clientCasesPage') ->withQueryString(), 'filters' => $request->only(['search']), ]); @@ -348,7 +349,7 @@ public function deleteActivity(ClientCase $clientCase, \App\Models\Activity $act }); return back()->with('success', 'Activity deleted.'); - } + } public function deleteContract(ClientCase $clientCase, string $uuid, Request $request) { @@ -680,137 +681,36 @@ public function show(ClientCase $clientCase) 'phone_types' => $this->referenceCache->getPhoneTypes(), ]; - // $active = false; - - // Optional segment filter from query string $segmentId = request()->integer('segment'); - - // Determine latest archive (non-reactivate) setting for this context to infer archive segment and related tables - $latestArchiveSetting = \App\Models\ArchiveSetting::query() - ->where('enabled', true) - ->where(function ($q) { - $q->whereNull('reactivate')->orWhere('reactivate', false); - }) - ->orderByDesc('id') - ->first(); - $archiveSegmentId = optional($latestArchiveSetting)->segment_id; // may be null - $relatedArchiveTables = []; - if ($latestArchiveSetting) { - $entities = (array) $latestArchiveSetting->entities; - foreach ($entities as $edef) { - if (isset($edef['related']) && is_array($edef['related'])) { - foreach ($edef['related'] as $rel) { - $relatedArchiveTables[] = $rel; - } - } - } - $relatedArchiveTables = array_values(array_unique($relatedArchiveTables)); - } - - // Prepare contracts and a reference map. - // Only apply active/inactive filtering IF a segment filter is provided. - $contractsQuery = $case->contracts() - // Only select lean columns to avoid oversize JSON / headers (include description for UI display) - ->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at']) - ->with([ - 'type:id,name', - // Use closure for account to avoid ambiguous column names with latestOfMany join - 'account' => function ($q) { - $q->select([ - 'accounts.id', - 'accounts.contract_id', - 'accounts.type_id', - 'accounts.initial_amount', - 'accounts.balance_amount', - 'accounts.promise_date', - 'accounts.created_at', - 'accounts.updated_at', // include updated_at so FE can detect changes & for debugging - ])->orderByDesc('accounts.id'); - }, - 'segments:id,name', - // Eager load objects so newly created objects appear without full reload logic issues - 'objects:id,contract_id,reference,name,description,type,created_at', - ]); - - $contractsQuery->orderByDesc('created_at'); - - if (! empty($segmentId)) { - // Filter to contracts that are in the provided segment and active on pivot - $contractsQuery->whereExists(function ($q) use ($segmentId) { - $q->from('contract_segment') - ->whereColumn('contract_segment.contract_id', 'contracts.id') - ->where('contract_segment.segment_id', $segmentId) - ->where('contract_segment.active', true); - }); - } - - // Use pagination for contracts to avoid loading too many at once - // Default to 50 per page, but allow frontend to request more - $perPage = request()->integer('contracts_per_page', 50); - $contracts = $contractsQuery->paginate($perPage, ['*'], 'contracts_page')->withQueryString(); - - // Prepare contract reference map from paginated contracts - $contractItems = $contracts instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator - ? $contracts->items() - : $contracts->all(); - - - $contractRefMap = []; - $contractUuidMap = []; - foreach ($contractItems as $c) { - $contractRefMap[$c->id] = $c->reference; - $contractUuidMap[$c->id] = $c->uuid; - } - - $contractIds = collect($contractItems)->pluck('id'); - - // Resolve current segment for display when filtered $currentSegment = null; if (! empty($segmentId)) { $currentSegment = \App\Models\Segment::query()->select('id', 'name')->find($segmentId); } - // Load initial batch of documents (limit to reduce payload size) - $contractDocs = collect(); - if ($contractIds->isNotEmpty()) { - $contractDocs = Document::query() - ->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public']) - ->where('documentable_type', Contract::class) - ->whereIn('documentable_id', $contractIds->all()) - ->orderByDesc('created_at') - ->limit(50) // Initial batch - frontend can request more via separate endpoint if needed - ->get() - ->map(function ($d) use ($contractRefMap, $contractUuidMap) { - $arr = $d->toArray(); - $arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null; - $arr['contract_uuid'] = $contractUuidMap[$d->documentable_id] ?? null; - return $arr; - }); - } + // Get contracts using service + $contractsPerPage = request()->integer('contracts_per_page', 10); + $contracts = $this->caseDataService->getContracts($case, $segmentId, $contractsPerPage); + $contractIds = collect($contracts->items())->pluck('id')->all(); - $caseDocs = $case->documents() - ->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public']) - ->orderByDesc('created_at') - ->limit(50) // Initial batch - ->get() - ->map(function ($d) use ($case) { - $arr = $d->toArray(); - $arr['client_case_uuid'] = $case->uuid; - return $arr; - }); - - $mergedDocs = $caseDocs - ->concat($contractDocs) - ->sortByDesc('created_at') - ->values(); + // Get activities using service + $activitiesPerPage = request()->integer('activities_per_page', 15); + $encodedFilters = request()->input('filter_activities'); + $activities = $this->caseDataService->getActivities($case, $segmentId, $encodedFilters, $contractIds, $activitiesPerPage); + + // Get documents using service + $contractsPerPage = request()->integer('documentsPerPage', 15); + $documents = $this->caseDataService->getDocuments($case, $contractIds, $contractsPerPage); + + // Get archive metadata using service + + $archiveMeta = $this->caseDataService->getArchiveMeta(); return Inertia::render('Cases/Show', [ 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(), 'client_case' => $case, - 'contracts' => $contracts, // Now paginated - 'documents' => $mergedDocs, + 'contracts' => $contracts, + 'documents' => $documents, ])->with([ - // Active document templates for contracts (latest version per slug) 'contract_doc_templates' => \App\Models\DocumentTemplate::query() ->where('active', true) ->where('core_entity', 'contract') @@ -819,38 +719,10 @@ public function show(ClientCase $clientCase) ->groupBy('slug') ->map(fn ($g) => $g->sortByDesc('version')->first()) ->values(), - 'archive_meta' => [ - 'archive_segment_id' => $archiveSegmentId, - 'related_tables' => $relatedArchiveTables, - ], - 'activities' => tap( - (function () use ($case, $segmentId, $contractIds) { - $q = $case->activities() - ->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name']) - ->orderByDesc('created_at'); - if (! empty($segmentId)) { - // Only activities for filtered contracts or unlinked (contract_id null) - $q->where(function ($qq) use ($contractIds) { - $qq->whereNull('contract_id'); - if ($contractIds->isNotEmpty()) { - $qq->orWhereIn('contract_id', $contractIds); - } - }); - } - - return $q->paginate(20, ['*'], 'activities')->withQueryString(); - })(), - function ($p) { - $p->getCollection()->transform(function ($a) { - $a->setAttribute('user_name', optional($a->user)->name); - - return $a; - }); - } - ), + 'archive_meta' => $archiveMeta, + 'activities' => $activities, 'contract_types' => $this->referenceCache->getContractTypes(), 'account_types' => $this->referenceCache->getAccountTypes(), - // Include decisions with auto-mail metadata and the linked email template entity_types for UI logic 'actions' => \App\Models\Action::query() ->with([ 'decisions' => function ($q) { @@ -865,7 +737,6 @@ function ($p) { 'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']), 'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']), 'current_segment' => $currentSegment, - // SMS helpers for per-case sending UI 'sms_profiles' => \App\Models\SmsProfile::query() ->select(['id', 'name', 'default_sender_id']) ->where('active', true) @@ -950,7 +821,7 @@ public function deleteContractDocument(Contract $contract, Document $document, R $document->delete(); - return back()->with('success', 'Document deleted.')->with('flash_method', 'DELETE'); + return back()->with('success', 'Document deleted.')->with('flash_method', 'DELETE'); } /** diff --git a/app/Http/Resources/ActivityCollection.php b/app/Http/Resources/ActivityCollection.php new file mode 100644 index 0000000..c63f40e --- /dev/null +++ b/app/Http/Resources/ActivityCollection.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + // Transform data to add user_name attribute + $this->collection->transform(function ($activity) { + $activity->setAttribute('user_name', optional($activity->user)->name); + + return $activity; + }); + + return $this->resource->toArray(); + } +} diff --git a/app/Http/Resources/ContractCollection.php b/app/Http/Resources/ContractCollection.php new file mode 100644 index 0000000..d5d7cd2 --- /dev/null +++ b/app/Http/Resources/ContractCollection.php @@ -0,0 +1,19 @@ + + */ + public function toArray(Request $request): array + { + return $this->resource->toArray(); + } +} diff --git a/app/Http/Resources/DocumentCollection.php b/app/Http/Resources/DocumentCollection.php new file mode 100644 index 0000000..22e0dad --- /dev/null +++ b/app/Http/Resources/DocumentCollection.php @@ -0,0 +1,21 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'data' => $this->collection, + ]; + } +} diff --git a/app/Models/Activity.php b/app/Models/Activity.php index d8c8950..3a6afa1 100644 --- a/app/Models/Activity.php +++ b/app/Models/Activity.php @@ -2,6 +2,8 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -57,6 +59,69 @@ protected static function booted() }); } + /** + * Scope activities to those linked to contracts within a specific segment. + */ + #[Scope] + public function scopeForSegment(Builder $query, int $segmentId, array $contractIds): Builder + { + return $query->where(function ($q) use ($contractIds) { + $q->whereNull('contract_id'); + if (! empty($contractIds)) { + $q->orWhereIn('contract_id', $contractIds); + } + }); + } + + /** + * Scope activities with decoded base64 filters. + */ + #[Scope] + public function scopeWithFilters(Builder $query, ?string $encodedFilters, \App\Models\ClientCase $clientCase): Builder + { + if (empty($encodedFilters)) { + return $query; + } + + try { + $decompressed = base64_decode($encodedFilters); + $filters = json_decode($decompressed, true); + + if (! is_array($filters)) { + return $query; + } + + if (! empty($filters['action_id'])) { + $query->where('action_id', $filters['action_id']); + } + + if (! empty($filters['contract_uuid'])) { + $contract = $clientCase->contracts()->where('uuid', $filters['contract_uuid'])->first(['id']); + if ($contract) { + $query->where('contract_id', $contract->id); + } + } + + if (! empty($filters['user_id'])) { + $query->where('user_id', $filters['user_id']); + } + + if (! empty($filters['date_from'])) { + $query->whereDate('created_at', '>=', $filters['date_from']); + } + + if (! empty($filters['date_to'])) { + $query->whereDate('created_at', '<=', $filters['date_to']); + } + } catch (\Throwable $e) { + \Log::error('Invalid activity filter format', [ + 'error' => $e->getMessage(), + ]); + } + + return $query; + } + public function action(): BelongsTo { return $this->belongsTo(\App\Models\Action::class); diff --git a/app/Models/Contract.php b/app/Models/Contract.php index 0f97745..3605e64 100644 --- a/app/Models/Contract.php +++ b/app/Models/Contract.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Traits\Uuid; +use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -55,6 +57,7 @@ protected function startDate(): Attribute return null; } $str = is_string($value) ? $value : (string) $value; + return \App\Services\DateNormalizer::toDate($str); } ); @@ -71,11 +74,26 @@ protected function endDate(): Attribute return null; } $str = is_string($value) ? $value : (string) $value; + return \App\Services\DateNormalizer::toDate($str); } ); } + /** + * Scope contracts to those in a specific segment with active pivot. + */ + #[Scope] + public function scopeForSegment(Builder $query, int $segmentId): Builder + { + return $query->whereExists(function ($q) use ($segmentId) { + $q->from('contract_segment') + ->whereColumn('contract_segment.contract_id', 'contracts.id') + ->where('contract_segment.segment_id', $segmentId) + ->where('contract_segment.active', true); + }); + } + public function type(): BelongsTo { return $this->belongsTo(\App\Models\ContractType::class, 'type_id'); diff --git a/app/Services/ClientCaseDataService.php b/app/Services/ClientCaseDataService.php new file mode 100644 index 0000000..1933c6b --- /dev/null +++ b/app/Services/ClientCaseDataService.php @@ -0,0 +1,181 @@ +contracts() + ->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at']) + ->with([ + 'type:id,name', + 'account' => function ($q) { + $q->select([ + 'accounts.id', + 'accounts.contract_id', + 'accounts.type_id', + 'accounts.initial_amount', + 'accounts.balance_amount', + 'accounts.promise_date', + 'accounts.created_at', + 'accounts.updated_at', + ])->orderByDesc('accounts.id'); + }, + 'segments:id,name', + 'objects:id,contract_id,reference,name,description,type,created_at', + ]) + ->orderByDesc('created_at'); + + if (! empty($segmentId)) { + $query->forSegment($segmentId); + } + + $perPage = max(1, min(100, $perPage)); + + return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString(); + } + + /** + * Get paginated activities for a client case with optional segment and filter constraints. + */ + public function getActivities( + ClientCase $clientCase, + ?int $segmentId = null, + ?string $encodedFilters = null, + array $contractIds = [], + int $perPage = 20 + ): LengthAwarePaginator { + $query = $clientCase->activities() + ->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name']) + ->orderByDesc('created_at'); + + if (! empty($segmentId)) { + $query->forSegment($segmentId, $contractIds); + } + + if (! empty($encodedFilters)) { + $query->withFilters($encodedFilters, $clientCase); + } + + $perPage = max(1, min(100, $perPage)); + + return $query->paginate($perPage, ['*'], 'activities_page')->withQueryString(); + } + + /** + * Get merged documents from case and its contracts. + */ + public function getDocuments(ClientCase $clientCase, array $contractIds = [], int $perPage = 15): LengthAwarePaginator + { + $query = null; + $caseDocsQuery = Document::query() + ->select([ + 'documents.id', + 'documents.uuid', + 'documents.documentable_id', + 'documents.documentable_type', + 'documents.name', + 'documents.file_name', + 'documents.original_name', + 'documents.extension', + 'documents.mime_type', + 'documents.size', + 'documents.created_at', + 'documents.is_public', + \DB::raw('NULL as contract_reference'), + \DB::raw('NULL as contract_uuid'), + \DB::raw("'{$clientCase->uuid}' as client_case_uuid"), + \DB::raw('users.name as created_by'), + ]) + ->join('users', 'documents.user_id', '=', 'users.id') + ->where('documents.documentable_type', ClientCase::class) + ->where('documents.documentable_id', $clientCase->id); + + if (! empty($contractIds)) { + // Get contract references for mapping + $contracts = Contract::query() + ->whereIn('id', $contractIds) + ->get(['id', 'uuid', 'reference']) + ->keyBy('id'); + + $contractDocsQuery = Document::query() + ->select([ + 'documents.id', + 'documents.uuid', + 'documents.documentable_id', + 'documents.documentable_type', + 'documents.name', + 'documents.file_name', + 'documents.original_name', + 'documents.extension', + 'documents.mime_type', + 'documents.size', + 'documents.created_at', + 'documents.is_public', + 'contracts.reference as contract_reference', + 'contracts.uuid as contract_uuid', + \DB::raw('NULL as client_case_uuid'), + \DB::raw('users.name as created_by'), + ]) + ->join('users', 'documents.user_id', '=', 'users.id') + ->join('contracts', 'documents.documentable_id', '=', 'contracts.id') + ->where('documents.documentable_type', Contract::class) + ->whereIn('documents.documentable_id', $contractIds); + + // Union the queries + $query = $caseDocsQuery->union($contractDocsQuery); + } else { + $query = $caseDocsQuery; + } + + return \DB::table(\DB::raw("({$query->toSql()}) as documents")) + ->mergeBindings($query->getQuery()) + ->orderByDesc('created_at') + ->paginate($perPage, ['*'], 'documentsPage') + ->withQueryString(); + } + + /** + * Get archive metadata from latest non-reactivate archive setting. + */ + public function getArchiveMeta(): array + { + $latestArchiveSetting = \App\Models\ArchiveSetting::query() + ->where('enabled', true) + ->where(function ($q) { + $q->whereNull('reactivate')->orWhere('reactivate', false); + }) + ->orderByDesc('id') + ->first(); + + $archiveSegmentId = optional($latestArchiveSetting)->segment_id; + $relatedArchiveTables = []; + + if ($latestArchiveSetting) { + $entities = (array) $latestArchiveSetting->entities; + foreach ($entities as $edef) { + if (isset($edef['related']) && is_array($edef['related'])) { + foreach ($edef['related'] as $rel) { + $relatedArchiveTables[] = $rel; + } + } + } + $relatedArchiveTables = array_values(array_unique($relatedArchiveTables)); + } + + return [ + 'archive_segment_id' => $archiveSegmentId, + 'related_tables' => $relatedArchiveTables, + ]; + } +} diff --git a/composer.lock b/composer.lock index 2238565..e2658a8 100644 --- a/composer.lock +++ b/composer.lock @@ -11289,6 +11289,6 @@ "platform": { "php": "^8.2" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/package-lock.json b/package-lock.json index fee49e5..2f91a7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.1.5", "@internationalized/date": "^3.9.0", + "@tanstack/vue-table": "^8.21.3", "@vee-validate/zod": "^4.15.1", "@vuepic/vue-datepicker": "^11.0.2", "@vueuse/core": "^14.0.0", @@ -1575,6 +1576,19 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { "version": "3.13.12", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", @@ -1585,6 +1599,25 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/vue-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz", + "integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": ">=3.2" + } + }, "node_modules/@tanstack/vue-virtual": { "version": "3.13.12", "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz", diff --git a/package.json b/package.json index 23b2619..888713a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.1.5", "@internationalized/date": "^3.9.0", + "@tanstack/vue-table": "^8.21.3", "@vee-validate/zod": "^4.15.1", "@vuepic/vue-datepicker": "^11.0.2", "@vueuse/core": "^14.0.0", diff --git a/resources/js/Components/DataTable/DataTable.vue b/resources/js/Components/DataTable/DataTable.vue index d18d089..296b457 100644 --- a/resources/js/Components/DataTable/DataTable.vue +++ b/resources/js/Components/DataTable/DataTable.vue @@ -1,18 +1,31 @@ -