Changes to UI and other stuff
This commit is contained in:
parent
b7fa2d261b
commit
3b284fa4bd
36
.github/copilot-instructions.md
vendored
36
.github/copilot-instructions.md
vendored
|
|
@ -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:
|
||||
|
||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### 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 ===
|
||||
|
|
|
|||
224
app/Helpers/LZStringHelper.php
Normal file
224
app/Helpers/LZStringHelper.php
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
class LZStringHelper
|
||||
{
|
||||
/**
|
||||
* Decompresses a string compressed with LZ-String's compressToEncodedURIComponent method.
|
||||
* This is a PHP port of the JavaScript LZ-String library.
|
||||
*
|
||||
* @param string $compressed
|
||||
* @return string|null
|
||||
*/
|
||||
public static function decompressFromEncodedURIComponent($compressed)
|
||||
{
|
||||
if ($compressed === null || $compressed === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Replace URL-safe characters back
|
||||
$compressed = str_replace(' ', '+', $compressed);
|
||||
|
||||
return self::decompress(strlen($compressed), 32, function ($index) use ($compressed) {
|
||||
return self::getBaseValue(self::$keyStrUriSafe, $compressed[$index]);
|
||||
});
|
||||
}
|
||||
|
||||
private static $keyStrUriSafe = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$';
|
||||
|
||||
private static function getBaseValue($alphabet, $character)
|
||||
{
|
||||
$pos = strpos($alphabet, $character);
|
||||
|
||||
return $pos !== false ? $pos : -1;
|
||||
}
|
||||
|
||||
private static function decompress($length, $resetValue, $getNextValue)
|
||||
{
|
||||
$dictionary = [];
|
||||
$enlargeIn = 4;
|
||||
$dictSize = 4;
|
||||
$numBits = 3;
|
||||
$entry = '';
|
||||
$result = [];
|
||||
$data = ['val' => $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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
26
app/Http/Resources/ActivityCollection.php
Normal file
26
app/Http/Resources/ActivityCollection.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ActivityCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* Transform the resource collection into an array.
|
||||
*
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
19
app/Http/Resources/ContractCollection.php
Normal file
19
app/Http/Resources/ContractCollection.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ContractCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* Transform the resource collection into an array.
|
||||
*
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return $this->resource->toArray();
|
||||
}
|
||||
}
|
||||
21
app/Http/Resources/DocumentCollection.php
Normal file
21
app/Http/Resources/DocumentCollection.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class DocumentCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* Transform the resource collection into an array.
|
||||
*
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->collection,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
181
app/Services/ClientCaseDataService.php
Normal file
181
app/Services/ClientCaseDataService.php
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Document;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ClientCaseDataService
|
||||
{
|
||||
/**
|
||||
* Get paginated contracts for a client case with optional segment filtering.
|
||||
*/
|
||||
public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int $perPage = 50): LengthAwarePaginator
|
||||
{
|
||||
$query = $clientCase->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
2
composer.lock
generated
2
composer.lock
generated
|
|
@ -11289,6 +11289,6 @@
|
|||
"platform": {
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
|
|
|||
33
package-lock.json
generated
33
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
72
resources/js/Components/DataTable/DataTableColumnHeader.vue
Normal file
72
resources/js/Components/DataTable/DataTableColumnHeader.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<script setup>
|
||||
import { h } from 'vue';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, EyeOff } from 'lucide-vue-next';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = defineProps({
|
||||
column: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
class: {
|
||||
type: [String, Object, Array],
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const getSortIcon = (column) => {
|
||||
if (!column.getIsSorted()) return ArrowUpDown;
|
||||
return column.getIsSorted() === 'asc' ? ArrowUp : ArrowDown;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="column.getCanSort()" :class="cn('flex items-center space-x-2', props.class)">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||
>
|
||||
<span>{{ title }}</span>
|
||||
<component
|
||||
:is="getSortIcon(column)"
|
||||
class="ml-2 h-4 w-4"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="column.toggleSorting(false)">
|
||||
<ArrowUp class="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="column.toggleSorting(true)">
|
||||
<ArrowDown class="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="column.toggleVisibility(false)">
|
||||
<EyeOff class="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div v-else :class="cn('', props.class)">
|
||||
{{ title }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
703
resources/js/Components/DataTable/DataTableNew.vue
Normal file
703
resources/js/Components/DataTable/DataTableNew.vue
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch, h } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import {
|
||||
useVueTable,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
flexRender,
|
||||
} from '@tanstack/vue-table';
|
||||
import DataTableToolbar from './DataTableToolbar.vue';
|
||||
import DataTableColumnHeader from './DataTableColumnHeader.vue';
|
||||
import DataTablePagination from './DataTablePagination.vue';
|
||||
import DataTableViewOptions from './DataTableViewOptions.vue';
|
||||
import SkeletonTable from '../Skeleton/SkeletonTable.vue';
|
||||
import EmptyState from '../EmptyState.vue';
|
||||
import Pagination from '../Pagination.vue';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/Components/ui/table';
|
||||
import Checkbox from '@/Components/ui/checkbox/Checkbox.vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = defineProps({
|
||||
// Data
|
||||
rows: { type: Array, default: () => [] },
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: (cols) =>
|
||||
cols.every(
|
||||
(col) => col.key && col.label && typeof col.key === 'string' && typeof col.label === 'string'
|
||||
),
|
||||
},
|
||||
|
||||
// Pagination (for server-side)
|
||||
meta: {
|
||||
type: Object,
|
||||
default: null,
|
||||
validator: (meta) =>
|
||||
!meta ||
|
||||
(typeof meta.current_page === 'number' &&
|
||||
typeof meta.per_page === 'number' &&
|
||||
typeof meta.total === 'number' &&
|
||||
typeof meta.last_page === 'number'),
|
||||
},
|
||||
|
||||
// Sorting
|
||||
sort: {
|
||||
type: Object,
|
||||
default: () => ({ key: null, direction: null }),
|
||||
},
|
||||
|
||||
// Search
|
||||
search: { type: String, default: '' },
|
||||
|
||||
// Loading state
|
||||
loading: { type: Boolean, default: false },
|
||||
|
||||
// Client-side pagination (when meta is null)
|
||||
pageSize: { type: Number, default: 10 },
|
||||
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
|
||||
|
||||
// Routing (for server-side)
|
||||
routeName: { type: String, default: null },
|
||||
routeParams: { type: Object, default: () => ({}) },
|
||||
pageParamName: { type: String, default: 'page' },
|
||||
onlyProps: { type: Array, default: () => [] },
|
||||
|
||||
// Features
|
||||
showToolbar: { type: Boolean, default: true },
|
||||
showSearch: { type: Boolean, default: false },
|
||||
showPageSize: { type: Boolean, default: false },
|
||||
showPagination: { type: Boolean, default: true },
|
||||
showFilters: { type: Boolean, default: false },
|
||||
showExport: { type: Boolean, default: false },
|
||||
showAdd: { type: Boolean, default: false },
|
||||
showOptions: { type: Boolean, default: false },
|
||||
showSelectedCount: { type: Boolean, default: false },
|
||||
showOptionsMenu: { type: Boolean, default: false },
|
||||
showViewOptions: { type: Boolean, default: false },
|
||||
compactToolbar: { type: Boolean, default: false },
|
||||
hasActiveFilters: { type: Boolean, default: false },
|
||||
rowKey: { type: [String, Function], default: 'uuid' },
|
||||
selectable: { type: Boolean, default: false },
|
||||
striped: { type: Boolean, default: false },
|
||||
hoverable: { type: Boolean, default: true },
|
||||
|
||||
// Empty state
|
||||
emptyText: { type: String, default: 'Ni podatkov' },
|
||||
emptyIcon: { type: [String, Object, Array], default: null },
|
||||
emptyDescription: { type: String, default: null },
|
||||
|
||||
// Actions
|
||||
showActions: { type: Boolean, default: false },
|
||||
actionsPosition: { type: String, default: 'right', validator: (v) => ['left', 'right'].includes(v) },
|
||||
|
||||
// Mobile
|
||||
mobileCardView: { type: Boolean, default: true },
|
||||
mobileBreakpoint: { type: Number, default: 768 },
|
||||
|
||||
// State preservation
|
||||
preserveState: { type: Boolean, default: true },
|
||||
preserveScroll: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:search',
|
||||
'update:sort',
|
||||
'update:page',
|
||||
'update:pageSize',
|
||||
'row:click',
|
||||
'row:select',
|
||||
'selection:change',
|
||||
]);
|
||||
|
||||
// Determine if this is server-side (has meta and routeName)
|
||||
const isServerSide = computed(() => !!(props.meta && props.routeName));
|
||||
const isClientSide = computed(() => !isServerSide.value);
|
||||
|
||||
// Row key helper
|
||||
function keyOf(row) {
|
||||
if (typeof props.rowKey === 'function') return props.rowKey(row);
|
||||
if (typeof props.rowKey === 'string' && row && row[props.rowKey] != null)
|
||||
return row[props.rowKey];
|
||||
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
// Convert simple column format to TanStack Table ColumnDef format
|
||||
const columnDefinitions = computed(() => {
|
||||
return props.columns.map((col) => ({
|
||||
accessorKey: col.key,
|
||||
id: col.key,
|
||||
header: ({ column }) => {
|
||||
return h(DataTableColumnHeader, {
|
||||
column,
|
||||
title: col.label,
|
||||
class: col.class,
|
||||
});
|
||||
},
|
||||
cell: ({ row, getValue }) => {
|
||||
return getValue();
|
||||
},
|
||||
enableSorting: col.sortable !== false,
|
||||
enableHiding: col.hideable !== false,
|
||||
meta: {
|
||||
align: col.align || 'left',
|
||||
class: col.class,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Add selection column if selectable
|
||||
const columnsWithSelection = computed(() => {
|
||||
if (!props.selectable) return columnDefinitions.value;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
header: ({ table }) => {
|
||||
return h(Checkbox, {
|
||||
modelValue: table.getIsAllPageRowsSelected(),
|
||||
indeterminate: table.getIsSomePageRowsSelected(),
|
||||
'onUpdate:modelValue': (value) => table.toggleAllPageRowsSelected(!!value),
|
||||
'aria-label': 'Select all',
|
||||
});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return h(Checkbox, {
|
||||
modelValue: row.getIsSelected(),
|
||||
'onUpdate:modelValue': (value) => row.toggleSelected(!!value),
|
||||
'aria-label': 'Select row',
|
||||
});
|
||||
},
|
||||
},
|
||||
...columnDefinitions.value,
|
||||
];
|
||||
});
|
||||
|
||||
// Add actions column if showActions
|
||||
const finalColumns = computed(() => {
|
||||
if (!props.showActions && !props.$slots.actions) return columnsWithSelection.value;
|
||||
|
||||
return [
|
||||
...columnsWithSelection.value,
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
header: () => h('span', { class: 'sr-only' }, 'Actions'),
|
||||
cell: ({ row }) => {
|
||||
// Actions will be rendered via slot
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// Internal search state
|
||||
const internalSearch = ref(props.search);
|
||||
watch(
|
||||
() => props.search,
|
||||
(newVal) => {
|
||||
internalSearch.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
// Internal sorting state
|
||||
const sorting = computed(() => {
|
||||
if (!props.sort?.key || !props.sort?.direction) return [];
|
||||
return [
|
||||
{
|
||||
id: props.sort.key,
|
||||
desc: props.sort.direction === 'desc',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// Internal pagination state
|
||||
const pagination = computed(() => {
|
||||
if (isServerSide.value) {
|
||||
return {
|
||||
pageIndex: (props.meta?.current_page ?? 1) - 1,
|
||||
pageSize: props.meta?.per_page ?? props.pageSize,
|
||||
};
|
||||
}
|
||||
return {
|
||||
pageIndex: internalPage.value - 1,
|
||||
pageSize: internalPageSize.value,
|
||||
};
|
||||
});
|
||||
|
||||
const internalPage = ref(1);
|
||||
const internalPageSize = ref(props.pageSize);
|
||||
|
||||
// Row selection
|
||||
const rowSelection = ref({});
|
||||
|
||||
// Create TanStack Table instance
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return props.rows;
|
||||
},
|
||||
get columns() {
|
||||
return finalColumns.value;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: isClientSide.value ? getPaginationRowModel() : undefined,
|
||||
getSortedRowModel: isClientSide.value ? getSortedRowModel() : undefined,
|
||||
getFilteredRowModel: isClientSide.value ? getFilteredRowModel() : undefined,
|
||||
onSortingChange: (updater) => {
|
||||
const newSorting = typeof updater === 'function' ? updater(sorting.value) : updater;
|
||||
if (newSorting.length > 0) {
|
||||
const sort = newSorting[0];
|
||||
emit('update:sort', {
|
||||
key: sort.id,
|
||||
direction: sort.desc ? 'desc' : 'asc',
|
||||
});
|
||||
if (isServerSide.value) {
|
||||
doServerRequest({
|
||||
sort: sort.id,
|
||||
direction: sort.desc ? 'desc' : 'asc',
|
||||
page: 1,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
emit('update:sort', { key: null, direction: null });
|
||||
if (isServerSide.value) {
|
||||
doServerRequest({ sort: null, direction: null, page: 1 });
|
||||
}
|
||||
}
|
||||
},
|
||||
onPaginationChange: (updater) => {
|
||||
const newPagination = typeof updater === 'function' ? updater(pagination.value) : updater;
|
||||
if (isServerSide.value) {
|
||||
doServerRequest({ page: newPagination.pageIndex + 1 });
|
||||
} else {
|
||||
internalPage.value = newPagination.pageIndex + 1;
|
||||
emit('update:page', newPagination.pageIndex + 1);
|
||||
}
|
||||
internalPageSize.value = newPagination.pageSize;
|
||||
emit('update:pageSize', newPagination.pageSize);
|
||||
},
|
||||
onRowSelectionChange: (updater) => {
|
||||
const newSelection = typeof updater === 'function' ? updater(rowSelection.value) : updater;
|
||||
rowSelection.value = newSelection;
|
||||
const selectedKeys = Object.keys(newSelection).filter((key) => newSelection[key]);
|
||||
emit('selection:change', selectedKeys);
|
||||
},
|
||||
manualSorting: isServerSide.value,
|
||||
manualPagination: isServerSide.value,
|
||||
manualFiltering: isServerSide.value,
|
||||
enableRowSelection: props.selectable,
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
get pagination() {
|
||||
return pagination.value;
|
||||
},
|
||||
get rowSelection() {
|
||||
return rowSelection.value;
|
||||
},
|
||||
get globalFilter() {
|
||||
return internalSearch.value;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Server-side request
|
||||
function doServerRequest(overrides = {}) {
|
||||
const existingParams = Object.fromEntries(
|
||||
new URLSearchParams(window.location.search).entries()
|
||||
);
|
||||
|
||||
const q = {
|
||||
...existingParams,
|
||||
per_page: overrides.perPage ?? props.meta?.per_page ?? internalPageSize.value,
|
||||
sort: overrides.sort ?? props.sort?.key ?? existingParams.sort ?? null,
|
||||
direction: overrides.direction ?? props.sort?.direction ?? existingParams.direction ?? null,
|
||||
search: overrides.search ?? internalSearch.value ?? existingParams.search ?? '',
|
||||
};
|
||||
|
||||
const pageParam = props.pageParamName || 'page';
|
||||
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
|
||||
if (pageParam !== 'page') {
|
||||
delete q.page;
|
||||
}
|
||||
|
||||
Object.keys(q).forEach((k) => {
|
||||
if (q[k] === null || q[k] === undefined || q[k] === '') delete q[k];
|
||||
});
|
||||
|
||||
const url = route(props.routeName, props.routeParams || {});
|
||||
router.get(
|
||||
url,
|
||||
q,
|
||||
{
|
||||
preserveScroll: props.preserveScroll,
|
||||
preserveState: props.preserveState,
|
||||
replace: true,
|
||||
only: props.onlyProps.length ? props.onlyProps : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleSearchChange(value) {
|
||||
internalSearch.value = value;
|
||||
emit('update:search', value);
|
||||
|
||||
if (isServerSide.value) {
|
||||
clearTimeout(searchTimer.value);
|
||||
searchTimer.value = setTimeout(() => {
|
||||
doServerRequest({ search: value, page: 1 });
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageSizeChange(size) {
|
||||
const newSize = Number(size);
|
||||
internalPageSize.value = newSize;
|
||||
emit('update:pageSize', newSize);
|
||||
|
||||
if (isServerSide.value) {
|
||||
doServerRequest({ perPage: newSize, page: 1 });
|
||||
} else {
|
||||
table.setPageSize(newSize);
|
||||
}
|
||||
}
|
||||
|
||||
const searchTimer = ref(null);
|
||||
|
||||
// Mobile detection
|
||||
const isMobile = ref(false);
|
||||
if (typeof window !== 'undefined') {
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth < props.mobileBreakpoint;
|
||||
};
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
}
|
||||
|
||||
// Display rows
|
||||
const displayRows = computed(() => {
|
||||
if (isServerSide.value) return props.rows;
|
||||
return table.getRowModel().rows.map((row) => row.original);
|
||||
});
|
||||
|
||||
const total = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.total ?? 0;
|
||||
return table.getFilteredRowModel().rows.length;
|
||||
});
|
||||
|
||||
const from = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.from ?? 0;
|
||||
const pageIndex = table.getState().pagination.pageIndex;
|
||||
const pageSize = table.getState().pagination.pageSize;
|
||||
return total.value === 0 ? 0 : pageIndex * pageSize + 1;
|
||||
});
|
||||
|
||||
const to = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.to ?? 0;
|
||||
const pageIndex = table.getState().pagination.pageIndex;
|
||||
const pageSize = table.getState().pagination.pageSize;
|
||||
return Math.min((pageIndex + 1) * pageSize, total.value);
|
||||
});
|
||||
|
||||
// Export functionality
|
||||
function handleExport(format) {
|
||||
const data = displayRows.value.map((row) => {
|
||||
const exported = {};
|
||||
props.columns.forEach((col) => {
|
||||
exported[col.label] = row?.[col.key] ?? '';
|
||||
});
|
||||
return exported;
|
||||
});
|
||||
|
||||
if (format === 'csv') {
|
||||
exportToCSV(data);
|
||||
} else if (format === 'xlsx') {
|
||||
exportToXLSX(data);
|
||||
}
|
||||
}
|
||||
|
||||
function exportToCSV(data) {
|
||||
if (data.length === 0) return;
|
||||
|
||||
const headers = Object.keys(data[0]);
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...data.map((row) =>
|
||||
headers
|
||||
.map((header) => {
|
||||
const value = row[header];
|
||||
if (value == null) return '';
|
||||
const stringValue = String(value).replace(/"/g, '""');
|
||||
return `"${stringValue}"`;
|
||||
})
|
||||
.join(',')
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function exportToXLSX(data) {
|
||||
exportToCSV(data);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-4">
|
||||
<!-- Toolbar -->
|
||||
<DataTableToolbar
|
||||
v-if="showToolbar"
|
||||
:search="internalSearch"
|
||||
:show-search="showSearch"
|
||||
:show-page-size="showPageSize"
|
||||
:page-size="internalPageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:selected-count="Object.keys(rowSelection).filter((key) => rowSelection[key]).length"
|
||||
:show-selected-count="showSelectedCount"
|
||||
:show-export="showExport"
|
||||
:show-add="showAdd"
|
||||
:show-options="showOptions"
|
||||
:show-filters="showFilters"
|
||||
:show-options-menu="showOptionsMenu"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
:compact="compactToolbar"
|
||||
@update:search="handleSearchChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
@export="handleExport"
|
||||
>
|
||||
<template #add>
|
||||
<slot name="toolbar-add" />
|
||||
</template>
|
||||
<template #options>
|
||||
<slot name="toolbar-options" />
|
||||
</template>
|
||||
<template #filters>
|
||||
<slot name="toolbar-filters" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="toolbar-actions" />
|
||||
</template>
|
||||
</DataTableToolbar>
|
||||
|
||||
<!-- View Options -->
|
||||
<div v-if="showViewOptions" class="flex items-center space-x-2">
|
||||
<DataTableViewOptions :table="table" />
|
||||
</div>
|
||||
|
||||
<!-- Table Container -->
|
||||
<div data-table-container class="relative overflow-hidden">
|
||||
<!-- Desktop Table View -->
|
||||
<div v-if="!isMobile || !mobileCardView" class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow
|
||||
v-for="headerGroup in table.getHeaderGroups()"
|
||||
:key="headerGroup.id"
|
||||
>
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
header.column.columnDef.meta?.class,
|
||||
header.column.columnDef.meta?.align === 'right' ? 'text-right' :
|
||||
header.column.columnDef.meta?.align === 'center' ? 'text-center' : 'text-left',
|
||||
]"
|
||||
>
|
||||
<div v-if="!header.isPlaceholder">
|
||||
<component
|
||||
:is="flexRender(header.column.columnDef.header, header.getContext())"
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<TableRow>
|
||||
<TableCell
|
||||
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
|
||||
class="h-24 text-center"
|
||||
>
|
||||
<SkeletonTable :rows="5" :cols="columns.length" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template v-else-if="!loading && table.getRowModel().rows.length === 0">
|
||||
<TableRow>
|
||||
<TableCell
|
||||
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
|
||||
class="h-24 text-center"
|
||||
>
|
||||
<EmptyState
|
||||
:icon="emptyIcon"
|
||||
:title="emptyText"
|
||||
:description="emptyDescription"
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<!-- Rows -->
|
||||
<template v-else>
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() && 'selected'"
|
||||
:class="cn(
|
||||
hoverable && 'cursor-pointer',
|
||||
striped && row.index % 2 === 1 && 'bg-muted/50',
|
||||
)"
|
||||
@click="(e) => {
|
||||
const interactive = e.target.closest('button, a, [role=button], [data-dropdown], .relative, input[type=checkbox]');
|
||||
if (interactive) return;
|
||||
$emit('row:click', row.original, row.index);
|
||||
}"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:class="[
|
||||
cell.column.columnDef.meta?.class,
|
||||
cell.column.columnDef.meta?.align === 'right' ? 'text-right' :
|
||||
cell.column.columnDef.meta?.align === 'center' ? 'text-center' : 'text-left',
|
||||
]"
|
||||
>
|
||||
<template v-if="cell.column.id === 'actions'">
|
||||
<slot name="actions" :row="row.original" :index="row.index">
|
||||
<slot name="row-actions" :row="row.original" :index="row.index" />
|
||||
</slot>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot
|
||||
:name="`cell-${cell.column.id}`"
|
||||
:row="row.original"
|
||||
:column="cell.column.columnDef"
|
||||
:value="cell.getValue()"
|
||||
:index="row.index"
|
||||
>
|
||||
<slot
|
||||
name="cell"
|
||||
:row="row.original"
|
||||
:column="cell.column.columnDef"
|
||||
:value="cell.getValue()"
|
||||
:index="row.index"
|
||||
>
|
||||
<component
|
||||
:is="flexRender(cell.column.columnDef.cell, cell.getContext())"
|
||||
/>
|
||||
</slot>
|
||||
</slot>
|
||||
</template>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Card View -->
|
||||
<div v-else-if="isMobile && mobileCardView" class="divide-y divide-gray-200">
|
||||
<template v-if="loading">
|
||||
<div class="p-4">
|
||||
<SkeletonTable :rows="3" :cols="1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!loading && table.getRowModel().rows.length === 0">
|
||||
<div class="p-6">
|
||||
<EmptyState
|
||||
:icon="emptyIcon"
|
||||
:title="emptyText"
|
||||
:description="emptyDescription"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
@click="$emit('row:click', row.original, row.index)"
|
||||
class="p-4 space-y-2 hover:bg-gray-50 transition-colors"
|
||||
:class="{ 'cursor-pointer': !!$attrs.onRowClick }"
|
||||
>
|
||||
<slot name="mobile-card" :row="row.original" :index="row.index">
|
||||
<!-- Default mobile card layout -->
|
||||
<div
|
||||
v-for="col in columns.slice(0, 3)"
|
||||
:key="col.key"
|
||||
class="flex justify-between items-start"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
{{ col.label }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-900 text-right">
|
||||
<slot
|
||||
:name="`cell-${col.key}`"
|
||||
:row="row.original"
|
||||
:column="col"
|
||||
:value="row.original?.[col.key]"
|
||||
:index="row.index"
|
||||
>
|
||||
{{ row.original?.[col.key] ?? '—' }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showActions || $slots.actions" class="pt-2 border-t border-gray-100">
|
||||
<slot name="actions" :row="row.original" :index="row.index">
|
||||
<slot name="row-actions" :row="row.original" :index="row.index" />
|
||||
</slot>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="showPagination">
|
||||
<!-- Use existing Pagination component for server-side -->
|
||||
<template v-if="isServerSide && meta?.links">
|
||||
<Pagination
|
||||
:links="meta.links"
|
||||
:from="from"
|
||||
:to="to"
|
||||
:total="total"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- TanStack Table Pagination for client-side -->
|
||||
<template v-else>
|
||||
<DataTablePagination :table="table" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
601
resources/js/Components/DataTable/DataTableNew2.vue
Normal file
601
resources/js/Components/DataTable/DataTableNew2.vue
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch, h } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
useVueTable,
|
||||
} from "@tanstack/vue-table";
|
||||
import { valueUpdater } from "@/lib/utils";
|
||||
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
|
||||
import DataTablePagination from "./DataTablePagination.vue";
|
||||
import DataTableViewOptions from "./DataTableViewOptions.vue";
|
||||
import DataTableToolbar from "./DataTableToolbar.vue";
|
||||
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
|
||||
import EmptyState from "../EmptyState.vue";
|
||||
import Pagination from "../Pagination.vue";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import Checkbox from "@/Components/ui/checkbox/Checkbox.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
// Column definitions using TanStack Table format or simple format
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
// Data rows
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// Server-side pagination meta (Laravel pagination)
|
||||
meta: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
// Current sort state
|
||||
sort: {
|
||||
type: Object,
|
||||
default: () => ({ key: null, direction: null }),
|
||||
},
|
||||
// Search/filter value
|
||||
search: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
// Loading state
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Page size for client-side pagination
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 10,
|
||||
},
|
||||
pageSizeOptions: {
|
||||
type: Array,
|
||||
default: () => [10, 25, 50, 100],
|
||||
},
|
||||
// Server-side routing
|
||||
routeName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
routeParams: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
pageParamName: {
|
||||
type: String,
|
||||
default: "page",
|
||||
},
|
||||
perPageParamName: {
|
||||
type: String,
|
||||
default: "per_page",
|
||||
},
|
||||
onlyProps: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
preserveState: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
preserveScroll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// Features
|
||||
showPagination: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showToolbar: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
filterColumn: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
filterPlaceholder: {
|
||||
type: String,
|
||||
default: "Filter...",
|
||||
},
|
||||
rowKey: {
|
||||
type: [String, Function],
|
||||
default: "id",
|
||||
},
|
||||
enableRowSelection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
striped: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hoverable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// Empty state
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: "No results.",
|
||||
},
|
||||
emptyIcon: {
|
||||
type: [String, Object, Array],
|
||||
default: null,
|
||||
},
|
||||
emptyDescription: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"update:search",
|
||||
"update:sort",
|
||||
"update:page",
|
||||
"update:pageSize",
|
||||
"row:click",
|
||||
"row:select",
|
||||
"selection:change",
|
||||
]);
|
||||
|
||||
// Determine if this is server-side mode
|
||||
const isServerSide = computed(() => !!(props.meta && props.routeName));
|
||||
|
||||
// Convert simple column format to TanStack ColumnDef if needed
|
||||
const columnDefinitions = computed(() => {
|
||||
return props.columns.map((col) => {
|
||||
// If already a full ColumnDef, return as is
|
||||
if (col.accessorKey || col.accessorFn) {
|
||||
return col;
|
||||
}
|
||||
|
||||
// Convert simple format to ColumnDef
|
||||
return {
|
||||
accessorKey: col.key,
|
||||
id: col.key,
|
||||
header: ({ column }) => {
|
||||
return h(DataTableColumnHeader, {
|
||||
column,
|
||||
title: col.label,
|
||||
class: col.class,
|
||||
});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue(col.key);
|
||||
return h("div", { class: col.class }, value);
|
||||
},
|
||||
enableSorting: col.sortable !== false,
|
||||
enableHiding: col.hideable !== false,
|
||||
meta: {
|
||||
align: col.align || "left",
|
||||
class: col.class,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Add selection column if enabled
|
||||
const columnsWithSelection = computed(() => {
|
||||
if (!props.enableRowSelection) return columnDefinitions.value;
|
||||
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return h(Checkbox, {
|
||||
modelValue: table.getIsAllPageRowsSelected(),
|
||||
indeterminate: table.getIsSomePageRowsSelected(),
|
||||
"onUpdate:modelValue": (value) => table.toggleAllPageRowsSelected(!!value),
|
||||
"aria-label": "Select all",
|
||||
});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return h(Checkbox, {
|
||||
modelValue: row.getIsSelected(),
|
||||
"onUpdate:modelValue": (value) => row.toggleSelected(!!value),
|
||||
"aria-label": "Select row",
|
||||
});
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
...columnDefinitions.value,
|
||||
];
|
||||
});
|
||||
|
||||
// Internal state
|
||||
const sorting = ref([]);
|
||||
const columnFilters = ref([]);
|
||||
const columnVisibility = ref({});
|
||||
const rowSelection = ref({});
|
||||
|
||||
// Client-side pagination state
|
||||
const clientPagination = ref({
|
||||
pageIndex: 0,
|
||||
pageSize: props.pageSize,
|
||||
});
|
||||
|
||||
// Initialize sorting from props
|
||||
watch(
|
||||
() => props.sort,
|
||||
(newSort) => {
|
||||
if (newSort?.key && newSort?.direction) {
|
||||
sorting.value = [
|
||||
{
|
||||
id: newSort.key,
|
||||
desc: newSort.direction === "desc",
|
||||
},
|
||||
];
|
||||
} else {
|
||||
sorting.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Initialize filter from props
|
||||
watch(
|
||||
() => props.search,
|
||||
(newSearch) => {
|
||||
if (props.filterColumn && newSearch) {
|
||||
columnFilters.value = [
|
||||
{
|
||||
id: props.filterColumn,
|
||||
value: newSearch,
|
||||
},
|
||||
];
|
||||
} else if (!newSearch) {
|
||||
columnFilters.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Pagination state
|
||||
const pagination = computed(() => {
|
||||
if (isServerSide.value) {
|
||||
// Check URL for custom per-page parameter
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const perPageParam = props.perPageParamName || "per_page";
|
||||
const urlPerPage = urlParams.get(perPageParam);
|
||||
const pageSize = urlPerPage
|
||||
? Number(urlPerPage)
|
||||
: (props.meta?.per_page ?? props.pageSize);
|
||||
|
||||
return {
|
||||
pageIndex: (props.meta?.current_page ?? 1) - 1,
|
||||
pageSize: pageSize,
|
||||
};
|
||||
}
|
||||
return clientPagination.value;
|
||||
});
|
||||
|
||||
// Watch for prop changes to update client pagination
|
||||
watch(
|
||||
() => props.pageSize,
|
||||
(newSize) => {
|
||||
if (!isServerSide.value) {
|
||||
clientPagination.value.pageSize = newSize;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create TanStack Table
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return props.data;
|
||||
},
|
||||
get columns() {
|
||||
return columnsWithSelection.value;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: !isServerSide.value ? getPaginationRowModel() : undefined,
|
||||
getSortedRowModel: !isServerSide.value ? getSortedRowModel() : undefined,
|
||||
getFilteredRowModel: !isServerSide.value ? getFilteredRowModel() : undefined,
|
||||
onSortingChange: (updater) => {
|
||||
valueUpdater(updater, sorting);
|
||||
const newSort = sorting.value[0];
|
||||
if (newSort) {
|
||||
emit("update:sort", {
|
||||
key: newSort.id,
|
||||
direction: newSort.desc ? "desc" : "asc",
|
||||
});
|
||||
if (isServerSide.value) {
|
||||
doServerRequest({
|
||||
sort: newSort.id,
|
||||
direction: newSort.desc ? "desc" : "asc",
|
||||
page: 1,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
emit("update:sort", { key: null, direction: null });
|
||||
if (isServerSide.value) {
|
||||
doServerRequest({ sort: null, direction: null, page: 1 });
|
||||
}
|
||||
}
|
||||
},
|
||||
onColumnFiltersChange: (updater) => {
|
||||
valueUpdater(updater, columnFilters);
|
||||
const filter = columnFilters.value.find((f) => f.id === props.filterColumn);
|
||||
const searchValue = filter?.value ?? "";
|
||||
emit("update:search", searchValue);
|
||||
if (isServerSide.value) {
|
||||
clearTimeout(searchTimer.value);
|
||||
searchTimer.value = setTimeout(() => {
|
||||
doServerRequest({ search: searchValue, page: 1 });
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
onColumnVisibilityChange: (updater) => valueUpdater(updater, columnVisibility),
|
||||
onRowSelectionChange: (updater) => {
|
||||
valueUpdater(updater, rowSelection);
|
||||
const selectedKeys = Object.keys(rowSelection.value).filter(
|
||||
(key) => rowSelection.value[key]
|
||||
);
|
||||
emit("selection:change", selectedKeys);
|
||||
},
|
||||
onPaginationChange: (updater) => {
|
||||
const currentPagination = pagination.value;
|
||||
const newPagination =
|
||||
typeof updater === "function" ? updater(currentPagination) : updater;
|
||||
|
||||
// Check if page size changed
|
||||
const pageSizeChanged = newPagination.pageSize !== currentPagination.pageSize;
|
||||
|
||||
if (isServerSide.value) {
|
||||
// If page size changed, go back to page 1
|
||||
const targetPage = pageSizeChanged ? 1 : newPagination.pageIndex + 1;
|
||||
doServerRequest({
|
||||
page: targetPage,
|
||||
perPage: newPagination.pageSize,
|
||||
});
|
||||
} else {
|
||||
// Update client-side pagination state
|
||||
clientPagination.value = {
|
||||
pageIndex: newPagination.pageIndex,
|
||||
pageSize: newPagination.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
if (pageSizeChanged) {
|
||||
emit("update:pageSize", newPagination.pageSize);
|
||||
}
|
||||
if (newPagination.pageIndex !== currentPagination.pageIndex) {
|
||||
emit("update:page", newPagination.pageIndex + 1);
|
||||
}
|
||||
},
|
||||
manualSorting: isServerSide.value,
|
||||
manualPagination: isServerSide.value,
|
||||
manualFiltering: isServerSide.value,
|
||||
enableRowSelection: props.enableRowSelection,
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
get columnFilters() {
|
||||
return columnFilters.value;
|
||||
},
|
||||
get columnVisibility() {
|
||||
return columnVisibility.value;
|
||||
},
|
||||
get rowSelection() {
|
||||
return rowSelection.value;
|
||||
},
|
||||
get pagination() {
|
||||
return pagination.value;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const searchTimer = ref(null);
|
||||
|
||||
// Server-side request handler
|
||||
function doServerRequest(overrides = {}) {
|
||||
if (!props.routeName) return;
|
||||
|
||||
const existingParams = Object.fromEntries(
|
||||
new URLSearchParams(window.location.search).entries()
|
||||
);
|
||||
|
||||
const perPageParam = props.perPageParamName || "per_page";
|
||||
const pageParam = props.pageParamName || "page";
|
||||
|
||||
const q = {
|
||||
...existingParams,
|
||||
sort: overrides.sort ?? props.sort?.key ?? null,
|
||||
direction: overrides.direction ?? props.sort?.direction ?? null,
|
||||
search: overrides.search ?? props.search ?? "",
|
||||
};
|
||||
|
||||
// Use custom per_page parameter name
|
||||
q[perPageParam] = overrides.perPage ?? props.meta?.per_page ?? props.pageSize;
|
||||
if (perPageParam !== "per_page") {
|
||||
delete q.per_page;
|
||||
}
|
||||
|
||||
// Use custom page parameter name
|
||||
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
|
||||
if (pageParam !== "page") {
|
||||
delete q.page;
|
||||
}
|
||||
|
||||
Object.keys(q).forEach((k) => {
|
||||
if (q[k] === null || q[k] === undefined || q[k] === "") delete q[k];
|
||||
});
|
||||
|
||||
const url = route(props.routeName, props.routeParams || {});
|
||||
router.get(url, q, {
|
||||
preserveScroll: props.preserveScroll,
|
||||
preserveState: props.preserveState,
|
||||
replace: true,
|
||||
only: props.onlyProps.length ? props.onlyProps : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Row key helper
|
||||
function keyOf(row) {
|
||||
if (typeof props.rowKey === "function") return props.rowKey(row);
|
||||
if (typeof props.rowKey === "string" && row && row[props.rowKey] != null)
|
||||
return row[props.rowKey];
|
||||
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<!-- Toolbar -->
|
||||
<DataTableToolbar
|
||||
v-if="showToolbar"
|
||||
:table="table"
|
||||
:filter-column="filterColumn"
|
||||
:filter-placeholder="filterPlaceholder"
|
||||
:show-per-page-selector="isServerSide"
|
||||
:per-page="pagination.pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
@update:per-page="(value) => table.setPageSize(value)"
|
||||
class="px-4 py-2 border-t"
|
||||
>
|
||||
<template #filters="slotProps">
|
||||
<slot name="toolbar-filters" v-bind="slotProps" />
|
||||
</template>
|
||||
<template #actions="slotProps">
|
||||
<slot name="toolbar-actions" v-bind="slotProps" />
|
||||
</template>
|
||||
</DataTableToolbar>
|
||||
|
||||
<!-- Custom toolbar slot for full control -->
|
||||
<slot name="toolbar" :table="table" />
|
||||
|
||||
<!-- Table -->
|
||||
<div class="border-t">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
'py-4',
|
||||
header.column.columnDef.meta?.class,
|
||||
header.column.columnDef.meta?.align === 'right'
|
||||
? 'text-right'
|
||||
: header.column.columnDef.meta?.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left',
|
||||
]"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<TableRow>
|
||||
<TableCell :colspan="table.getAllColumns().length" class="h-24 text-center">
|
||||
<SkeletonTable :rows="5" :cols="columns.length" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template v-else-if="table.getRowModel().rows.length === 0">
|
||||
<TableRow>
|
||||
<TableCell :colspan="table.getAllColumns().length" class="h-24 text-center">
|
||||
<EmptyState
|
||||
:icon="emptyIcon"
|
||||
:title="emptyText"
|
||||
:description="emptyDescription"
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<!-- Data Rows -->
|
||||
<template v-else>
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="keyOf(row.original)"
|
||||
:data-state="row.getIsSelected() && 'selected'"
|
||||
:class="
|
||||
cn(
|
||||
hoverable && 'cursor-pointer hover:bg-muted/50',
|
||||
striped && row.index % 2 === 1 && 'bg-muted/50'
|
||||
)
|
||||
"
|
||||
@click="$emit('row:click', row.original, row.index)"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:class="[
|
||||
cell.column.columnDef.meta?.class,
|
||||
cell.column.columnDef.meta?.align === 'right'
|
||||
? 'text-right'
|
||||
: cell.column.columnDef.meta?.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left',
|
||||
]"
|
||||
>
|
||||
<!-- Use slot if provided -->
|
||||
<slot
|
||||
:name="`cell-${cell.column.id}`"
|
||||
:row="row.original"
|
||||
:column="cell.column"
|
||||
:value="cell.getValue()"
|
||||
:index="row.index"
|
||||
>
|
||||
<!-- Otherwise use FlexRender -->
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</slot>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="showPagination">
|
||||
<!-- Server-side pagination -->
|
||||
<template v-if="isServerSide && meta?.links">
|
||||
<Pagination
|
||||
:links="meta.links"
|
||||
:from="meta.from"
|
||||
:to="meta.to"
|
||||
:total="meta.total"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Client-side pagination -->
|
||||
<template v-else>
|
||||
<DataTablePagination :table="table" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
884
resources/js/Components/DataTable/DataTableOld.vue
Normal file
884
resources/js/Components/DataTable/DataTableOld.vue
Normal file
|
|
@ -0,0 +1,884 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import DataTableToolbar from './DataTableToolbar.vue';
|
||||
import SkeletonTable from '../Skeleton/SkeletonTable.vue';
|
||||
import EmptyState from '../EmptyState.vue';
|
||||
import Pagination from '../Pagination.vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import {
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faSort,
|
||||
faSortUp,
|
||||
faSortDown,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const props = defineProps({
|
||||
// Data
|
||||
rows: { type: Array, default: () => [] },
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: (cols) =>
|
||||
cols.every(
|
||||
(col) => col.key && col.label && typeof col.key === 'string' && typeof col.label === 'string'
|
||||
),
|
||||
},
|
||||
|
||||
// Pagination (for server-side)
|
||||
meta: {
|
||||
type: Object,
|
||||
default: null,
|
||||
validator: (meta) =>
|
||||
!meta ||
|
||||
(typeof meta.current_page === 'number' &&
|
||||
typeof meta.per_page === 'number' &&
|
||||
typeof meta.total === 'number' &&
|
||||
typeof meta.last_page === 'number'),
|
||||
},
|
||||
|
||||
// Sorting
|
||||
sort: {
|
||||
type: Object,
|
||||
default: () => ({ key: null, direction: null }),
|
||||
},
|
||||
|
||||
// Search
|
||||
search: { type: String, default: '' },
|
||||
|
||||
// Loading state
|
||||
loading: { type: Boolean, default: false },
|
||||
|
||||
// Client-side pagination (when meta is null)
|
||||
pageSize: { type: Number, default: 10 },
|
||||
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
|
||||
|
||||
// Routing (for server-side)
|
||||
routeName: { type: String, default: null },
|
||||
routeParams: { type: Object, default: () => ({}) },
|
||||
pageParamName: { type: String, default: 'page' },
|
||||
onlyProps: { type: Array, default: () => [] },
|
||||
|
||||
// Features
|
||||
showToolbar: { type: Boolean, default: true },
|
||||
showSearch: { type: Boolean, default: false },
|
||||
showPageSize: { type: Boolean, default: false },
|
||||
showPagination: { type: Boolean, default: true },
|
||||
showFilters: { type: Boolean, default: false },
|
||||
showExport: { type: Boolean, default: false },
|
||||
showAdd: { type: Boolean, default: false }, // Show add buttons dropdown
|
||||
showOptions: { type: Boolean, default: false }, // Show custom options slot
|
||||
showSelectedCount: { type: Boolean, default: false }, // Show selected count badge
|
||||
showOptionsMenu: { type: Boolean, default: false }, // Show options menu (three dots)
|
||||
compactToolbar: { type: Boolean, default: false }, // Compact mode: move search/page size to menu
|
||||
hasActiveFilters: { type: Boolean, default: false }, // External indicator for active filters
|
||||
rowKey: { type: [String, Function], default: 'uuid' },
|
||||
selectable: { type: Boolean, default: false },
|
||||
striped: { type: Boolean, default: false },
|
||||
hoverable: { type: Boolean, default: true },
|
||||
|
||||
// Empty state
|
||||
emptyText: { type: String, default: 'Ni podatkov' },
|
||||
emptyIcon: { type: [String, Object, Array], default: null },
|
||||
emptyDescription: { type: String, default: null },
|
||||
|
||||
// Actions
|
||||
showActions: { type: Boolean, default: false },
|
||||
actionsPosition: { type: String, default: 'right', validator: (v) => ['left', 'right'].includes(v) },
|
||||
|
||||
// Mobile
|
||||
mobileCardView: { type: Boolean, default: true },
|
||||
mobileBreakpoint: { type: Number, default: 768 }, // Tailwind md breakpoint
|
||||
|
||||
// State preservation
|
||||
preserveState: { type: Boolean, default: true },
|
||||
preserveScroll: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:search',
|
||||
'update:sort',
|
||||
'update:page',
|
||||
'update:pageSize',
|
||||
'row:click',
|
||||
'row:select',
|
||||
'selection:change',
|
||||
]);
|
||||
|
||||
// Determine if this is server-side (has meta and routeName)
|
||||
const isServerSide = computed(() => !!(props.meta && props.routeName));
|
||||
const isClientSide = computed(() => !isServerSide.value);
|
||||
|
||||
// Row key helper
|
||||
function keyOf(row) {
|
||||
if (typeof props.rowKey === 'function') return props.rowKey(row);
|
||||
if (typeof props.rowKey === 'string' && row && row[props.rowKey] != null)
|
||||
return row[props.rowKey];
|
||||
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
// Client-side sorting
|
||||
const sortedRows = computed(() => {
|
||||
if (isServerSide.value || !props.sort?.key || !props.sort?.direction) {
|
||||
return props.rows;
|
||||
}
|
||||
|
||||
const key = props.sort.key;
|
||||
const direction = props.sort.direction;
|
||||
const sorted = [...props.rows];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
let aVal = a?.[key];
|
||||
let bVal = b?.[key];
|
||||
|
||||
// Handle nulls/undefined
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
// Handle dates
|
||||
if (aVal instanceof Date || (typeof aVal === 'string' && aVal.match(/\d{4}-\d{2}-\d{2}/))) {
|
||||
aVal = new Date(aVal);
|
||||
bVal = new Date(bVal);
|
||||
}
|
||||
|
||||
// Handle numbers
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return direction === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
// Handle strings
|
||||
const aStr = String(aVal).toLowerCase();
|
||||
const bStr = String(bVal).toLowerCase();
|
||||
if (direction === 'asc') {
|
||||
return aStr.localeCompare(bStr);
|
||||
}
|
||||
return bStr.localeCompare(aStr);
|
||||
});
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
// Client-side pagination
|
||||
const currentPage = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.current_page ?? 1;
|
||||
return internalPage.value;
|
||||
});
|
||||
|
||||
const internalPage = ref(1);
|
||||
// Use computed for pageSize to always reflect the correct value
|
||||
// For server-side: use meta.per_page, for client-side: use internal state or props.pageSize
|
||||
const internalPageSize = computed({
|
||||
get: () => {
|
||||
if (isServerSide.value && props.meta?.per_page) {
|
||||
return props.meta.per_page;
|
||||
}
|
||||
return internalPageSizeState.value ?? props.pageSize;
|
||||
},
|
||||
set: (value) => {
|
||||
internalPageSizeState.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Internal state for client-side or when user changes page size before server responds
|
||||
const internalPageSizeState = ref(null);
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.last_page ?? 1;
|
||||
return Math.ceil(sortedRows.value.length / internalPageSize.value);
|
||||
});
|
||||
|
||||
const paginatedRows = computed(() => {
|
||||
if (isServerSide.value) return props.rows;
|
||||
|
||||
const start = (currentPage.value - 1) * internalPageSize.value;
|
||||
const end = start + internalPageSize.value;
|
||||
return sortedRows.value.slice(start, end);
|
||||
});
|
||||
|
||||
// Client-side search
|
||||
const filteredRows = computed(() => {
|
||||
if (isServerSide.value || !internalSearch.value) {
|
||||
return paginatedRows.value;
|
||||
}
|
||||
|
||||
const searchTerm = internalSearch.value.toLowerCase();
|
||||
return paginatedRows.value.filter((row) => {
|
||||
return props.columns.some((col) => {
|
||||
const value = row?.[col.key];
|
||||
if (value == null) return false;
|
||||
return String(value).toLowerCase().includes(searchTerm);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Search handling
|
||||
const internalSearch = ref(props.search);
|
||||
watch(
|
||||
() => props.search,
|
||||
(newVal) => {
|
||||
internalSearch.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
// Selection
|
||||
const selectedRows = ref(new Set());
|
||||
const isAllSelected = computed(() => {
|
||||
if (filteredRows.value.length === 0) return false;
|
||||
return filteredRows.value.every((row) => selectedRows.value.has(keyOf(row)));
|
||||
});
|
||||
const isSomeSelected = computed(() => {
|
||||
return (
|
||||
selectedRows.value.size > 0 &&
|
||||
filteredRows.value.some((row) => selectedRows.value.has(keyOf(row)))
|
||||
);
|
||||
});
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
filteredRows.value.forEach((row) => {
|
||||
selectedRows.value.delete(keyOf(row));
|
||||
});
|
||||
} else {
|
||||
filteredRows.value.forEach((row) => {
|
||||
selectedRows.value.add(keyOf(row));
|
||||
});
|
||||
}
|
||||
emit('selection:change', Array.from(selectedRows.value));
|
||||
}
|
||||
|
||||
function toggleSelectRow(row) {
|
||||
const key = keyOf(row);
|
||||
if (selectedRows.value.has(key)) {
|
||||
selectedRows.value.delete(key);
|
||||
} else {
|
||||
selectedRows.value.add(key);
|
||||
}
|
||||
emit('row:select', row, selectedRows.value.has(key));
|
||||
emit('selection:change', Array.from(selectedRows.value));
|
||||
}
|
||||
|
||||
// Sorting
|
||||
function toggleSort(col) {
|
||||
if (!col.sortable) return;
|
||||
|
||||
if (isServerSide.value) {
|
||||
const current = props.sort || { key: null, direction: null };
|
||||
let direction = 'asc';
|
||||
if (current.key === col.key) {
|
||||
direction =
|
||||
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
|
||||
}
|
||||
emit('update:sort', { key: direction ? col.key : null, direction });
|
||||
doServerRequest({ sort: direction ? col.key : null, direction, page: 1 });
|
||||
} else {
|
||||
const current = props.sort || { key: null, direction: null };
|
||||
let direction = 'asc';
|
||||
if (current.key === col.key) {
|
||||
direction =
|
||||
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
|
||||
}
|
||||
emit('update:sort', { key: direction ? col.key : null, direction });
|
||||
}
|
||||
}
|
||||
|
||||
function getSortIcon(col) {
|
||||
if (props.sort?.key !== col.key) return faSort;
|
||||
if (props.sort?.direction === 'asc') return faSortUp;
|
||||
if (props.sort?.direction === 'desc') return faSortDown;
|
||||
return faSort;
|
||||
}
|
||||
|
||||
// Server-side request
|
||||
function doServerRequest(overrides = {}) {
|
||||
// Preserve existing query parameters from URL
|
||||
const existingParams = Object.fromEntries(
|
||||
new URLSearchParams(window.location.search).entries()
|
||||
);
|
||||
|
||||
const q = {
|
||||
...existingParams, // Preserve all existing query parameters
|
||||
per_page: overrides.perPage ?? props.meta?.per_page ?? internalPageSizeState.value ?? internalPageSize.value,
|
||||
sort: overrides.sort ?? props.sort?.key ?? existingParams.sort ?? null,
|
||||
direction: overrides.direction ?? props.sort?.direction ?? existingParams.direction ?? null,
|
||||
search: overrides.search ?? internalSearch.value ?? existingParams.search ?? '',
|
||||
};
|
||||
|
||||
const pageParam = props.pageParamName || 'page';
|
||||
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
|
||||
if (pageParam !== 'page') {
|
||||
delete q.page;
|
||||
}
|
||||
|
||||
// Clean nulls and empty strings
|
||||
Object.keys(q).forEach((k) => {
|
||||
if (q[k] === null || q[k] === undefined || q[k] === '') delete q[k];
|
||||
});
|
||||
|
||||
const url = route(props.routeName, props.routeParams || {});
|
||||
router.get(
|
||||
url,
|
||||
q,
|
||||
{
|
||||
preserveScroll: props.preserveScroll,
|
||||
preserveState: props.preserveState,
|
||||
replace: true,
|
||||
only: props.onlyProps.length ? props.onlyProps : undefined,
|
||||
onSuccess: () => {
|
||||
// Scroll to top of table after server request completes
|
||||
setTimeout(() => {
|
||||
const tableElement = document.querySelector('[data-table-container]');
|
||||
if (tableElement) {
|
||||
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleSearchChange(value) {
|
||||
internalSearch.value = value;
|
||||
emit('update:search', value);
|
||||
|
||||
if (isServerSide.value) {
|
||||
clearTimeout(searchTimer.value);
|
||||
searchTimer.value = setTimeout(() => {
|
||||
doServerRequest({ search: value, page: 1 });
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageSizeChange(size) {
|
||||
const newSize = Number(size);
|
||||
internalPageSizeState.value = newSize;
|
||||
emit('update:pageSize', newSize);
|
||||
|
||||
if (isServerSide.value) {
|
||||
// Reset to page 1 when changing page size to avoid being on a non-existent page
|
||||
doServerRequest({ perPage: newSize, page: 1 });
|
||||
} else {
|
||||
// Calculate total pages with new size
|
||||
const newTotalPages = Math.ceil(sortedRows.value.length / newSize);
|
||||
// If current page exceeds new total, reset to last page or page 1
|
||||
const targetPage = currentPage.value > newTotalPages && newTotalPages > 0 ? newTotalPages : 1;
|
||||
internalPage.value = targetPage;
|
||||
emit('update:page', targetPage);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page) {
|
||||
if (isServerSide.value) {
|
||||
doServerRequest({ page });
|
||||
} else {
|
||||
internalPage.value = page;
|
||||
emit('update:page', page);
|
||||
}
|
||||
|
||||
// Scroll to top of table after page change
|
||||
setTimeout(() => {
|
||||
const tableElement = document.querySelector('[data-table-container]');
|
||||
if (tableElement) {
|
||||
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, isServerSide.value ? 100 : 50);
|
||||
}
|
||||
|
||||
const searchTimer = ref(null);
|
||||
|
||||
// Mobile detection
|
||||
const isMobile = ref(false);
|
||||
if (typeof window !== 'undefined') {
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth < props.mobileBreakpoint;
|
||||
};
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
}
|
||||
|
||||
// Display rows
|
||||
const displayRows = computed(() => {
|
||||
if (isServerSide.value || !internalSearch.value) {
|
||||
return paginatedRows.value;
|
||||
}
|
||||
return filteredRows.value;
|
||||
});
|
||||
|
||||
const total = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.total ?? 0;
|
||||
return internalSearch.value ? filteredRows.value.length : sortedRows.value.length;
|
||||
});
|
||||
|
||||
const from = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.from ?? 0;
|
||||
return total.value === 0 ? 0 : (currentPage.value - 1) * internalPageSize.value + 1;
|
||||
});
|
||||
|
||||
const to = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.to ?? 0;
|
||||
return Math.min(currentPage.value * internalPageSize.value, total.value);
|
||||
});
|
||||
|
||||
// Export functionality
|
||||
function handleExport(format) {
|
||||
const data = displayRows.value.map((row) => {
|
||||
const exported = {};
|
||||
props.columns.forEach((col) => {
|
||||
exported[col.label] = row?.[col.key] ?? '';
|
||||
});
|
||||
return exported;
|
||||
});
|
||||
|
||||
if (format === 'csv') {
|
||||
exportToCSV(data);
|
||||
} else if (format === 'xlsx') {
|
||||
exportToXLSX(data);
|
||||
}
|
||||
}
|
||||
|
||||
function exportToCSV(data) {
|
||||
if (data.length === 0) return;
|
||||
|
||||
const headers = Object.keys(data[0]);
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...data.map((row) =>
|
||||
headers
|
||||
.map((header) => {
|
||||
const value = row[header];
|
||||
if (value == null) return '';
|
||||
const stringValue = String(value).replace(/"/g, '""');
|
||||
return `"${stringValue}"`;
|
||||
})
|
||||
.join(',')
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function exportToXLSX(data) {
|
||||
// For XLSX, we'll use a CSV-like format or alert user to install xlsx library
|
||||
// Simple implementation: use CSV format with .xlsx extension
|
||||
exportToCSV(data);
|
||||
// In production, you might want to use a library like 'xlsx' or 'exceljs'
|
||||
}
|
||||
|
||||
// Generate visible page numbers with ellipsis
|
||||
function getVisiblePages() {
|
||||
const pages = [];
|
||||
const total = totalPages.value;
|
||||
const current = currentPage.value;
|
||||
const maxVisible = 7;
|
||||
|
||||
if (total <= maxVisible) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
// Calculate start and end
|
||||
let start = Math.max(2, current - 1);
|
||||
let end = Math.min(total - 1, current + 1);
|
||||
|
||||
// Adjust if near start
|
||||
if (current <= 3) {
|
||||
end = Math.min(4, total - 1);
|
||||
}
|
||||
|
||||
// Adjust if near end
|
||||
if (current >= total - 2) {
|
||||
start = Math.max(2, total - 3);
|
||||
}
|
||||
|
||||
// Add ellipsis after first page if needed
|
||||
if (start > 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Add middle pages
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
// Add ellipsis before last page if needed
|
||||
if (end < total - 1) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
if (total > 1) {
|
||||
pages.push(total);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-4">
|
||||
<!-- Toolbar -->
|
||||
<DataTableToolbar
|
||||
v-if="showToolbar"
|
||||
:search="internalSearch"
|
||||
:show-search="showSearch"
|
||||
:show-page-size="showPageSize"
|
||||
:page-size="internalPageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:selected-count="selectedRows.size"
|
||||
:show-selected-count="showSelectedCount"
|
||||
:show-export="showExport"
|
||||
:show-add="showAdd"
|
||||
:show-options="showOptions"
|
||||
:show-filters="showFilters"
|
||||
:show-options-menu="showOptionsMenu"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
:compact="compactToolbar"
|
||||
@update:search="handleSearchChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
@export="handleExport"
|
||||
>
|
||||
<template #add>
|
||||
<slot name="toolbar-add" />
|
||||
</template>
|
||||
<template #options>
|
||||
<slot name="toolbar-options" />
|
||||
</template>
|
||||
<template #filters>
|
||||
<slot name="toolbar-filters" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="toolbar-actions" />
|
||||
</template>
|
||||
</DataTableToolbar>
|
||||
|
||||
<!-- Table Container -->
|
||||
<div
|
||||
data-table-container
|
||||
class="relative overflow-hidden"
|
||||
>
|
||||
<!-- Desktop Table View -->
|
||||
<div v-if="!isMobile || !mobileCardView" class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<!-- Header -->
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- Select All Checkbox -->
|
||||
<th
|
||||
v-if="selectable"
|
||||
class="w-12 px-6 py-3 text-left"
|
||||
scope="col"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isAllSelected"
|
||||
:indeterminate="isSomeSelected && !isAllSelected"
|
||||
@change="toggleSelectAll"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</th>
|
||||
|
||||
<!-- Column Headers -->
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
:class="[
|
||||
col.class,
|
||||
col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : 'text-left',
|
||||
]"
|
||||
>
|
||||
<button
|
||||
v-if="col.sortable"
|
||||
type="button"
|
||||
class="group inline-flex items-center gap-1.5 hover:text-gray-700 transition-colors"
|
||||
@click="toggleSort(col)"
|
||||
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<FontAwesomeIcon
|
||||
:icon="getSortIcon(col)"
|
||||
class="w-3 h-3 transition-colors"
|
||||
:class="{
|
||||
'text-gray-700': sort?.key === col.key,
|
||||
'text-gray-400 group-hover:text-gray-500': sort?.key !== col.key,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<span v-else>{{ col.label }}</span>
|
||||
</th>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<th
|
||||
v-if="showActions || $slots.actions"
|
||||
scope="col"
|
||||
class="relative w-px px-6 py-3"
|
||||
>
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<!-- Body -->
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<tr>
|
||||
<td :colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)" class="px-6 py-4">
|
||||
<SkeletonTable :rows="5" :cols="columns.length" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template v-else-if="!loading && displayRows.length === 0">
|
||||
<tr>
|
||||
<td
|
||||
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
|
||||
class="px-6 py-12 text-center"
|
||||
>
|
||||
<EmptyState
|
||||
:icon="emptyIcon"
|
||||
:title="emptyText"
|
||||
:description="emptyDescription"
|
||||
size="sm"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Rows -->
|
||||
<template v-else>
|
||||
<tr
|
||||
v-for="(row, idx) in displayRows"
|
||||
:key="keyOf(row)"
|
||||
@click="(e) => { const interactive = e.target.closest('button, a, [role=button], [data-dropdown], .relative'); if (interactive) return; $emit('row:click', row, idx); }"
|
||||
class="transition-colors"
|
||||
:class="{
|
||||
'cursor-pointer': !!$attrs.onRowClick,
|
||||
'bg-gray-50': striped && idx % 2 === 1,
|
||||
'hover:bg-gray-50': hoverable && !selectedRows.has(keyOf(row)),
|
||||
'bg-primary-50': selectedRows.has(keyOf(row)),
|
||||
}"
|
||||
>
|
||||
<!-- Select Checkbox -->
|
||||
<td v-if="selectable" class="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedRows.has(keyOf(row))"
|
||||
@click.stop="toggleSelectRow(row)"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<!-- Data Cells -->
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
class="px-6 py-4 whitespace-nowrap text-sm"
|
||||
:class="[
|
||||
col.class,
|
||||
col.align === 'right'
|
||||
? 'text-right text-gray-900'
|
||||
: col.align === 'center'
|
||||
? 'text-center text-gray-900'
|
||||
: 'text-left text-gray-900',
|
||||
]"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${col.key}`"
|
||||
:row="row"
|
||||
:column="col"
|
||||
:value="row?.[col.key]"
|
||||
:index="idx"
|
||||
>
|
||||
<slot name="cell" :row="row" :column="col" :value="row?.[col.key]" :index="idx">
|
||||
<span>{{ row?.[col.key] ?? '—' }}</span>
|
||||
</slot>
|
||||
</slot>
|
||||
</td>
|
||||
|
||||
<!-- Actions Cell -->
|
||||
<td
|
||||
v-if="showActions || $slots.actions"
|
||||
class="relative whitespace-nowrap py-4 pl-3 pr-6 text-right text-sm font-medium"
|
||||
>
|
||||
<slot name="actions" :row="row" :index="idx">
|
||||
<slot name="row-actions" :row="row" :index="idx" />
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Card View -->
|
||||
<div v-else-if="isMobile && mobileCardView" class="divide-y divide-gray-200">
|
||||
<template v-if="loading">
|
||||
<div class="p-4">
|
||||
<SkeletonTable :rows="3" :cols="1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!loading && displayRows.length === 0">
|
||||
<div class="p-6">
|
||||
<EmptyState
|
||||
:icon="emptyIcon"
|
||||
:title="emptyText"
|
||||
:description="emptyDescription"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(row, idx) in displayRows"
|
||||
:key="keyOf(row)"
|
||||
@click="$emit('row:click', row, idx)"
|
||||
class="p-4 space-y-2 hover:bg-gray-50 transition-colors"
|
||||
:class="{ 'cursor-pointer': !!$attrs.onRowClick }"
|
||||
>
|
||||
<slot name="mobile-card" :row="row" :index="idx">
|
||||
<!-- Default mobile card layout -->
|
||||
<div
|
||||
v-for="col in columns.slice(0, 3)"
|
||||
:key="col.key"
|
||||
class="flex justify-between items-start"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
{{ col.label }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-900 text-right">
|
||||
<slot
|
||||
:name="`cell-${col.key}`"
|
||||
:row="row"
|
||||
:column="col"
|
||||
:value="row?.[col.key]"
|
||||
:index="idx"
|
||||
>
|
||||
{{ row?.[col.key] ?? '—' }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showActions || $slots.actions" class="pt-2 border-t border-gray-100">
|
||||
<slot name="actions" :row="row" :index="idx">
|
||||
<slot name="row-actions" :row="row" :index="idx" />
|
||||
</slot>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="showPagination && totalPages > 1">
|
||||
<!-- Use existing Pagination component for server-side -->
|
||||
<template v-if="isServerSide && meta?.links">
|
||||
<Pagination
|
||||
:links="meta.links"
|
||||
:from="from"
|
||||
:to="to"
|
||||
:total="total"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Custom pagination for client-side -->
|
||||
<template v-else>
|
||||
<div class="flex flex-1 justify-between sm:hidden">
|
||||
<button
|
||||
@click="handlePageChange(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Prejšnja
|
||||
</button>
|
||||
<button
|
||||
@click="handlePageChange(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Naslednja
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Prikazano
|
||||
<span class="font-medium">{{ from }}</span>
|
||||
do
|
||||
<span class="font-medium">{{ to }}</span>
|
||||
od
|
||||
<span class="font-medium">{{ total }}</span>
|
||||
rezultatov
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||
<button
|
||||
@click="handlePageChange(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="sr-only">Prejšnja</span>
|
||||
<FontAwesomeIcon :icon="faChevronLeft" class="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<template v-for="page in getVisiblePages()" :key="page">
|
||||
<button
|
||||
v-if="page !== '...'"
|
||||
@click="handlePageChange(page)"
|
||||
:aria-current="page === currentPage ? 'page' : undefined"
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20"
|
||||
:class="
|
||||
page === currentPage
|
||||
? 'z-10 bg-primary-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600'
|
||||
: 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0'
|
||||
"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<button
|
||||
@click="handlePageChange(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="sr-only">Naslednja</span>
|
||||
<FontAwesomeIcon :icon="faChevronRight" class="h-5 w-5" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
95
resources/js/Components/DataTable/DataTablePagination.vue
Normal file
95
resources/js/Components/DataTable/DataTablePagination.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
|
||||
const props = defineProps({
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
{{ table.getFilteredSelectedRowModel().rows.length }} of
|
||||
{{ table.getFilteredRowModel().rows.length }} row(s) selected.
|
||||
</div>
|
||||
<div class="flex items-center space-x-6 lg:space-x-8">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-sm font-medium">Rows per page</p>
|
||||
<Select
|
||||
:model-value="`${table.getState().pagination.pageSize}`"
|
||||
@update:model-value="(value) => table.setPageSize(Number(value))"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-[70px]">
|
||||
<SelectValue :placeholder="`${table.getState().pagination.pageSize}`" />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
<SelectItem
|
||||
v-for="pageSize in pageSizeOptions"
|
||||
:key="pageSize"
|
||||
:value="`${pageSize}`"
|
||||
>
|
||||
{{ pageSize }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Page {{ table.getState().pagination.pageIndex + 1 }} of
|
||||
{{ table.getPageCount() }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="hidden h-8 w-8 p-0 lg:flex"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.setPageIndex(0)"
|
||||
>
|
||||
<span class="sr-only">Go to first page</span>
|
||||
<ChevronsLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-8 w-8 p-0"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()"
|
||||
>
|
||||
<span class="sr-only">Go to previous page</span>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-8 w-8 p-0"
|
||||
:disabled="!table.getCanNextPage()"
|
||||
@click="table.nextPage()"
|
||||
>
|
||||
<span class="sr-only">Go to next page</span>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="hidden h-8 w-8 p-0 lg:flex"
|
||||
:disabled="!table.getCanNextPage()"
|
||||
@click="table.setPageIndex(table.getPageCount() - 1)"
|
||||
>
|
||||
<span class="sr-only">Go to last page</span>
|
||||
<ChevronsRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -1,318 +1,182 @@
|
|||
<script setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faSearch, faTimes, faDownload, faEllipsisVertical, faGear, faPlus, faFilter } from '@fortawesome/free-solid-svg-icons';
|
||||
import Dropdown from '../Dropdown.vue';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { computed, ref } from "vue";
|
||||
import { X, Settings2 } from "lucide-vue-next";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/Components/ui/popover';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
} from "@/Components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||
import DataTableViewOptions from "./DataTableViewOptions.vue";
|
||||
|
||||
/**
|
||||
* DataTable Toolbar Component
|
||||
* Simplified toolbar following shadcn-vue patterns for TanStack Table integration
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
search: { type: String, default: '' },
|
||||
showSearch: { type: Boolean, default: false },
|
||||
showPageSize: { type: Boolean, default: false },
|
||||
pageSize: { type: Number, default: 10 },
|
||||
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
|
||||
selectedCount: { type: Number, default: 0 },
|
||||
showSelectedCount: { type: Boolean, default: false }, // Control visibility of selected count badge
|
||||
showExport: { type: Boolean, default: false },
|
||||
showAdd: { type: Boolean, default: false }, // Control visibility of add buttons dropdown
|
||||
showOptions: { type: Boolean, default: false }, // Control visibility of custom options slot
|
||||
showFilters: { type: Boolean, default: false }, // Control visibility of filters button
|
||||
showOptionsMenu: { type: Boolean, default: false }, // Control visibility of options menu (three dots)
|
||||
compact: { type: Boolean, default: false }, // New prop to toggle compact menu mode
|
||||
hasActiveFilters: { type: Boolean, default: false }, // External indicator for active filters
|
||||
// TanStack Table instance
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// Column to filter on (e.g., 'email', 'name')
|
||||
filterColumn: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
// Placeholder text for filter input
|
||||
filterPlaceholder: {
|
||||
type: String,
|
||||
default: "Filter...",
|
||||
},
|
||||
// Show view options (column visibility toggle)
|
||||
showViewOptions: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// Show per-page selector
|
||||
showPerPageSelector: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Current per page value
|
||||
perPage: {
|
||||
type: Number,
|
||||
default: 15,
|
||||
},
|
||||
// Per page options
|
||||
pageSizeOptions: {
|
||||
type: Array,
|
||||
default: () => [10, 15, 25, 50, 100],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:search', 'update:page-size', 'export']);
|
||||
const emit = defineEmits(["update:perPage"]);
|
||||
|
||||
const internalSearch = ref(props.search);
|
||||
const menuOpen = ref(false);
|
||||
// Popover state
|
||||
const settingsPopoverOpen = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.search,
|
||||
(newVal) => {
|
||||
internalSearch.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return !!internalSearch.value || props.selectedCount > 0;
|
||||
// Check if any filters are active
|
||||
const isFiltered = computed(() => {
|
||||
if (!props.filterColumn) return false;
|
||||
const column = props.table.getColumn(props.filterColumn);
|
||||
return column && column.getFilterValue();
|
||||
});
|
||||
|
||||
function clearSearch() {
|
||||
internalSearch.value = '';
|
||||
emit('update:search', '');
|
||||
}
|
||||
// Get/set filter value
|
||||
const filterValue = computed({
|
||||
get() {
|
||||
if (!props.filterColumn) return "";
|
||||
const column = props.table.getColumn(props.filterColumn);
|
||||
return column?.getFilterValue() ?? "";
|
||||
},
|
||||
set(value) {
|
||||
if (!props.filterColumn) return;
|
||||
const column = props.table.getColumn(props.filterColumn);
|
||||
column?.setFilterValue(value);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSearchInput() {
|
||||
emit('update:search', internalSearch.value);
|
||||
}
|
||||
|
||||
function handlePageSizeChange(value) {
|
||||
emit('update:page-size', Number(value));
|
||||
}
|
||||
|
||||
function handleExport(format) {
|
||||
emit('export', format);
|
||||
menuOpen.value = false;
|
||||
// Reset all filters
|
||||
function resetFilters() {
|
||||
props.table.resetColumnFilters();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<!-- Left side: Search and Add buttons dropdown -->
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<!-- Search (always visible if showSearch is true) -->
|
||||
<div v-if="showSearch && !compact" class="flex-1 max-w-sm">
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 z-10">
|
||||
<FontAwesomeIcon :icon="faSearch" class="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
v-model="internalSearch"
|
||||
@input="handleSearchInput"
|
||||
type="text"
|
||||
placeholder="Iskanje..."
|
||||
class="pl-10"
|
||||
:class="internalSearch ? 'pr-10' : ''"
|
||||
/>
|
||||
<Button
|
||||
v-if="internalSearch"
|
||||
@click="clearSearch"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="absolute inset-y-0 right-0 h-full w-auto px-3 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTimes" class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Left side: Search and Filters -->
|
||||
<div class="flex flex-1 items-center space-x-2">
|
||||
<!-- Filter Input -->
|
||||
<Input
|
||||
v-if="filterColumn"
|
||||
v-model="filterValue"
|
||||
:placeholder="filterPlaceholder"
|
||||
class="h-8 w-[150px] lg:w-[250px]"
|
||||
/>
|
||||
|
||||
<!-- Add buttons dropdown (after search input) -->
|
||||
<Dropdown v-if="$slots.add && showAdd && !compact" align="left">
|
||||
<template #trigger>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4" />
|
||||
<span class="sr-only">Dodaj</span>
|
||||
</Button>
|
||||
</template>
|
||||
<template #content>
|
||||
<slot name="add" />
|
||||
</template>
|
||||
</Dropdown>
|
||||
<!-- Custom filter slots -->
|
||||
<slot name="filters" :table="table" />
|
||||
|
||||
<!-- Custom options dropdown (after search input and add buttons) -->
|
||||
<div v-if="$slots.options && showOptions && !compact" class="flex items-center">
|
||||
<slot name="options" />
|
||||
</div>
|
||||
<!-- Reset filters button -->
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
@click="resetFilters"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
>
|
||||
Reset
|
||||
<X class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Filters button (after options, before right side) -->
|
||||
<Popover v-if="showFilters && $slots.filters && !compact">
|
||||
<!-- Right side: Actions and View Options -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Custom action slots -->
|
||||
<slot name="actions" :table="table" />
|
||||
|
||||
<!-- Settings Popover (Per-page selector + View Options) -->
|
||||
<Popover v-model:open="settingsPopoverOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="relative">
|
||||
<FontAwesomeIcon :icon="faFilter" class="h-4 w-4" />
|
||||
<span
|
||||
v-if="hasActiveFilters"
|
||||
class="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary-600"
|
||||
></span>
|
||||
<span class="sr-only">Filtri</span>
|
||||
<Button variant="outline" size="sm" class="gap-2">
|
||||
<Settings2 class="h-4 w-4" />
|
||||
Pogled
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-4" align="start">
|
||||
<slot name="filters" />
|
||||
<PopoverContent class="w-[300px]" align="end">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium text-sm">Nastavitve pogleda</h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Per page selector -->
|
||||
<div
|
||||
v-if="showPerPageSelector"
|
||||
class="flex items-center justify-between gap-4"
|
||||
>
|
||||
<label class="text-sm whitespace-nowrap">Elementov na stran</label>
|
||||
<Select
|
||||
:model-value="String(perPage)"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
emit('update:perPage', Number(value));
|
||||
settingsPopoverOpen = false;
|
||||
}
|
||||
"
|
||||
>
|
||||
<SelectTrigger class="h-9 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem
|
||||
v-for="size in pageSizeOptions"
|
||||
:key="size"
|
||||
:value="String(size)"
|
||||
>
|
||||
{{ size }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Column visibility -->
|
||||
<div v-if="showViewOptions" class="flex items-center justify-between gap-4">
|
||||
<label class="text-sm whitespace-nowrap">Vidnost stolpcev</label>
|
||||
<DataTableViewOptions
|
||||
:table="table"
|
||||
@column-toggle="settingsPopoverOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Selected count, Page size, Menu & Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Selected count badge -->
|
||||
<div
|
||||
v-if="selectedCount > 0 && showSelectedCount"
|
||||
class="inline-flex items-center rounded-md bg-primary-50 px-3 py-1.5 text-sm font-medium text-primary-700"
|
||||
>
|
||||
{{ selectedCount }} izbran{{ selectedCount === 1 ? 'o' : 'ih' }}
|
||||
</div>
|
||||
|
||||
<!-- Page size selector (visible when not in compact mode) -->
|
||||
<div v-if="showPageSize && !compact" class="flex items-center gap-2">
|
||||
<Label for="page-size" class="text-sm text-gray-600 whitespace-nowrap">Na stran:</Label>
|
||||
<Select
|
||||
:model-value="String(pageSize)"
|
||||
@update:model-value="handlePageSizeChange"
|
||||
>
|
||||
<SelectTrigger id="page-size" class="w-[100px]">
|
||||
<SelectValue :placeholder="String(pageSize)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in pageSizeOptions"
|
||||
:key="opt"
|
||||
:value="String(opt)"
|
||||
>
|
||||
{{ opt }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Table Options Menu (compact mode or always as dropdown) -->
|
||||
<DropdownMenu v-if="showOptionsMenu" v-model:open="menuOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:class="hasActiveFilters && !compact ? 'relative' : ''"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4" />
|
||||
<span
|
||||
v-if="hasActiveFilters && !compact"
|
||||
class="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary-600"
|
||||
></span>
|
||||
<span class="sr-only">Možnosti tabele</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-56">
|
||||
<!-- Search in menu (only in compact mode) -->
|
||||
<div v-if="compact && showSearch" class="p-2 border-b">
|
||||
<Label for="menu-search" class="text-xs font-medium mb-1.5 block">Iskanje</Label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5 z-10">
|
||||
<FontAwesomeIcon :icon="faSearch" class="h-3.5 w-3.5 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
id="menu-search"
|
||||
v-model="internalSearch"
|
||||
@input="handleSearchInput"
|
||||
type="text"
|
||||
placeholder="Iskanje..."
|
||||
class="pl-8 h-8 text-sm"
|
||||
:class="internalSearch ? 'pr-8' : ''"
|
||||
/>
|
||||
<Button
|
||||
v-if="internalSearch"
|
||||
@click="clearSearch"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="absolute inset-y-0 right-0 h-full w-auto px-2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTimes" class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page size in menu (only in compact mode) -->
|
||||
<div v-if="compact && showPageSize" class="p-2 border-b">
|
||||
<Label for="menu-page-size" class="text-xs font-medium mb-1.5 block">Elementov na stran</Label>
|
||||
<Select
|
||||
:model-value="String(pageSize)"
|
||||
@update:model-value="handlePageSizeChange"
|
||||
>
|
||||
<SelectTrigger id="menu-page-size" class="w-full h-8 text-sm">
|
||||
<SelectValue :placeholder="String(pageSize)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in pageSizeOptions"
|
||||
:key="opt"
|
||||
:value="String(opt)"
|
||||
>
|
||||
{{ opt }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Export options -->
|
||||
<template v-if="showExport">
|
||||
<DropdownMenuLabel>Izvozi</DropdownMenuLabel>
|
||||
<DropdownMenuItem @select="handleExport('csv')">
|
||||
<FontAwesomeIcon :icon="faDownload" class="mr-2 h-4 w-4" />
|
||||
CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @select="handleExport('xlsx')">
|
||||
<FontAwesomeIcon :icon="faDownload" class="mr-2 h-4 w-4" />
|
||||
Excel
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
<!-- Custom actions slot in menu -->
|
||||
<template v-if="$slots.actions">
|
||||
<DropdownMenuSeparator />
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<template v-else>
|
||||
<!-- If options menu is hidden but we have content to show, render it inline -->
|
||||
<div v-if="showExport && !compact" class="flex items-center gap-2">
|
||||
<Dropdown v-if="showExport" align="right">
|
||||
<template #trigger>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="gap-2"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4" />
|
||||
Izvozi
|
||||
</Button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
@click="handleExport('csv')"
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleExport('xlsx')"
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Excel
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Custom actions slot (visible when not in compact mode) -->
|
||||
<div v-if="$slots.actions && !compact" class="flex items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import DataTableToolbar from './DataTableToolbar.vue';
|
||||
|
||||
// Example: Using DataTableToolbar standalone
|
||||
const search = ref('');
|
||||
const pageSize = ref(10);
|
||||
const selectedCount = ref(0);
|
||||
|
||||
const handleSearchChange = (value) => {
|
||||
search.value = value;
|
||||
console.log('Search changed:', value);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (value) => {
|
||||
pageSize.value = value;
|
||||
console.log('Page size changed:', value);
|
||||
};
|
||||
|
||||
const handleExport = (format) => {
|
||||
console.log('Export:', format);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
console.log('Add button clicked');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Standalone DataTableToolbar -->
|
||||
<DataTableToolbar
|
||||
:search="search"
|
||||
:show-search="true"
|
||||
:show-page-size="true"
|
||||
:page-size="pageSize"
|
||||
:selected-count="selectedCount"
|
||||
:show-selected-count="true"
|
||||
:show-export="true"
|
||||
:show-add="true"
|
||||
:show-filters="true"
|
||||
@update:search="handleSearchChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
@export="handleExport"
|
||||
>
|
||||
<!-- Add button dropdown content -->
|
||||
<template #add>
|
||||
<button
|
||||
@click="handleAdd"
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Dodaj novo
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Custom options -->
|
||||
<template #options>
|
||||
<button class="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded">
|
||||
Opcija 1
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Filters -->
|
||||
<template #filters>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Filtriraj po:</label>
|
||||
<input type="text" class="w-full px-2 py-1 border rounded" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Custom actions -->
|
||||
<template #actions>
|
||||
<button class="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded">
|
||||
Akcija
|
||||
</button>
|
||||
</template>
|
||||
</DataTableToolbar>
|
||||
|
||||
<!-- Your content here -->
|
||||
<div class="p-4 bg-gray-50 rounded">
|
||||
<p>Search: {{ search }}</p>
|
||||
<p>Page Size: {{ pageSize }}</p>
|
||||
<p>Selected: {{ selectedCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
50
resources/js/Components/DataTable/DataTableViewOptions.vue
Normal file
50
resources/js/Components/DataTable/DataTableViewOptions.vue
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { Settings } from "lucide-vue-next";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
|
||||
const props = defineProps({
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const columns = computed(() =>
|
||||
props.table
|
||||
.getAllColumns()
|
||||
.filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide())
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="ml-auto hidden h-8 lg:flex">
|
||||
<Settings class="mr-2 h-4 w-4" />
|
||||
Pogled
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-[150px]">
|
||||
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="column in columns"
|
||||
:key="column.id"
|
||||
class="capitalize"
|
||||
:model-value="column.getIsVisible()"
|
||||
@update:model-value="(value) => column.toggleVisibility(!!value)"
|
||||
>
|
||||
{{ column.id }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
291
resources/js/Components/DataTable/MIGRATION.md
Normal file
291
resources/js/Components/DataTable/MIGRATION.md
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
# DataTable Migration Guide
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
The DataTable component has been updated to follow **shadcn-vue** architecture patterns using **TanStack Table v8**. This provides better flexibility, more features, and follows industry-standard patterns.
|
||||
|
||||
## What's New
|
||||
|
||||
### ✅ Components Created/Updated
|
||||
|
||||
1. **`DataTableNew2.vue`** - New main component with shadcn-vue architecture
|
||||
2. **`DataTableColumnHeader.vue`** - Already good, uses lucide-vue-next icons
|
||||
3. **`DataTablePagination.vue`** - Already follows shadcn-vue patterns
|
||||
4. **`DataTableViewOptions.vue`** - Already follows shadcn-vue patterns
|
||||
5. **`DataTableToolbar.vue`** - Already exists with advanced features
|
||||
6. **`columns-example.js`** - Column definition examples
|
||||
7. **`README.md`** - Comprehensive documentation
|
||||
8. **`DataTableExample.vue`** - Working example page
|
||||
|
||||
### ✅ Utilities Added
|
||||
|
||||
- **`valueUpdater()`** in `lib/utils.js` - Helper for TanStack Table state management
|
||||
|
||||
## Key Improvements
|
||||
|
||||
### 1. **FlexRender Integration**
|
||||
Now properly uses TanStack Table's FlexRender for column headers and cells:
|
||||
```vue
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
```
|
||||
|
||||
### 2. **Better Column Definitions**
|
||||
Supports both simple and advanced formats:
|
||||
|
||||
**Simple:**
|
||||
```javascript
|
||||
{ key: 'name', label: 'Name', sortable: true }
|
||||
```
|
||||
|
||||
**Advanced:**
|
||||
```javascript
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => h(DataTableColumnHeader, { column, title: 'Name' }),
|
||||
cell: ({ row }) => h('div', {}, row.getValue('name')),
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **Enhanced Features**
|
||||
- ✅ Row selection with checkboxes
|
||||
- ✅ Column visibility toggle
|
||||
- ✅ Advanced filtering
|
||||
- ✅ Better loading/empty states
|
||||
- ✅ Custom cell slots
|
||||
- ✅ Flexible toolbar
|
||||
|
||||
### 4. **Better State Management**
|
||||
Uses `valueUpdater()` helper for proper Vue reactivity with TanStack Table:
|
||||
```javascript
|
||||
onSortingChange: (updater) => valueUpdater(updater, sorting)
|
||||
```
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### Step 1: Update Imports
|
||||
|
||||
**Before:**
|
||||
```vue
|
||||
import DataTable from '@/Components/DataTable/DataTable.vue';
|
||||
```
|
||||
|
||||
**After:**
|
||||
```vue
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
```
|
||||
|
||||
### Step 2: Update Props
|
||||
|
||||
**Before:**
|
||||
```vue
|
||||
<DataTable
|
||||
:rows="clients.data"
|
||||
:columns="columns"
|
||||
:meta="clients.meta"
|
||||
/>
|
||||
```
|
||||
|
||||
**After:**
|
||||
```vue
|
||||
<DataTable
|
||||
:data="clients.data"
|
||||
:columns="columns"
|
||||
:meta="clients.meta"
|
||||
route-name="clients.index"
|
||||
/>
|
||||
```
|
||||
|
||||
Main prop changes:
|
||||
- `rows` → `data`
|
||||
- Add `route-name` for server-side pagination
|
||||
|
||||
### Step 3: Column Definitions
|
||||
|
||||
Your existing simple column format still works:
|
||||
```javascript
|
||||
const columns = [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
];
|
||||
```
|
||||
|
||||
But you can now use advanced format for more control:
|
||||
```javascript
|
||||
import { h } from 'vue';
|
||||
import DataTableColumnHeader from '@/Components/DataTable/DataTableColumnHeader.vue';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => h(DataTableColumnHeader, { column, title: 'Name' }),
|
||||
cell: ({ row }) => h('div', { class: 'font-medium' }, row.getValue('name')),
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### Step 4: Custom Cell Rendering
|
||||
|
||||
**Before:** Required editing component
|
||||
**After:** Use slots!
|
||||
|
||||
```vue
|
||||
<DataTable :columns="columns" :data="data">
|
||||
<template #cell-status="{ value, row }">
|
||||
<Badge :variant="value === 'active' ? 'default' : 'secondary'">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</template>
|
||||
</DataTable>
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The **old DataTable components are still available**:
|
||||
- `DataTable.vue` - Your current enhanced version
|
||||
- `DataTableServer.vue` - Your server-side version
|
||||
- `DataTableOld.vue` - Original version
|
||||
|
||||
You can migrate pages gradually. Both old and new can coexist.
|
||||
|
||||
## Example Migration
|
||||
|
||||
### Before (Client/Index.vue)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import DataTable from '@/Components/DataTable/DataTable.vue';
|
||||
|
||||
const props = defineProps({
|
||||
clients: Object,
|
||||
filters: Object,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:rows="clients.data"
|
||||
:columns="columns"
|
||||
:meta="clients.meta"
|
||||
:search="filters.search"
|
||||
:sort="filters.sort"
|
||||
route-name="clients.index"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### After (Using DataTableNew2)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
|
||||
const props = defineProps({
|
||||
clients: Object,
|
||||
filters: Object,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'status', label: 'Status', sortable: false },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:data="clients.data"
|
||||
:columns="columns"
|
||||
:meta="clients.meta"
|
||||
:search="filters.search"
|
||||
:sort="filters.sort"
|
||||
route-name="clients.index"
|
||||
filter-column="email"
|
||||
filter-placeholder="Search clients..."
|
||||
:only-props="['clients']"
|
||||
>
|
||||
<!-- Add custom cell rendering -->
|
||||
<template #cell-status="{ value }">
|
||||
<Badge :variant="value === 'active' ? 'default' : 'secondary'">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Testing Your Migration
|
||||
|
||||
1. **Check the example page:**
|
||||
```
|
||||
Visit: /examples/datatable
|
||||
```
|
||||
(You'll need to add a route for this)
|
||||
|
||||
2. **Test features:**
|
||||
- ✅ Sorting (click column headers)
|
||||
- ✅ Filtering (use search input)
|
||||
- ✅ Pagination (navigate pages)
|
||||
- ✅ Row selection (if enabled)
|
||||
- ✅ Column visibility (View button)
|
||||
|
||||
3. **Check browser console:**
|
||||
- No errors
|
||||
- Events firing correctly
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Issue: "FlexRender is not defined"
|
||||
**Solution:** Make sure you imported it:
|
||||
```javascript
|
||||
import { FlexRender } from '@tanstack/vue-table';
|
||||
```
|
||||
|
||||
### Issue: Column not sorting
|
||||
**Solution:** Make sure `sortable: true` is set:
|
||||
```javascript
|
||||
{ key: 'name', label: 'Name', sortable: true }
|
||||
```
|
||||
|
||||
### Issue: Server-side not working
|
||||
**Solution:** Provide both `meta` and `route-name`:
|
||||
```vue
|
||||
<DataTable
|
||||
:data="data"
|
||||
:meta="meta"
|
||||
route-name="your.route.name"
|
||||
/>
|
||||
```
|
||||
|
||||
### Issue: Custom cells not rendering
|
||||
**Solution:** Use the correct slot name format:
|
||||
```vue
|
||||
<template #cell-columnKey="{ value, row }">
|
||||
<!-- Your content -->
|
||||
</template>
|
||||
```
|
||||
|
||||
## Need Help?
|
||||
|
||||
1. Check `README.md` for detailed documentation
|
||||
2. Look at `columns-example.js` for column patterns
|
||||
3. Review `DataTableExample.vue` for working examples
|
||||
4. Check TanStack Table docs: https://tanstack.com/table/v8
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If you encounter issues, you can always use the old components:
|
||||
```vue
|
||||
import DataTable from '@/Components/DataTable/DataTable.vue';
|
||||
// or
|
||||
import DataTableServer from '@/Components/DataTable/DataTableServer.vue';
|
||||
```
|
||||
|
||||
Nothing breaks your existing code!
|
||||
390
resources/js/Components/DataTable/README.md
Normal file
390
resources/js/Components/DataTable/README.md
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
# DataTable Component - Usage Guide
|
||||
|
||||
This DataTable component follows the shadcn-vue architecture and uses TanStack Table v8 for powerful table functionality.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Client-side and server-side pagination
|
||||
- ✅ Sorting (single column)
|
||||
- ✅ Filtering/Search
|
||||
- ✅ Row selection
|
||||
- ✅ Column visibility toggle
|
||||
- ✅ Customizable column definitions
|
||||
- ✅ Loading states
|
||||
- ✅ Empty states
|
||||
- ✅ Flexible toolbar
|
||||
- ✅ Cell-level customization via slots
|
||||
- ✅ Responsive design
|
||||
- ✅ Laravel Inertia integration
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Simple Format (Recommended for basic tables)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
|
||||
const columns = [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'status', label: 'Status' },
|
||||
];
|
||||
|
||||
const data = ref([
|
||||
{ id: 1, name: 'John Doe', email: 'john@example.com', status: 'Active' },
|
||||
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', status: 'Inactive' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data="data" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### Advanced Format (Full TanStack Table power)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { h } from 'vue';
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { columns } from './columns'; // Import from separate file
|
||||
|
||||
const data = ref([...]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data="data" />
|
||||
</template>
|
||||
```
|
||||
|
||||
See `columns-example.js` for comprehensive column definition examples.
|
||||
|
||||
## Props
|
||||
|
||||
### Data Props
|
||||
- `columns` (Array, required) - Column definitions (simple or TanStack format)
|
||||
- `data` (Array, default: []) - Array of data objects
|
||||
- `meta` (Object, default: null) - Laravel pagination meta for server-side
|
||||
- `loading` (Boolean, default: false) - Loading state
|
||||
|
||||
### Server-side Props
|
||||
- `routeName` (String) - Laravel route name for server-side requests
|
||||
- `routeParams` (Object) - Additional route parameters
|
||||
- `pageParamName` (String, default: 'page') - Custom page parameter name
|
||||
- `onlyProps` (Array) - Inertia.js only props
|
||||
- `preserveState` (Boolean, default: true)
|
||||
- `preserveScroll` (Boolean, default: true)
|
||||
|
||||
### Sorting & Filtering
|
||||
- `sort` (Object, default: {key: null, direction: null})
|
||||
- `search` (String, default: '')
|
||||
- `filterColumn` (String) - Column to filter on
|
||||
- `filterPlaceholder` (String, default: 'Filter...')
|
||||
|
||||
### Pagination
|
||||
- `showPagination` (Boolean, default: true)
|
||||
- `pageSize` (Number, default: 10)
|
||||
- `pageSizeOptions` (Array, default: [10, 25, 50, 100])
|
||||
|
||||
### Features
|
||||
- `enableRowSelection` (Boolean, default: false)
|
||||
- `showToolbar` (Boolean, default: true)
|
||||
- `striped` (Boolean, default: false)
|
||||
- `hoverable` (Boolean, default: true)
|
||||
- `rowKey` (String|Function, default: 'id')
|
||||
|
||||
### Empty State
|
||||
- `emptyText` (String, default: 'No results.')
|
||||
- `emptyIcon` (String|Object|Array)
|
||||
- `emptyDescription` (String)
|
||||
|
||||
## Events
|
||||
|
||||
- `@update:search` - Emitted when search changes
|
||||
- `@update:sort` - Emitted when sort changes
|
||||
- `@update:page` - Emitted when page changes
|
||||
- `@update:pageSize` - Emitted when page size changes
|
||||
- `@row:click` - Emitted when row is clicked
|
||||
- `@selection:change` - Emitted when selection changes
|
||||
|
||||
## Client-side Example
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
|
||||
const columns = [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
];
|
||||
|
||||
const data = ref([
|
||||
// Your data here
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:page-size="10"
|
||||
filter-column="email"
|
||||
filter-placeholder="Filter emails..."
|
||||
enable-row-selection
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Server-side Example (Laravel Inertia)
|
||||
|
||||
### Controller
|
||||
```php
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Client::query();
|
||||
|
||||
// Search
|
||||
if ($request->search) {
|
||||
$query->where('name', 'like', "%{$request->search}%")
|
||||
->orWhere('email', 'like', "%{$request->search}%");
|
||||
}
|
||||
|
||||
// Sort
|
||||
if ($request->sort && $request->direction) {
|
||||
$query->orderBy($request->sort, $request->direction);
|
||||
}
|
||||
|
||||
$clients = $query->paginate($request->per_page ?? 10);
|
||||
|
||||
return Inertia::render('Clients/Index', [
|
||||
'clients' => $clients,
|
||||
'filters' => $request->only(['search', 'sort', 'direction']),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
### Vue Component
|
||||
```vue
|
||||
<script setup>
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
|
||||
const props = defineProps({
|
||||
clients: Object,
|
||||
filters: Object,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="clients.data"
|
||||
:meta="clients.meta"
|
||||
:search="filters.search"
|
||||
:sort="{ key: filters.sort, direction: filters.direction }"
|
||||
route-name="clients.index"
|
||||
filter-column="email"
|
||||
filter-placeholder="Search clients..."
|
||||
:only-props="['clients']"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Custom Cell Rendering
|
||||
|
||||
### Using Slots
|
||||
```vue
|
||||
<template>
|
||||
<DataTable :columns="columns" :data="data">
|
||||
<!-- Custom cell for status column -->
|
||||
<template #cell-status="{ value, row }">
|
||||
<Badge :variant="value === 'active' ? 'default' : 'secondary'">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<!-- Custom cell for actions -->
|
||||
<template #cell-actions="{ row }">
|
||||
<Button @click="editRow(row)">Edit</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Using Column Definitions
|
||||
```javascript
|
||||
import { h } from 'vue';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status');
|
||||
return h(Badge, {
|
||||
variant: status === 'active' ? 'default' : 'secondary'
|
||||
}, () => status);
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Custom Toolbar
|
||||
|
||||
The new toolbar is simplified and follows shadcn-vue patterns:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
filter-column="email"
|
||||
filter-placeholder="Search emails..."
|
||||
>
|
||||
<!-- Add custom filter controls -->
|
||||
<template #toolbar-filters="{ table }">
|
||||
<select
|
||||
@change="table.getColumn('status')?.setFilterValue($event.target.value)"
|
||||
class="h-8 rounded-md border px-3"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<!-- Add custom action buttons -->
|
||||
<template #toolbar-actions="{ table }">
|
||||
<Button @click="exportData">Export</Button>
|
||||
<Button @click="addNew">Add New</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
Or completely replace the toolbar:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<DataTable :columns="columns" :data="data" :show-toolbar="false">
|
||||
<template #toolbar="{ table }">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Input
|
||||
:model-value="table.getColumn('email')?.getFilterValue()"
|
||||
@update:model-value="table.getColumn('email')?.setFilterValue($event)"
|
||||
placeholder="Filter emails..."
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Button @click="exportData">Export</Button>
|
||||
<DataTableViewOptions :table="table" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Row Selection
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
|
||||
|
||||
const selectedRows = ref([]);
|
||||
|
||||
function handleSelectionChange(keys) {
|
||||
selectedRows.value = keys;
|
||||
console.log('Selected rows:', keys);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
enable-row-selection
|
||||
@selection:change="handleSelectionChange"
|
||||
/>
|
||||
|
||||
<div v-if="selectedRows.length">
|
||||
Selected {{ selectedRows.length }} row(s)
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Row Click Handler
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
function handleRowClick(row, index) {
|
||||
console.log('Clicked row:', row);
|
||||
// Navigate or perform action
|
||||
router.visit(route('clients.show', row.id));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
@row:click="handleRowClick"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Column Keys**: Always use consistent keys/accessorKeys across your data
|
||||
2. **Server-side**: Always provide `meta` and `routeName` props together
|
||||
3. **Performance**: For large datasets, use server-side pagination
|
||||
4. **Styling**: Use column `class` property for custom styling
|
||||
5. **Slots**: Prefer slots for complex cell rendering over h() functions
|
||||
|
||||
## Migration from Old DataTable
|
||||
|
||||
### Before (Old API)
|
||||
```vue
|
||||
<DataTable
|
||||
:rows="clients.data"
|
||||
:columns="columns"
|
||||
:meta="clients.meta"
|
||||
/>
|
||||
```
|
||||
|
||||
### After (New API)
|
||||
```vue
|
||||
<DataTableNew2
|
||||
:data="clients.data"
|
||||
:columns="columns"
|
||||
:meta="clients.meta"
|
||||
route-name="clients.index"
|
||||
/>
|
||||
```
|
||||
|
||||
Main changes:
|
||||
- `rows` → `data`
|
||||
- Added `route-name` prop for server-side
|
||||
- More consistent prop naming
|
||||
- Better TypeScript support
|
||||
- More flexible column definitions
|
||||
|
||||
## Component Files
|
||||
|
||||
- `DataTableNew2.vue` - Main table component
|
||||
- `DataTableColumnHeader.vue` - Sortable column header
|
||||
- `DataTablePagination.vue` - Pagination controls
|
||||
- `DataTableViewOptions.vue` - Column visibility toggle
|
||||
- `DataTableToolbar.vue` - Toolbar component
|
||||
- `columns-example.js` - Column definition examples
|
||||
|
|
@ -1,31 +1,36 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Dropdown from '../Dropdown.vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faEllipsisVertical } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faEllipsisVertical } from "@fortawesome/free-solid-svg-icons";
|
||||
import Button from "../ui/button/Button.vue";
|
||||
|
||||
const props = defineProps({
|
||||
align: {
|
||||
type: String,
|
||||
default: 'right', // left, right
|
||||
validator: (v) => ['left', 'right'].includes(v),
|
||||
default: "right", // left, right
|
||||
validator: (v) => ["left", "right"].includes(v),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md', // sm, md
|
||||
validator: (v) => ['sm', 'md'].includes(v),
|
||||
default: "md", // sm, md
|
||||
validator: (v) => ["sm", "md"].includes(v),
|
||||
},
|
||||
});
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
sm: "h-6 w-6",
|
||||
md: "h-8 w-8",
|
||||
};
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
const emit = defineEmits(["action"]);
|
||||
|
||||
function handleAction(action) {
|
||||
emit('action', action);
|
||||
emit("action", action);
|
||||
if (action.onClick) {
|
||||
action.onClick();
|
||||
}
|
||||
|
|
@ -33,23 +38,14 @@ function handleAction(action) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown :align="align" :content-classes="['py-1']">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'inline-flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 transition-colors',
|
||||
sizeClasses[size],
|
||||
]"
|
||||
aria-label="Actions"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" aria-label="Actions">
|
||||
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent :align="align === 'right' ? 'end' : 'start'" class="py-1">
|
||||
<slot :handle-action="handleAction" />
|
||||
</template>
|
||||
</Dropdown>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
||||
|
||||
|
|
|
|||
267
resources/js/Components/DataTable/columns-example.js
Normal file
267
resources/js/Components/DataTable/columns-example.js
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import { h } from 'vue';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Checkbox } from '@/Components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, ArrowUpDown } from 'lucide-vue-next';
|
||||
|
||||
/**
|
||||
* Example columns definition following shadcn-vue DataTable patterns
|
||||
*
|
||||
* Usage:
|
||||
* import { columns } from './columns'
|
||||
* <DataTable :columns="columns" :data="data" />
|
||||
*
|
||||
* This is a TypeScript-like example for JavaScript.
|
||||
* The columns follow TanStack Table's ColumnDef format.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simple format - automatically converted to ColumnDef
|
||||
* Use this for basic tables
|
||||
*/
|
||||
export const simpleColumns = [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'status', label: 'Status', sortable: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Advanced format - full TanStack Table ColumnDef
|
||||
* Use this for custom rendering, formatting, etc.
|
||||
*/
|
||||
export const advancedColumns = [
|
||||
// Selection column (added automatically if enableRowSelection prop is true)
|
||||
// {
|
||||
// id: 'select',
|
||||
// header: ({ table }) => {
|
||||
// return h(Checkbox, {
|
||||
// modelValue: table.getIsAllPageRowsSelected(),
|
||||
// indeterminate: table.getIsSomePageRowsSelected(),
|
||||
// 'onUpdate:modelValue': (value) => table.toggleAllPageRowsSelected(!!value),
|
||||
// 'aria-label': 'Select all',
|
||||
// });
|
||||
// },
|
||||
// cell: ({ row }) => {
|
||||
// return h(Checkbox, {
|
||||
// modelValue: row.getIsSelected(),
|
||||
// 'onUpdate:modelValue': (value) => row.toggleSelected(!!value),
|
||||
// 'aria-label': 'Select row',
|
||||
// });
|
||||
// },
|
||||
// enableSorting: false,
|
||||
// enableHiding: false,
|
||||
// },
|
||||
|
||||
// ID column
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['ID', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'w-20 font-medium' }, row.getValue('id'));
|
||||
},
|
||||
},
|
||||
|
||||
// Name column
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'font-medium' }, row.getValue('name'));
|
||||
},
|
||||
},
|
||||
|
||||
// Email column with custom rendering
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Email', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'lowercase' }, row.getValue('email'));
|
||||
},
|
||||
},
|
||||
|
||||
// Amount column with formatting
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: () => h('div', { class: 'text-right' }, 'Amount'),
|
||||
cell: ({ row }) => {
|
||||
const amount = parseFloat(row.getValue('amount'));
|
||||
const formatted = new Intl.NumberFormat('sl-SI', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
|
||||
return h('div', { class: 'text-right font-medium' }, formatted);
|
||||
},
|
||||
},
|
||||
|
||||
// Status column with badge
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status');
|
||||
const variants = {
|
||||
success: 'default',
|
||||
pending: 'secondary',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
return h(
|
||||
Badge,
|
||||
{
|
||||
variant: variants[status] || 'outline',
|
||||
},
|
||||
() => status
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// Actions column
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-right' },
|
||||
h(
|
||||
DropdownMenu,
|
||||
{},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
DropdownMenuTrigger,
|
||||
{ asChild: true },
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
class: 'h-8 w-8 p-0',
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h('span', { class: 'sr-only' }, 'Open menu'),
|
||||
h(MoreHorizontal, { class: 'h-4 w-4' }),
|
||||
],
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
h(
|
||||
DropdownMenuContent,
|
||||
{ align: 'end' },
|
||||
{
|
||||
default: () => [
|
||||
h(DropdownMenuLabel, {}, () => 'Actions'),
|
||||
h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
onClick: () => navigator.clipboard.writeText(item.id),
|
||||
},
|
||||
() => 'Copy ID'
|
||||
),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, {}, () => 'View details'),
|
||||
h(DropdownMenuItem, {}, () => 'Edit'),
|
||||
],
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Payments example from shadcn-vue docs
|
||||
*/
|
||||
export const paymentColumns = [
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status');
|
||||
return h('div', { class: 'capitalize' }, status);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Email', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => h('div', { class: 'lowercase' }, row.getValue('email')),
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: () => h('div', { class: 'text-right' }, 'Amount'),
|
||||
cell: ({ row }) => {
|
||||
const amount = parseFloat(row.getValue('amount'));
|
||||
const formatted = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(amount);
|
||||
return h('div', { class: 'text-right font-medium' }, formatted);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Example with custom cell slots
|
||||
* Use template slots in your component:
|
||||
*
|
||||
* <DataTable :columns="columnsWithSlots" :data="data">
|
||||
* <template #cell-status="{ value }">
|
||||
* <Badge :variant="value === 'active' ? 'default' : 'secondary'">
|
||||
* {{ value }}
|
||||
* </Badge>
|
||||
* </template>
|
||||
* </DataTable>
|
||||
*/
|
||||
export const columnsWithSlots = [
|
||||
{ key: 'id', label: 'ID', sortable: true },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'status', label: 'Status', sortable: false }, // Will use #cell-status slot
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
];
|
||||
|
||||
export default advancedColumns;
|
||||
|
|
@ -13,32 +13,47 @@ import {
|
|||
faTrash,
|
||||
faFileAlt,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { ref } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import DataTable from "../DataTable/DataTable.vue";
|
||||
import DataTable from "../DataTable/DataTableNew2.vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import DeleteDialog from "../Dialogs/DeleteDialog.vue";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import TableActions from "@/Components/DataTable/TableActions.vue";
|
||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
||||
import { fmtDateTime } from "@/Utilities/functions";
|
||||
|
||||
const props = defineProps({
|
||||
documents: { type: Array, default: () => [] },
|
||||
documents: { type: [Array, Object], default: () => [] },
|
||||
viewUrlBuilder: { type: Function, default: null },
|
||||
// Optional: direct download URL builder; if absent we emit 'download'
|
||||
downloadUrlBuilder: { type: Function, default: null },
|
||||
// Optional: direct delete URL builder; if absent we emit 'delete'
|
||||
deleteUrlBuilder: { type: Function, default: null },
|
||||
edit: { type: Boolean, default: false },
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 15,
|
||||
},
|
||||
pageSizeOptions: {
|
||||
type: Array,
|
||||
default: () => [10, 15, 25, 50, 100],
|
||||
},
|
||||
// Server-side pagination support
|
||||
clientCase: { type: Object, default: null },
|
||||
});
|
||||
|
||||
// Define columns for DataTable
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Naziv' },
|
||||
{ key: 'type', label: 'Vrsta' },
|
||||
{ key: 'size', label: 'Velikost', align: 'right' },
|
||||
{ key: 'created_at', label: 'Dodano' },
|
||||
{ key: 'source', label: 'Vir' },
|
||||
{ key: 'description', label: 'Opis', align: 'center' },
|
||||
{ key: "name", label: "Naziv", sortable: false },
|
||||
{ key: "type", label: "Vrsta", sortable: false },
|
||||
{ key: "size", label: "Velikost", align: "right", sortable: false },
|
||||
{ key: "created_at", label: "Dodano", sortable: false },
|
||||
{ key: "source", label: "Vir", sortable: false },
|
||||
{ key: "description", label: "Opis", align: "center", sortable: false },
|
||||
{ key: "actions", label: "", sortable: false, hideable: false, align: "center" },
|
||||
];
|
||||
|
||||
// Derive a human-friendly source for a document: Case or Contract reference
|
||||
const sourceLabel = (doc) => {
|
||||
// Server can include optional documentable meta; fall back to type
|
||||
|
|
@ -50,6 +65,19 @@ const sourceLabel = (doc) => {
|
|||
|
||||
const emit = defineEmits(["view", "download", "delete", "edit"]);
|
||||
|
||||
// Support both array and Resource Collection (object with data property)
|
||||
const documentsData = computed(() => {
|
||||
if (Array.isArray(props.documents)) {
|
||||
return props.documents;
|
||||
}
|
||||
return props.documents?.data || [];
|
||||
});
|
||||
|
||||
// Check if using server-side pagination
|
||||
const isServerSide = computed(() => {
|
||||
return !!(props.documents?.links && props.clientCase);
|
||||
});
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes == null) return "-";
|
||||
const thresh = 1024;
|
||||
|
|
@ -243,19 +271,27 @@ function closeActions() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="space-y-4">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="documents"
|
||||
:show-toolbar="false"
|
||||
:data="documentsData"
|
||||
:meta="isServerSide ? documents : null"
|
||||
:route-name="isServerSide ? 'clientCase.show' : null"
|
||||
:route-params="isServerSide ? { client_case: clientCase.uuid } : {}"
|
||||
:only-props="isServerSide ? ['documents'] : []"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
page-param-name="documentsPage"
|
||||
per-page-param-name="documentsPerPage"
|
||||
:show-pagination="false"
|
||||
:striped="false"
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
:show-actions="true"
|
||||
row-key="uuid"
|
||||
empty-text="Ni dokumentov."
|
||||
empty-icon="faFileAlt"
|
||||
>
|
||||
<template #toolbar-actions>
|
||||
<slot name="add" />
|
||||
</template>
|
||||
<!-- Name column -->
|
||||
<template #cell-name="{ row }">
|
||||
<div>
|
||||
|
|
@ -267,7 +303,12 @@ function closeActions() {
|
|||
>
|
||||
{{ row.name }}
|
||||
</button>
|
||||
<Badge v-if="row.is_public" variant="secondary" class="bg-green-100 text-green-700 hover:bg-green-200">Public</Badge>
|
||||
<Badge
|
||||
v-if="row.is_public"
|
||||
variant="secondary"
|
||||
class="bg-green-100 text-green-700 hover:bg-green-200"
|
||||
>Public</Badge
|
||||
>
|
||||
</div>
|
||||
<!-- Expanded description -->
|
||||
<div
|
||||
|
|
@ -297,12 +338,25 @@ function closeActions() {
|
|||
|
||||
<!-- Created at column -->
|
||||
<template #cell-created_at="{ row }">
|
||||
{{ new Date(row.created_at).toLocaleString() }}
|
||||
<div class="text-gray-800 font-medium leading-tight">
|
||||
{{ row.created_by }}
|
||||
</div>
|
||||
<div v-if="row.created_at" class="mt-1">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded text-[11px] bg-gray-100 text-gray-600 tracking-wide"
|
||||
>
|
||||
{{ fmtDateTime(row.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Source column -->
|
||||
<template #cell-source="{ row }">
|
||||
<Badge variant="secondary" class="bg-purple-100 text-purple-700 hover:bg-purple-200">{{ sourceLabel(row) }}</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="bg-purple-100 text-purple-700 hover:bg-purple-200"
|
||||
>{{ sourceLabel(row) }}</Badge
|
||||
>
|
||||
</template>
|
||||
|
||||
<!-- Description column -->
|
||||
|
|
@ -321,53 +375,29 @@ function closeActions() {
|
|||
</template>
|
||||
|
||||
<!-- Actions column -->
|
||||
<template #actions="{ row }">
|
||||
<div @click.stop>
|
||||
<Dropdown align="right" width="48">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none transition-colors"
|
||||
title="Možnosti"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faEllipsisVertical"
|
||||
class="h-4 w-4 text-gray-700"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 transition-colors"
|
||||
@click="emit('edit', row)"
|
||||
v-if="edit"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
|
||||
<span>Uredi</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 transition-colors"
|
||||
@click="handleDownload(row)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" />
|
||||
<span>Prenos</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 transition-colors"
|
||||
@click="askDelete(row)"
|
||||
v-if="edit"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4" />
|
||||
<span>Izbriši</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<template #cell-actions="{ row }">
|
||||
<TableActions align="right">
|
||||
<template #default>
|
||||
<ActionMenuItem
|
||||
v-if="edit"
|
||||
:icon="faCircleInfo"
|
||||
label="Uredi"
|
||||
@click="emit('edit', row)"
|
||||
/>
|
||||
<ActionMenuItem
|
||||
:icon="faDownload"
|
||||
label="Prenos"
|
||||
@click="handleDownload(row)"
|
||||
/>
|
||||
<ActionMenuItem
|
||||
v-if="edit"
|
||||
:icon="faTrash"
|
||||
label="Izbriši"
|
||||
danger
|
||||
@click="askDelete(row)"
|
||||
/>
|
||||
</template>
|
||||
</TableActions>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,40 @@
|
|||
<script setup>
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { computed, ref } from "vue";
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationItem,
|
||||
PaginationLast,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/Components/ui/pagination";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-vue-next";
|
||||
import { toInteger } from "lodash";
|
||||
|
||||
const props = defineProps({
|
||||
links: { type: Array, default: () => [] },
|
||||
from: { type: Number, default: 0 },
|
||||
to: { type: Number, default: 0 },
|
||||
total: { type: Number, default: 0 },
|
||||
perPage: { type: Number, default: 15 },
|
||||
pageSizeOptions: { type: Array, default: () => [10, 15, 25, 50, 100] },
|
||||
currentPage: { type: Number, default: 0 },
|
||||
lastPage: { type: Number, default: 0 },
|
||||
perPageParam: { type: String, default: "per_page" }, // e.g., 'activities_per_page', 'contracts_per_page'
|
||||
pageParam: { type: String, default: "page" }, // e.g., 'activities_page', 'contracts_page'
|
||||
});
|
||||
|
||||
const num = props.links?.length || 0;
|
||||
|
|
@ -53,44 +79,12 @@ const lastLink = computed(() => {
|
|||
return maxLink;
|
||||
});
|
||||
|
||||
const numericLinks = computed(() => {
|
||||
if (num < 3 || !props.links || !Array.isArray(props.links)) return [];
|
||||
return props.links
|
||||
.slice(1, num - 1)
|
||||
.filter((l) => l != null)
|
||||
.map((l) => ({
|
||||
...l,
|
||||
page: Number.parseInt(String(l?.label || "").replace(/[^0-9]/g, ""), 10),
|
||||
}))
|
||||
.filter((l) => !Number.isNaN(l.page) && l.page != null);
|
||||
});
|
||||
|
||||
const currentPage = computed(() => {
|
||||
const active = numericLinks.value.find((l) => l?.active);
|
||||
return active?.page || 1;
|
||||
});
|
||||
const lastPage = computed(() => {
|
||||
if (!numericLinks.value.length) return 1;
|
||||
const pages = numericLinks.value.map((l) => l?.page).filter(p => p != null);
|
||||
return pages.length ? Math.max(...pages) : 1;
|
||||
});
|
||||
|
||||
const linkByPage = computed(() => {
|
||||
const m = new Map();
|
||||
for (const l of numericLinks.value) {
|
||||
if (l?.page != null) {
|
||||
m.set(l.page, l);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
// Generate visible page numbers with ellipsis (similar to DataTableClient)
|
||||
const visiblePages = computed(() => {
|
||||
const pages = [];
|
||||
const total = lastPage.value;
|
||||
const current = currentPage.value;
|
||||
const maxVisible = 5; // Match DataTableClient default
|
||||
const total = props.lastPage;
|
||||
const current = props.currentPage;
|
||||
const maxVisible = 5;
|
||||
|
||||
if (total <= maxVisible) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
|
|
@ -105,64 +99,55 @@ const visiblePages = computed(() => {
|
|||
let end = Math.min(total, start + maxVisible - 1);
|
||||
start = Math.max(1, Math.min(start, end - maxVisible + 1));
|
||||
|
||||
// Handle first page
|
||||
if (start > 1) {
|
||||
pages.push(1);
|
||||
if (start > 2) pages.push("...");
|
||||
}
|
||||
|
||||
// Add pages in window
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
// Handle last page
|
||||
if (end < total) {
|
||||
if (end < total - 1) pages.push("...");
|
||||
pages.push(total);
|
||||
}
|
||||
|
||||
return pages;
|
||||
});
|
||||
|
||||
const gotoInput = ref("");
|
||||
|
||||
// Handle scroll on navigation
|
||||
function handleLinkClick(event) {
|
||||
// Prevent default scroll behavior
|
||||
event.preventDefault();
|
||||
const href = event.currentTarget.getAttribute('href');
|
||||
if (href) {
|
||||
router.visit(href, {
|
||||
preserveScroll: false,
|
||||
onSuccess: () => {
|
||||
// Scroll to top of table after navigation completes
|
||||
setTimeout(() => {
|
||||
const tableElement = document.querySelector('[data-table-container]');
|
||||
if (tableElement) {
|
||||
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
}
|
||||
// Navigate to a specific page using Laravel's pagination links
|
||||
function navigateToPage(pageNum) {
|
||||
if (!pageNum || pageNum < 1 || pageNum > props.lastPage) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(props.pageParam, String(pageNum));
|
||||
|
||||
router.get(
|
||||
url.pathname + url.search,
|
||||
{},
|
||||
{
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function goToPage() {
|
||||
const raw = String(gotoInput.value || "").trim();
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 1 || n > lastPage.value) {
|
||||
if (!Number.isFinite(n) || n < 1 || n > props.lastPage) {
|
||||
gotoInput.value = "";
|
||||
return;
|
||||
}
|
||||
const targetLink = linkByPage.value.get(Math.floor(n));
|
||||
if (targetLink?.url) {
|
||||
router.visit(targetLink.url, {
|
||||
preserveScroll: false,
|
||||
onSuccess: () => {
|
||||
// Scroll to top of table when page changes
|
||||
const tableElement = document.querySelector('[data-table-container]');
|
||||
if (tableElement) {
|
||||
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// If link not found, try to construct URL manually
|
||||
gotoInput.value = "";
|
||||
}
|
||||
navigateToPage(n);
|
||||
gotoInput.value = "";
|
||||
}
|
||||
|
||||
function handleKeyPress(event) {
|
||||
|
|
@ -170,39 +155,54 @@ function handleKeyPress(event) {
|
|||
goToPage();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePerPageChange(value) {
|
||||
const newPerPage = Number(value);
|
||||
if (!newPerPage) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(props.perPageParam, newPerPage);
|
||||
url.searchParams.set(props.pageParam, "1"); // Reset to first page
|
||||
|
||||
router.get(
|
||||
url.pathname + url.search,
|
||||
{},
|
||||
{
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 bg-white px-4 py-3 text-sm text-gray-700 sm:px-6"
|
||||
class="flex flex-wrap items-center justify-between gap-3 px-2 text-sm text-gray-700 sm:px-5"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<!-- Mobile: Simple prev/next -->
|
||||
<div class="flex flex-1 justify-between sm:hidden">
|
||||
<Link
|
||||
<button
|
||||
v-if="prevLink?.url"
|
||||
:href="prevLink.url"
|
||||
:preserve-scroll="false"
|
||||
@click="handleLinkClick"
|
||||
@click="navigateToPage(currentPage - 1)"
|
||||
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Prejšnja
|
||||
</Link>
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-400 cursor-not-allowed opacity-50"
|
||||
>
|
||||
Prejšnja
|
||||
</span>
|
||||
<Link
|
||||
<button
|
||||
v-if="nextLink?.url"
|
||||
:href="nextLink.url"
|
||||
:preserve-scroll="false"
|
||||
@click="handleLinkClick"
|
||||
@click="navigateToPage(currentPage + 1)"
|
||||
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Naslednja
|
||||
</Link>
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-400 cursor-not-allowed opacity-50"
|
||||
|
|
@ -213,159 +213,105 @@ function handleKeyPress(event) {
|
|||
|
||||
<!-- Desktop: Full pagination -->
|
||||
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<!-- Page stats -->
|
||||
<div v-if="total > 0">
|
||||
<span class="text-sm text-gray-700">
|
||||
Prikazano: <span class="font-medium">{{ from || 0 }}</span>–<span class="font-medium">{{ to || 0 }}</span> od
|
||||
<span class="font-medium">{{ total || 0 }}</span>
|
||||
</span>
|
||||
<!-- Page stats with modern badge style -->
|
||||
<div v-if="total > 0" class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">Prikazano</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-sm font-medium"
|
||||
>
|
||||
<span class="text-foreground">{{ from || 0 }}</span>
|
||||
<span class="text-muted-foreground">-</span>
|
||||
<span class="text-foreground">{{ to || 0 }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">od</span>
|
||||
<div
|
||||
class="inline-flex items-center rounded-md bg-primary/10 px-2.5 py-1 text-sm font-semibold text-primary"
|
||||
>
|
||||
{{ total || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span class="text-sm text-gray-700">Ni zadetkov</span>
|
||||
<div
|
||||
v-else
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-1.5"
|
||||
>
|
||||
<span class="text-sm font-medium text-muted-foreground">Ni zadetkov</span>
|
||||
</div>
|
||||
|
||||
<!-- Pagination controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- First -->
|
||||
<Link
|
||||
v-if="firstLink?.url && currentPage > 1"
|
||||
:href="firstLink.url"
|
||||
:preserve-scroll="false"
|
||||
@click="handleLinkClick"
|
||||
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
|
||||
aria-label="Prva stran"
|
||||
>
|
||||
««
|
||||
</Link>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
|
||||
aria-label="Prva stran"
|
||||
>
|
||||
««
|
||||
</span>
|
||||
<Pagination
|
||||
v-slot="{ page }"
|
||||
:total="total"
|
||||
:items-per-page="perPage"
|
||||
:sibling-count="1"
|
||||
show-edges
|
||||
:default-page="currentPage"
|
||||
:page="currentPage"
|
||||
>
|
||||
<PaginationContent>
|
||||
<!-- First -->
|
||||
<PaginationFirst :disabled="currentPage <= 1" @click="navigateToPage(1)">
|
||||
<ChevronsLeft />
|
||||
</PaginationFirst>
|
||||
|
||||
<!-- Prev -->
|
||||
<Link
|
||||
v-if="prevLink?.url"
|
||||
:href="prevLink.url"
|
||||
:preserve-scroll="false"
|
||||
@click="handleLinkClick"
|
||||
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
|
||||
aria-label="Prejšnja stran"
|
||||
>
|
||||
«
|
||||
</Link>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
|
||||
aria-label="Prejšnja stran"
|
||||
>
|
||||
«
|
||||
</span>
|
||||
|
||||
<!-- Leading ellipsis / first page when window doesn't include 1 -->
|
||||
<Link
|
||||
v-if="visiblePages[0] > 1"
|
||||
:href="firstLink?.url || '#'"
|
||||
:preserve-scroll="false"
|
||||
@click="handleLinkClick"
|
||||
class="px-3 py-1 rounded border border-gray-300 bg-white text-gray-900 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
1
|
||||
</Link>
|
||||
<span v-if="visiblePages[0] > 2" class="px-1 text-gray-700">…</span>
|
||||
|
||||
<!-- Page numbers -->
|
||||
<template v-for="p in visiblePages" :key="p">
|
||||
<Link
|
||||
v-if="linkByPage.get(p)?.url"
|
||||
:href="linkByPage.get(p).url"
|
||||
:preserve-scroll="false"
|
||||
@click="handleLinkClick"
|
||||
class="px-3 py-1 rounded border transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
|
||||
:class="
|
||||
p === currentPage
|
||||
? 'border-primary-600 bg-primary-600 text-white'
|
||||
: 'border-gray-300 bg-white text-gray-900 hover:bg-gray-50'
|
||||
"
|
||||
:aria-current="p === currentPage ? 'page' : undefined"
|
||||
<!-- Previous -->
|
||||
<PaginationPrevious
|
||||
:disabled="currentPage <= 1"
|
||||
@click="navigateToPage(currentPage - 1)"
|
||||
>
|
||||
{{ p }}
|
||||
</Link>
|
||||
<span
|
||||
v-else
|
||||
class="px-3 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
|
||||
<ChevronLeft />
|
||||
</PaginationPrevious>
|
||||
|
||||
<!-- Page numbers -->
|
||||
<template v-for="(item, index) in visiblePages" :key="index">
|
||||
<PaginationEllipsis v-if="item === '...'" />
|
||||
<PaginationItem
|
||||
v-else
|
||||
:value="item"
|
||||
:is-active="currentPage === item"
|
||||
@click="navigateToPage(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</PaginationItem>
|
||||
</template>
|
||||
|
||||
<!-- Next -->
|
||||
<PaginationNext
|
||||
:disabled="currentPage >= lastPage"
|
||||
@click="navigateToPage(currentPage + 1)"
|
||||
>
|
||||
{{ p }}
|
||||
</span>
|
||||
</template>
|
||||
<ChevronRight />
|
||||
</PaginationNext>
|
||||
|
||||
<!-- Trailing ellipsis / last page when window doesn't include last -->
|
||||
<span v-if="visiblePages[visiblePages.length - 1] < lastPage - 1" class="px-1 text-gray-700">…</span>
|
||||
<Link
|
||||
v-if="visiblePages[visiblePages.length - 1] < lastPage && lastLink?.url"
|
||||
:href="lastLink.url"
|
||||
:preserve-scroll="false"
|
||||
@click="handleLinkClick"
|
||||
class="px-3 py-1 rounded border border-gray-300 bg-white text-gray-900 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{{ lastPage }}
|
||||
</Link>
|
||||
<!-- Last -->
|
||||
<PaginationLast
|
||||
:disabled="currentPage >= lastPage"
|
||||
@click="navigateToPage(lastPage)"
|
||||
>
|
||||
<ChevronsRight />
|
||||
</PaginationLast>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
|
||||
<!-- Next -->
|
||||
<Link
|
||||
v-if="nextLink?.url"
|
||||
:href="nextLink.url"
|
||||
:preserve-scroll="false"
|
||||
@click="handleLinkClick"
|
||||
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
|
||||
aria-label="Naslednja stran"
|
||||
<!-- Goto page input -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Go to page -->
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-md border border-input bg-background px-2 h-8"
|
||||
>
|
||||
»
|
||||
</Link>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
|
||||
aria-label="Naslednja stran"
|
||||
>
|
||||
»
|
||||
</span>
|
||||
|
||||
<!-- Last -->
|
||||
<Link
|
||||
v-if="lastLink?.url && currentPage < lastPage"
|
||||
:href="lastLink.url"
|
||||
:preserve-scroll="false"
|
||||
@click="handleLinkClick"
|
||||
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
|
||||
aria-label="Zadnja stran"
|
||||
>
|
||||
»»
|
||||
</Link>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
|
||||
aria-label="Zadnja stran"
|
||||
>
|
||||
»»
|
||||
</span>
|
||||
|
||||
<!-- Goto page input -->
|
||||
<div class="ms-2 flex items-center gap-1">
|
||||
<Input
|
||||
<input
|
||||
v-model="gotoInput"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="lastPage"
|
||||
inputmode="numeric"
|
||||
class="w-16 text-sm"
|
||||
class="w-10 h-full text-sm text-center bg-transparent border-0 outline-none focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
:placeholder="String(currentPage)"
|
||||
aria-label="Pojdi na stran"
|
||||
@keyup.enter="goToPage"
|
||||
@blur="goToPage"
|
||||
/>
|
||||
<span class="text-sm text-gray-500">/ {{ lastPage }}</span>
|
||||
<Separator orientation="vertical" class="h-full" />
|
||||
<span class="text-sm text-muted-foreground">{{ lastPage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,65 +1,64 @@
|
|||
<script setup>
|
||||
import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
|
||||
import Dropdown from "../Dropdown.vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
import { EllipsisVertical } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
person: Object,
|
||||
edit: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add', 'edit', 'delete']);
|
||||
const emit = defineEmits(["add", "edit", "delete"]);
|
||||
|
||||
const handleAdd = () => emit('add');
|
||||
const handleEdit = (id) => emit('edit', id);
|
||||
const handleDelete = (id, label) => emit('delete', id, label);
|
||||
const handleAdd = () => emit("add");
|
||||
const handleEdit = (id) => emit("edit", id);
|
||||
const handleDelete = (id, label) => emit("delete", id, label);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div
|
||||
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
v-for="address in person.addresses"
|
||||
:key="address.id"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<Card class="p-2" v-for="address in person.addresses" :key="address.id">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
||||
>
|
||||
{{ address.country }}
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||
>
|
||||
{{ address.type.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="edit">
|
||||
<Dropdown align="right" width="48">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
|
||||
title="Možnosti"
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" title="Možnosti">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="handleEdit(address.id)">
|
||||
<EditIcon size="sm" />
|
||||
<span>Uredi</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@click="handleDelete(address.id, address.address)"
|
||||
class="text-red-600 focus:bg-red-50 focus:text-red-600"
|
||||
>
|
||||
<DottedMenu size="sm" css="text-gray-600" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="py-1">
|
||||
<button
|
||||
@click="handleEdit(address.id)"
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<EditIcon size="sm" />
|
||||
<span>Uredi</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(address.id, address.address)"
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<TrashBinIcon size="sm" />
|
||||
<span>Izbriši</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<TrashBinIcon size="sm" class="text-red-600" />
|
||||
<span>Izbriši</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 leading-relaxed">
|
||||
|
|
@ -69,15 +68,14 @@ const handleDelete = (id, label) => emit('delete', id, label);
|
|||
: address.address
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<button
|
||||
v-if="edit"
|
||||
@click="handleAdd"
|
||||
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
|
||||
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center h-full"
|
||||
title="Dodaj naslov"
|
||||
>
|
||||
<PlusIcon class="h-8 w-8 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,34 @@
|
|||
<script setup>
|
||||
import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
|
||||
import Dropdown from "../Dropdown.vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
import { EllipsisVertical } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
person: Object,
|
||||
edit: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add', 'edit', 'delete']);
|
||||
const emit = defineEmits(["add", "edit", "delete"]);
|
||||
|
||||
const getEmails = (p) => (Array.isArray(p?.emails) ? p.emails : []);
|
||||
|
||||
const handleAdd = () => emit('add');
|
||||
const handleEdit = (id) => emit('edit', id);
|
||||
const handleDelete = (id, label) => emit('delete', id, label);
|
||||
const handleAdd = () => emit("add");
|
||||
const handleEdit = (id) => emit("edit", id);
|
||||
const handleDelete = (id, label) => emit("delete", id, label);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<template v-if="getEmails(person).length">
|
||||
<div
|
||||
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
v-for="(email, idx) in getEmails(person)"
|
||||
:key="idx"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2" v-if="edit">
|
||||
<Card class="p-2" v-for="(email, idx) in getEmails(person)" :key="idx">
|
||||
<div class="flex items-center justify-between mb-2" v-if="edit">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-if="email?.label"
|
||||
|
|
@ -40,56 +44,54 @@ const handleDelete = (id, label) => emit('delete', id, label);
|
|||
</span>
|
||||
</div>
|
||||
<div v-if="edit">
|
||||
<Dropdown align="right" width="48">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
|
||||
title="Možnosti"
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" title="Možnosti">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="handleEdit(email.id)">
|
||||
<EditIcon size="sm" />
|
||||
<span>Uredi</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@click="
|
||||
handleDelete(email.id, email?.value || email?.email || email?.address)
|
||||
"
|
||||
class="text-red-600 focus:bg-red-50 focus:text-red-600"
|
||||
>
|
||||
<DottedMenu size="sm" css="text-gray-600" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="py-1">
|
||||
<button
|
||||
@click="handleEdit(email.id)"
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<EditIcon size="sm" />
|
||||
<span>Uredi</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(email.id, email?.value || email?.email || email?.address)"
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<TrashBinIcon size="sm" />
|
||||
<span>Izbriši</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<TrashBinIcon size="sm" class="text-red-600" />
|
||||
<span>Izbriši</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 leading-relaxed">
|
||||
{{ email?.value || email?.email || email?.address || "-" }}
|
||||
</p>
|
||||
<p v-if="email?.note" class="mt-2 text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
|
||||
<p
|
||||
v-if="email?.note"
|
||||
class="mt-2 text-xs text-gray-600 whitespace-pre-wrap leading-relaxed"
|
||||
>
|
||||
{{ email.note }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
<button
|
||||
v-if="edit"
|
||||
@click="handleAdd"
|
||||
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
|
||||
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center h-full"
|
||||
title="Dodaj email"
|
||||
>
|
||||
<PlusIcon class="h-8 w-8 text-gray-400" />
|
||||
</button>
|
||||
<p v-else-if="!edit && !getEmails(person).length" class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p
|
||||
v-else-if="!edit && !getEmails(person).length"
|
||||
class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200"
|
||||
>
|
||||
Ni e-poštnih naslovov.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { router } from "@inertiajs/vue3";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { PlusIcon } from "@/Utilities/Icons";
|
||||
import { faUser, faMapMarkerAlt, faPhone, faEnvelope, faUniversity } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import PersonUpdateForm from "./PersonUpdateForm.vue";
|
||||
import AddressCreateForm from "./AddressCreateForm.vue";
|
||||
import AddressUpdateForm from "./AddressUpdateForm.vue";
|
||||
|
|
@ -298,13 +300,21 @@ const switchToTab = (tab) => {
|
|||
<template>
|
||||
<Tabs v-model="activeTab" class="mt-2">
|
||||
<TabsList class="flex w-full bg-white gap-2 p-1">
|
||||
<TabsTrigger value="person" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2">Oseba</TabsTrigger>
|
||||
<TabsTrigger value="person" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faUser" class="h-4 w-4" />
|
||||
<span>Oseba</span>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="addresses" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<span>Naslovi</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faMapMarkerAlt" class="h-4 w-4" />
|
||||
<span>Naslovi</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="addressesCount > 0"
|
||||
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
|
||||
class="h-5 min-w-5 px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
|
||||
>
|
||||
{{ formatBadgeCount(addressesCount) }}
|
||||
</span>
|
||||
|
|
@ -312,10 +322,13 @@ const switchToTab = (tab) => {
|
|||
</TabsTrigger>
|
||||
<TabsTrigger value="phones" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<span>Telefonske</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faPhone" class="h-4 w-4" />
|
||||
<span>Telefonske</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="phonesCount > 0"
|
||||
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
|
||||
class="h-5 min-w-5 px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
|
||||
>
|
||||
{{ formatBadgeCount(phonesCount) }}
|
||||
</span>
|
||||
|
|
@ -323,10 +336,13 @@ const switchToTab = (tab) => {
|
|||
</TabsTrigger>
|
||||
<TabsTrigger value="emails" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<span>Email</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faEnvelope" class="h-4 w-4" />
|
||||
<span>Email</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="emailsCount > 0"
|
||||
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
|
||||
class="h-5 min-w-5 px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
|
||||
>
|
||||
{{ formatBadgeCount(emailsCount) }}
|
||||
</span>
|
||||
|
|
@ -334,10 +350,13 @@ const switchToTab = (tab) => {
|
|||
</TabsTrigger>
|
||||
<TabsTrigger value="trr" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<span>TRR</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faUniversity" class="h-4 w-4" />
|
||||
<span>TRR</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="trrsCount > 0"
|
||||
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
|
||||
class="h-5 min-w-5 px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
|
||||
>
|
||||
{{ formatBadgeCount(trrsCount) }}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup>
|
||||
import { UserEditIcon } from "@/Utilities/Icons";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const props = defineProps({
|
||||
person: Object,
|
||||
|
|
@ -35,7 +36,7 @@ const handleEdit = () => {
|
|||
|
||||
<template>
|
||||
<div class="flex justify-end mb-3">
|
||||
<button
|
||||
<Button
|
||||
v-if="edit && personEdit"
|
||||
@click="handleEdit"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
<script setup>
|
||||
import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
|
||||
import Dropdown from "../Dropdown.vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
import { EllipsisVertical, MessageSquare, MessageSquareText } from "lucide-vue-next";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
const props = defineProps({
|
||||
person: Object,
|
||||
|
|
@ -8,88 +17,84 @@ const props = defineProps({
|
|||
enableSms: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add', 'edit', 'delete', 'sms']);
|
||||
const emit = defineEmits(["add", "edit", "delete", "sms"]);
|
||||
|
||||
const getPhones = (p) => (Array.isArray(p?.phones) ? p.phones : []);
|
||||
|
||||
const handleAdd = () => emit('add');
|
||||
const handleEdit = (id) => emit('edit', id);
|
||||
const handleDelete = (id, label) => emit('delete', id, label);
|
||||
const handleSms = (phone) => emit('sms', phone);
|
||||
const handleAdd = () => emit("add");
|
||||
const handleEdit = (id) => emit("edit", id);
|
||||
const handleDelete = (id, label) => emit("delete", id, label);
|
||||
const handleSms = (phone) => emit("sms", phone);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<template v-if="getPhones(person).length">
|
||||
<div
|
||||
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
v-for="phone in getPhones(person)"
|
||||
:key="phone.id"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<Card class="p-2" v-for="phone in getPhones(person)" :key="phone.id">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
||||
>
|
||||
+{{ phone.country_code }}
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||
>
|
||||
{{ phone && phone.type && phone.type.name ? phone.type.name : "—" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Send SMS only in ClientCase person context -->
|
||||
<button
|
||||
|
||||
<Button
|
||||
v-if="enableSms"
|
||||
@click="handleSms(phone)"
|
||||
title="Pošlji SMS"
|
||||
class="px-2.5 py-1 text-xs font-medium text-indigo-700 bg-indigo-50 border border-indigo-200 hover:bg-indigo-100 rounded-lg transition-colors"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
SMS
|
||||
</button>
|
||||
<Dropdown v-if="edit" align="right" width="48">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
|
||||
title="Možnosti"
|
||||
<MessageSquare />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu v-if="edit">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" title="Možnosti">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="handleEdit(phone.id)">
|
||||
<EditIcon size="sm" />
|
||||
<span>Uredi</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@click="handleDelete(phone.id, phone.nu)"
|
||||
class="text-red-600 focus:bg-red-50 focus:text-red-600"
|
||||
>
|
||||
<DottedMenu size="sm" css="text-gray-600" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="py-1">
|
||||
<button
|
||||
@click="handleEdit(phone.id)"
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<EditIcon size="sm" />
|
||||
<span>Uredi</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(phone.id, phone.nu)"
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<TrashBinIcon size="sm" />
|
||||
<span>Izbriši</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<TrashBinIcon size="sm" class="text-red-600" />
|
||||
<span>Izbriši</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 leading-relaxed">{{ phone.nu }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
<button
|
||||
v-if="edit"
|
||||
@click="handleAdd"
|
||||
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
|
||||
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center h-full"
|
||||
title="Dodaj telefon"
|
||||
>
|
||||
<PlusIcon class="h-8 w-8 text-gray-400" />
|
||||
</button>
|
||||
<p v-else-if="!edit && !getPhones(person).length" class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p
|
||||
v-else-if="!edit && !getPhones(person).length"
|
||||
class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200"
|
||||
>
|
||||
Ni telefonov.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -245,7 +245,9 @@ watch(
|
|||
);
|
||||
|
||||
// Auto-select sender when profile changes
|
||||
watch(form.values.profile_id, (profileId) => {
|
||||
watch(
|
||||
() => form.values.profile_id,
|
||||
(profileId) => {
|
||||
if (!profileId) {
|
||||
form.setFieldValue("sender_id", null);
|
||||
return;
|
||||
|
|
@ -268,7 +270,8 @@ watch(form.values.profile_id, (profileId) => {
|
|||
} else {
|
||||
form.setFieldValue("sender_id", null);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Reset sender if not available for selected profile
|
||||
watch(sendersForSelectedProfile, (list) => {
|
||||
|
|
@ -355,15 +358,21 @@ const updateSmsFromSelection = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
watch(form.values.template_id, () => {
|
||||
watch(
|
||||
() => form.values.template_id,
|
||||
() => {
|
||||
if (!form.values.template_id) return;
|
||||
updateSmsFromSelection();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(form.values.contract_uuid, () => {
|
||||
watch(
|
||||
() => form.values.contract_uuid,
|
||||
() => {
|
||||
if (!form.values.template_id) return;
|
||||
updateSmsFromSelection();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(pageSmsTemplates, (list) => {
|
||||
if (!Array.isArray(list)) return;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
<script setup>
|
||||
import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
|
||||
import Dropdown from "../Dropdown.vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { EllipsisVertical } from "lucide-vue-next";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const props = defineProps({
|
||||
person: Object,
|
||||
edit: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add', 'edit', 'delete']);
|
||||
const emit = defineEmits(["add", "edit", "delete"]);
|
||||
|
||||
const getTRRs = (p) => {
|
||||
if (Array.isArray(p?.trrs)) return p.trrs;
|
||||
|
|
@ -17,20 +25,16 @@ const getTRRs = (p) => {
|
|||
return [];
|
||||
};
|
||||
|
||||
const handleAdd = () => emit('add');
|
||||
const handleEdit = (id) => emit('edit', id);
|
||||
const handleDelete = (id, label) => emit('delete', id, label);
|
||||
const handleAdd = () => emit("add");
|
||||
const handleEdit = (id) => emit("edit", id);
|
||||
const handleDelete = (id, label) => emit("delete", id, label);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<template v-if="getTRRs(person).length">
|
||||
<div
|
||||
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
v-for="(acc, idx) in getTRRs(person)"
|
||||
:key="idx"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2" v-if="edit">
|
||||
<Card class="p-2" v-for="(acc, idx) in getTRRs(person)" :key="idx">
|
||||
<div class="flex items-center justify-between mb-2" v-if="edit">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-if="acc?.bank_name"
|
||||
|
|
@ -52,35 +56,26 @@ const handleDelete = (id, label) => emit('delete', id, label);
|
|||
</span>
|
||||
</div>
|
||||
<div v-if="edit">
|
||||
<Dropdown align="right" width="48">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
|
||||
title="Možnosti"
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" title="Možnosti">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="handleEdit(acc.id)">
|
||||
<EditIcon size="sm" />
|
||||
<span>Uredi</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@click="handleDelete(acc.id, acc?.iban || acc?.account_number)"
|
||||
class="text-red-600 focus:bg-red-50 focus:text-red-600"
|
||||
>
|
||||
<DottedMenu size="sm" css="text-gray-600" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="py-1">
|
||||
<button
|
||||
@click="handleEdit(acc.id)"
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<EditIcon size="sm" />
|
||||
<span>Uredi</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(acc.id, acc?.iban || acc?.account_number)"
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<TrashBinIcon size="sm" />
|
||||
<span>Izbriši</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<TrashBinIcon size="sm" class="text-red-600" />
|
||||
<span>Izbriši</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 leading-relaxed font-mono">
|
||||
|
|
@ -93,22 +88,27 @@ const handleDelete = (id, label) => emit('delete', id, label);
|
|||
"-"
|
||||
}}
|
||||
</p>
|
||||
<p v-if="acc?.notes" class="mt-2 text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
|
||||
<p
|
||||
v-if="acc?.notes"
|
||||
class="mt-2 text-xs text-gray-600 whitespace-pre-wrap leading-relaxed"
|
||||
>
|
||||
{{ acc.notes }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
<button
|
||||
v-if="edit"
|
||||
@click="handleAdd"
|
||||
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center min-h-[120px]"
|
||||
class="rounded-lg p-4 bg-white border-2 border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 transition-all flex items-center justify-center h-full"
|
||||
title="Dodaj TRR"
|
||||
>
|
||||
<PlusIcon class="h-8 w-8 text-gray-400" />
|
||||
</button>
|
||||
<p v-else-if="!edit && !getTRRs(person).length" class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p
|
||||
v-else-if="!edit && !getTRRs(person).length"
|
||||
class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200"
|
||||
>
|
||||
Ni TRR računov.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,18 +3,17 @@ import { cva } from "class-variance-authority";
|
|||
export { default as Button } from "./Button.vue";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
|
|
|
|||
17
resources/js/Components/ui/card/Card.vue
Normal file
17
resources/js/Components/ui/card/Card.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('rounded-xl border bg-card text-card-foreground shadow', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
13
resources/js/Components/ui/card/CardContent.vue
Normal file
13
resources/js/Components/ui/card/CardContent.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('p-6 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
13
resources/js/Components/ui/card/CardDescription.vue
Normal file
13
resources/js/Components/ui/card/CardDescription.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
13
resources/js/Components/ui/card/CardFooter.vue
Normal file
13
resources/js/Components/ui/card/CardFooter.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center p-6 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
13
resources/js/Components/ui/card/CardHeader.vue
Normal file
13
resources/js/Components/ui/card/CardHeader.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
13
resources/js/Components/ui/card/CardTitle.vue
Normal file
13
resources/js/Components/ui/card/CardTitle.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3 :class="cn('font-semibold leading-none tracking-tight', props.class)">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
6
resources/js/Components/ui/card/index.js
Normal file
6
resources/js/Components/ui/card/index.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { default as Card } from "./Card.vue";
|
||||
export { default as CardContent } from "./CardContent.vue";
|
||||
export { default as CardDescription } from "./CardDescription.vue";
|
||||
export { default as CardFooter } from "./CardFooter.vue";
|
||||
export { default as CardHeader } from "./CardHeader.vue";
|
||||
export { default as CardTitle } from "./CardTitle.vue";
|
||||
109
resources/js/Components/ui/command/Command.vue
Normal file
109
resources/js/Components/ui/command/Command.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ListboxRoot, useFilter, useForwardPropsEmits } from "reka-ui";
|
||||
import { reactive, ref, watch } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { provideCommandContext } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: null, required: false, default: "" },
|
||||
defaultValue: { type: null, required: false },
|
||||
multiple: { type: Boolean, required: false },
|
||||
orientation: { type: String, required: false },
|
||||
dir: { type: String, required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
selectionBehavior: { type: String, required: false },
|
||||
highlightOnHover: { type: Boolean, required: false },
|
||||
by: { type: [String, Function], required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
name: { type: String, required: false },
|
||||
required: { type: Boolean, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const emits = defineEmits([
|
||||
"update:modelValue",
|
||||
"highlight",
|
||||
"entryFocus",
|
||||
"leave",
|
||||
]);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
|
||||
const allItems = ref(new Map());
|
||||
const allGroups = ref(new Map());
|
||||
|
||||
const { contains } = useFilter({ sensitivity: "base" });
|
||||
const filterState = reactive({
|
||||
search: "",
|
||||
filtered: {
|
||||
/** The count of all visible items. */
|
||||
count: 0,
|
||||
/** Map from visible item id to its search score. */
|
||||
items: new Map(),
|
||||
/** Set of groups with at least one visible item. */
|
||||
groups: new Set(),
|
||||
},
|
||||
});
|
||||
|
||||
function filterItems() {
|
||||
if (!filterState.search) {
|
||||
filterState.filtered.count = allItems.value.size;
|
||||
// Do nothing, each item will know to show itself because search is empty
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the groups
|
||||
filterState.filtered.groups = new Set();
|
||||
let itemCount = 0;
|
||||
|
||||
// Check which items should be included
|
||||
for (const [id, value] of allItems.value) {
|
||||
const score = contains(value, filterState.search);
|
||||
filterState.filtered.items.set(id, score ? 1 : 0);
|
||||
if (score) itemCount++;
|
||||
}
|
||||
|
||||
// Check which groups have at least 1 item shown
|
||||
for (const [groupId, group] of allGroups.value) {
|
||||
for (const itemId of group) {
|
||||
if (filterState.filtered.items.get(itemId) > 0) {
|
||||
filterState.filtered.groups.add(groupId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filterState.filtered.count = itemCount;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => filterState.search,
|
||||
() => {
|
||||
filterItems();
|
||||
},
|
||||
);
|
||||
|
||||
provideCommandContext({
|
||||
allItems,
|
||||
allGroups,
|
||||
filterState,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListboxRoot
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</ListboxRoot>
|
||||
</template>
|
||||
26
resources/js/Components/ui/command/CommandDialog.vue
Normal file
26
resources/js/Components/ui/command/CommandDialog.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<script setup>
|
||||
import { useForwardPropsEmits } from "reka-ui";
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import Command from "./Command.vue";
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, required: false },
|
||||
defaultOpen: { type: Boolean, required: false },
|
||||
modal: { type: Boolean, required: false },
|
||||
});
|
||||
const emits = defineEmits(["update:open"]);
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-bind="forwarded">
|
||||
<DialogContent class="overflow-hidden p-0 shadow-lg">
|
||||
<Command
|
||||
class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
>
|
||||
<slot />
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
30
resources/js/Components/ui/command/CommandEmpty.vue
Normal file
30
resources/js/Components/ui/command/CommandEmpty.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Primitive } from "reka-ui";
|
||||
import { computed } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCommand } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const { filterState } = useCommand();
|
||||
const isRender = computed(
|
||||
() => !!filterState.search && filterState.filtered.count === 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-if="isRender"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('py-6 text-center text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
53
resources/js/Components/ui/command/CommandGroup.vue
Normal file
53
resources/js/Components/ui/command/CommandGroup.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ListboxGroup, ListboxGroupLabel, useId } from "reka-ui";
|
||||
import { computed, onMounted, onUnmounted } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { provideCommandGroupContext, useCommand } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
heading: { type: String, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const { allGroups, filterState } = useCommand();
|
||||
const id = useId();
|
||||
|
||||
const isRender = computed(() =>
|
||||
!filterState.search ? true : filterState.filtered.groups.has(id),
|
||||
);
|
||||
|
||||
provideCommandGroupContext({ id });
|
||||
onMounted(() => {
|
||||
if (!allGroups.value.has(id)) allGroups.value.set(id, new Set());
|
||||
});
|
||||
onUnmounted(() => {
|
||||
allGroups.value.delete(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListboxGroup
|
||||
v-bind="delegatedProps"
|
||||
:id="id"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
:hidden="isRender ? undefined : true"
|
||||
>
|
||||
<ListboxGroupLabel
|
||||
v-if="heading"
|
||||
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{{ heading }}
|
||||
</ListboxGroupLabel>
|
||||
<slot />
|
||||
</ListboxGroup>
|
||||
</template>
|
||||
43
resources/js/Components/ui/command/CommandInput.vue
Normal file
43
resources/js/Components/ui/command/CommandInput.vue
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Search } from "lucide-vue-next";
|
||||
import { ListboxFilter, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCommand } from ".";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, required: false },
|
||||
autoFocus: { type: Boolean, required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
|
||||
const { filterState } = useCommand();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center border-b px-3" cmdk-input-wrapper>
|
||||
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<ListboxFilter
|
||||
v-bind="{ ...forwardedProps, ...$attrs }"
|
||||
v-model="filterState.search"
|
||||
auto-focus
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
86
resources/js/Components/ui/command/CommandItem.vue
Normal file
86
resources/js/Components/ui/command/CommandItem.vue
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<script setup>
|
||||
import { reactiveOmit, useCurrentElement } from "@vueuse/core";
|
||||
import { ListboxItem, useForwardPropsEmits, useId } from "reka-ui";
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCommand, useCommandGroup } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: null, required: true },
|
||||
disabled: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
const emits = defineEmits(["select"]);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
|
||||
const id = useId();
|
||||
const { filterState, allItems, allGroups } = useCommand();
|
||||
const groupContext = useCommandGroup();
|
||||
|
||||
const isRender = computed(() => {
|
||||
if (!filterState.search) {
|
||||
return true;
|
||||
} else {
|
||||
const filteredCurrentItem = filterState.filtered.items.get(id);
|
||||
// If the filtered items is undefined means not in the all times map yet
|
||||
// Do the first render to add into the map
|
||||
if (filteredCurrentItem === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check with filter
|
||||
return filteredCurrentItem > 0;
|
||||
}
|
||||
});
|
||||
|
||||
const itemRef = ref();
|
||||
const currentElement = useCurrentElement(itemRef);
|
||||
onMounted(() => {
|
||||
if (!(currentElement.value instanceof HTMLElement)) return;
|
||||
|
||||
// textValue to perform filter
|
||||
allItems.value.set(
|
||||
id,
|
||||
currentElement.value.textContent ?? props?.value.toString(),
|
||||
);
|
||||
|
||||
const groupId = groupContext?.id;
|
||||
if (groupId) {
|
||||
if (!allGroups.value.has(groupId)) {
|
||||
allGroups.value.set(groupId, new Set([id]));
|
||||
} else {
|
||||
allGroups.value.get(groupId)?.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
onUnmounted(() => {
|
||||
allItems.value.delete(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListboxItem
|
||||
v-if="isRender"
|
||||
v-bind="forwarded"
|
||||
:id="id"
|
||||
ref="itemRef"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@select="
|
||||
() => {
|
||||
filterState.search = '';
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</ListboxItem>
|
||||
</template>
|
||||
26
resources/js/Components/ui/command/CommandList.vue
Normal file
26
resources/js/Components/ui/command/CommandList.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ListboxContent, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListboxContent
|
||||
v-bind="forwarded"
|
||||
:class="cn('max-h-[300px] overflow-y-auto overflow-x-hidden', props.class)"
|
||||
>
|
||||
<div role="presentation">
|
||||
<slot />
|
||||
</div>
|
||||
</ListboxContent>
|
||||
</template>
|
||||
24
resources/js/Components/ui/command/CommandSeparator.vue
Normal file
24
resources/js/Components/ui/command/CommandSeparator.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Separator } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
orientation: { type: String, required: false },
|
||||
decorative: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Separator
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('-mx-1 h-px bg-border', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Separator>
|
||||
</template>
|
||||
17
resources/js/Components/ui/command/CommandShortcut.vue
Normal file
17
resources/js/Components/ui/command/CommandShortcut.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
16
resources/js/Components/ui/command/index.js
Normal file
16
resources/js/Components/ui/command/index.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { createContext } from "reka-ui";
|
||||
|
||||
export { default as Command } from "./Command.vue";
|
||||
export { default as CommandDialog } from "./CommandDialog.vue";
|
||||
export { default as CommandEmpty } from "./CommandEmpty.vue";
|
||||
export { default as CommandGroup } from "./CommandGroup.vue";
|
||||
export { default as CommandInput } from "./CommandInput.vue";
|
||||
export { default as CommandItem } from "./CommandItem.vue";
|
||||
export { default as CommandList } from "./CommandList.vue";
|
||||
export { default as CommandSeparator } from "./CommandSeparator.vue";
|
||||
export { default as CommandShortcut } from "./CommandShortcut.vue";
|
||||
|
||||
export const [useCommand, provideCommandContext] = createContext("Command");
|
||||
|
||||
export const [useCommandGroup, provideCommandGroupContext] =
|
||||
createContext("CommandGroup");
|
||||
|
|
@ -12,7 +12,7 @@ const forwarded = useForwardPropsEmits(props, emits);
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-bind="forwarded">
|
||||
<slot />
|
||||
<DialogRoot v-slot="slotProps" data-slot="dialog" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
|
|
|||
29
resources/js/Components/ui/dialog/DialogOverlay.vue
Normal file
29
resources/js/Components/ui/dialog/DialogOverlay.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DialogOverlay } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
forceMount: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
|
|
@ -8,7 +8,7 @@ const props = defineProps({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger v-bind="props">
|
||||
<DialogTrigger v-bind="props" class="cursor-pointer">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
props.class,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const forwardedProps = useForwardProps(props);
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuTrigger class="outline-none" v-bind="forwardedProps">
|
||||
<DropdownMenuTrigger class="outline-none cursor-pointer" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
</template>
|
||||
|
|
|
|||
33
resources/js/Components/ui/input-group/InputGroup.vue
Normal file
33
resources/js/Components/ui/input-group/InputGroup.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
:class="
|
||||
cn(
|
||||
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border outline-none',
|
||||
'h-9 min-w-0 has-[>textarea]:h-auto',
|
||||
|
||||
// Variants based on alignment.
|
||||
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
|
||||
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
|
||||
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
|
||||
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
|
||||
|
||||
// Focus state.
|
||||
'has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1',
|
||||
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
32
resources/js/Components/ui/input-group/InputGroupAddon.vue
Normal file
32
resources/js/Components/ui/input-group/InputGroupAddon.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { inputGroupAddonVariants } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
align: { type: null, required: false, default: "inline-start" },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
function handleInputGroupAddonClick(e) {
|
||||
const currentTarget = e.currentTarget;
|
||||
const target = e.target;
|
||||
if (target && target.closest("button")) {
|
||||
return;
|
||||
}
|
||||
if (currentTarget && currentTarget?.parentElement) {
|
||||
currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
:data-align="props.align"
|
||||
:class="cn(inputGroupAddonVariants({ align: props.align }), props.class)"
|
||||
@click="handleInputGroupAddonClick"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -19,9 +19,12 @@ const modelValue = useVModel(props, "modelValue", emits, {
|
|||
<template>
|
||||
<input
|
||||
v-model="modelValue"
|
||||
data-slot="input"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
33
resources/js/Components/ui/pagination/Pagination.vue
Normal file
33
resources/js/Components/ui/pagination/Pagination.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { PaginationRoot, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
page: { type: Number, required: false },
|
||||
defaultPage: { type: Number, required: false },
|
||||
itemsPerPage: { type: Number, required: true },
|
||||
total: { type: Number, required: false },
|
||||
siblingCount: { type: Number, required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
showEdges: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
const emits = defineEmits(["update:page"]);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="pagination"
|
||||
v-bind="forwarded"
|
||||
:class="cn('mx-auto flex w-full justify-center', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
24
resources/js/Components/ui/pagination/PaginationContent.vue
Normal file
24
resources/js/Components/ui/pagination/PaginationContent.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { PaginationList } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationList
|
||||
v-slot="slotProps"
|
||||
data-slot="pagination-content"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('flex flex-row items-center gap-1', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PaginationList>
|
||||
</template>
|
||||
27
resources/js/Components/ui/pagination/PaginationEllipsis.vue
Normal file
27
resources/js/Components/ui/pagination/PaginationEllipsis.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { MoreHorizontal } from "lucide-vue-next";
|
||||
import { PaginationEllipsis } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationEllipsis
|
||||
data-slot="pagination-ellipsis"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('flex size-9 items-center justify-center', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<MoreHorizontal class="size-4" />
|
||||
<span class="sr-only">More pages</span>
|
||||
</slot>
|
||||
</PaginationEllipsis>
|
||||
</template>
|
||||
36
resources/js/Components/ui/pagination/PaginationFirst.vue
Normal file
36
resources/js/Components/ui/pagination/PaginationFirst.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronLeftIcon } from "lucide-vue-next";
|
||||
import { PaginationFirst, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
size: { type: null, required: false, default: "default" },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size");
|
||||
const forwarded = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationFirst
|
||||
data-slot="pagination-first"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: 'ghost', size }),
|
||||
'gap-1 px-2.5 sm:pr-2.5',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeftIcon />
|
||||
<span class="hidden sm:block">First</span>
|
||||
</slot>
|
||||
</PaginationFirst>
|
||||
</template>
|
||||
35
resources/js/Components/ui/pagination/PaginationItem.vue
Normal file
35
resources/js/Components/ui/pagination/PaginationItem.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { PaginationListItem } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: Number, required: true },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
size: { type: null, required: false, default: "icon" },
|
||||
class: { type: null, required: false },
|
||||
isActive: { type: Boolean, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size", "isActive");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationListItem
|
||||
data-slot="pagination-item"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size,
|
||||
}),
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</PaginationListItem>
|
||||
</template>
|
||||
36
resources/js/Components/ui/pagination/PaginationLast.vue
Normal file
36
resources/js/Components/ui/pagination/PaginationLast.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronRightIcon } from "lucide-vue-next";
|
||||
import { PaginationLast, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
size: { type: null, required: false, default: "default" },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size");
|
||||
const forwarded = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationLast
|
||||
data-slot="pagination-last"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: 'ghost', size }),
|
||||
'gap-1 px-2.5 sm:pr-2.5',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<span class="hidden sm:block">Last</span>
|
||||
<ChevronRightIcon />
|
||||
</slot>
|
||||
</PaginationLast>
|
||||
</template>
|
||||
36
resources/js/Components/ui/pagination/PaginationNext.vue
Normal file
36
resources/js/Components/ui/pagination/PaginationNext.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronRightIcon } from "lucide-vue-next";
|
||||
import { PaginationNext, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
size: { type: null, required: false, default: "default" },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size");
|
||||
const forwarded = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationNext
|
||||
data-slot="pagination-next"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: 'ghost', size }),
|
||||
'gap-1 px-2.5 sm:pr-2.5',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<span class="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</slot>
|
||||
</PaginationNext>
|
||||
</template>
|
||||
36
resources/js/Components/ui/pagination/PaginationPrevious.vue
Normal file
36
resources/js/Components/ui/pagination/PaginationPrevious.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronLeftIcon } from "lucide-vue-next";
|
||||
import { PaginationPrev, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
size: { type: null, required: false, default: "default" },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size");
|
||||
const forwarded = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationPrev
|
||||
data-slot="pagination-previous"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: 'ghost', size }),
|
||||
'gap-1 px-2.5 sm:pr-2.5',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeftIcon />
|
||||
<span class="hidden sm:block">Previous</span>
|
||||
</slot>
|
||||
</PaginationPrev>
|
||||
</template>
|
||||
8
resources/js/Components/ui/pagination/index.js
Normal file
8
resources/js/Components/ui/pagination/index.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export { default as Pagination } from "./Pagination.vue";
|
||||
export { default as PaginationContent } from "./PaginationContent.vue";
|
||||
export { default as PaginationEllipsis } from "./PaginationEllipsis.vue";
|
||||
export { default as PaginationFirst } from "./PaginationFirst.vue";
|
||||
export { default as PaginationItem } from "./PaginationItem.vue";
|
||||
export { default as PaginationLast } from "./PaginationLast.vue";
|
||||
export { default as PaginationNext } from "./PaginationNext.vue";
|
||||
export { default as PaginationPrevious } from "./PaginationPrevious.vue";
|
||||
|
|
@ -16,11 +16,11 @@ const delegatedProps = reactiveOmit(props, "class");
|
|||
|
||||
<template>
|
||||
<Separator
|
||||
data-slot="separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 bg-border',
|
||||
props.orientation === 'horizontal' ? 'h-px w-full' : 'w-px h-full',
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ const modelValue = useVModel(props, "modelValue", emits, {
|
|||
<template>
|
||||
<textarea
|
||||
v-model="modelValue"
|
||||
data-slot="textarea"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
22
resources/js/Components/ui/tooltip/Tooltip.vue
Normal file
22
resources/js/Components/ui/tooltip/Tooltip.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup>
|
||||
import { TooltipRoot, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps({
|
||||
defaultOpen: { type: Boolean, required: false },
|
||||
open: { type: Boolean, required: false },
|
||||
delayDuration: { type: Number, required: false },
|
||||
disableHoverableContent: { type: Boolean, required: false },
|
||||
disableClosingTrigger: { type: Boolean, required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
ignoreNonKeyboardFocus: { type: Boolean, required: false },
|
||||
});
|
||||
const emits = defineEmits(["update:open"]);
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TooltipRoot>
|
||||
</template>
|
||||
51
resources/js/Components/ui/tooltip/TooltipContent.vue
Normal file
51
resources/js/Components/ui/tooltip/TooltipContent.vue
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { TooltipContent, TooltipPortal, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
forceMount: { type: Boolean, required: false },
|
||||
ariaLabel: { type: String, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
side: { type: null, required: false },
|
||||
sideOffset: { type: Number, required: false, default: 4 },
|
||||
align: { type: null, required: false },
|
||||
alignOffset: { type: Number, required: false },
|
||||
avoidCollisions: { type: Boolean, required: false },
|
||||
collisionBoundary: { type: null, required: false },
|
||||
collisionPadding: { type: [Number, Object], required: false },
|
||||
arrowPadding: { type: Number, required: false },
|
||||
sticky: { type: String, required: false },
|
||||
hideWhenDetached: { type: Boolean, required: false },
|
||||
positionStrategy: { type: String, required: false },
|
||||
updatePositionStrategy: { type: String, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const emits = defineEmits(["escapeKeyDown", "pointerDownOutside"]);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</template>
|
||||
18
resources/js/Components/ui/tooltip/TooltipProvider.vue
Normal file
18
resources/js/Components/ui/tooltip/TooltipProvider.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script setup>
|
||||
import { TooltipProvider } from "reka-ui";
|
||||
|
||||
const props = defineProps({
|
||||
delayDuration: { type: Number, required: false },
|
||||
skipDelayDuration: { type: Number, required: false },
|
||||
disableHoverableContent: { type: Boolean, required: false },
|
||||
disableClosingTrigger: { type: Boolean, required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
ignoreNonKeyboardFocus: { type: Boolean, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider v-bind="props">
|
||||
<slot />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
15
resources/js/Components/ui/tooltip/TooltipTrigger.vue
Normal file
15
resources/js/Components/ui/tooltip/TooltipTrigger.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup>
|
||||
import { TooltipTrigger } from "reka-ui";
|
||||
|
||||
const props = defineProps({
|
||||
reference: { type: null, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipTrigger v-bind="props">
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
</template>
|
||||
4
resources/js/Components/ui/tooltip/index.js
Normal file
4
resources/js/Components/ui/tooltip/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { default as Tooltip } from "./Tooltip.vue";
|
||||
export { default as TooltipContent } from "./TooltipContent.vue";
|
||||
export { default as TooltipProvider } from "./TooltipProvider.vue";
|
||||
export { default as TooltipTrigger } from "./TooltipTrigger.vue";
|
||||
|
|
@ -3,9 +3,10 @@ import AppLayout from "@/Layouts/AppLayout.vue";
|
|||
import SectionTitle from "@/Components/SectionTitle.vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { ref } from "vue";
|
||||
import DataTable from "@/Components/DataTable/DataTable.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";
|
||||
|
||||
const props = defineProps({
|
||||
client_cases: Object,
|
||||
|
|
@ -50,10 +51,8 @@ const fmtDateDMY = (v) => {
|
|||
</SectionTitle>
|
||||
</div>
|
||||
<DataTable
|
||||
:show-search="true"
|
||||
:show-page-size="true"
|
||||
:columns="[
|
||||
{ key: 'nu', label: 'Št.', sortable: false, class: 'w-40' },
|
||||
{ key: 'nu', label: 'Št.', sortable: false },
|
||||
{ key: 'case', label: 'Primer', sortable: false },
|
||||
{ key: 'client', label: 'Stranka', sortable: false },
|
||||
{ key: 'tax', label: 'Davčna', sortable: false },
|
||||
|
|
@ -71,23 +70,13 @@ const fmtDateDMY = (v) => {
|
|||
align: 'right',
|
||||
},
|
||||
]"
|
||||
:rows="client_cases.data || []"
|
||||
:meta="{
|
||||
current_page: client_cases.current_page,
|
||||
per_page: client_cases.per_page,
|
||||
total: client_cases.total,
|
||||
last_page: client_cases.last_page,
|
||||
from: client_cases.from,
|
||||
to: client_cases.to,
|
||||
links: client_cases.links,
|
||||
}"
|
||||
:search="search"
|
||||
route-name="clientCase"
|
||||
page-param-name="client-cases-page"
|
||||
:only-props="['client_cases']"
|
||||
:empty-icon="faFolderOpen"
|
||||
empty-text="Ni zadetkov"
|
||||
empty-description="Ni najdenih primerov. Ustvarite nov primer ali preverite iskalne kriterije."
|
||||
:data="client_cases.data || []"
|
||||
:page-size="client_cases.per_page"
|
||||
:show-pagination="false"
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
row-key="uuid"
|
||||
empty-text="Ni najdenih primerov."
|
||||
>
|
||||
<template #cell-nu="{ row }">
|
||||
{{ row.person?.nu || "-" }}
|
||||
|
|
@ -130,6 +119,19 @@ const fmtDateDMY = (v) => {
|
|||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<Pagination
|
||||
:links="client_cases.links"
|
||||
:from="client_cases.from"
|
||||
:to="client_cases.to"
|
||||
:total="client_cases.total"
|
||||
:per-page="client_cases.per_page || 20"
|
||||
:last-page="client_cases.last_page"
|
||||
:current-page="client_cases.current_page"
|
||||
per-page-param="perPage"
|
||||
page-param="clientCasesPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pagination handled by DataTableServer -->
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,28 @@
|
|||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { ref, computed, useSlots, watch, onMounted } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import DataTable from "@/Components/DataTable/DataTable.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import DeleteDialog from "@/Components/Dialogs/DeleteDialog.vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faTrash, faEllipsisVertical, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import TableActions from "@/Components/DataTable/TableActions.vue";
|
||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/Components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||
import { RangeCalendar } from "@/Components/ui/range-calendar";
|
||||
import { CalendarIcon, X, Filter, Check, ChevronsUpDown } from "lucide-vue-next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DateFormatter, getLocalTimeZone, parseDate } from "@internationalized/date";
|
||||
|
||||
library.add(faTrash, faEllipsisVertical, faCopy);
|
||||
|
||||
|
|
@ -14,15 +30,219 @@ const props = defineProps({
|
|||
client_case: Object,
|
||||
activities: Object,
|
||||
edit: Boolean,
|
||||
actions: Array,
|
||||
contracts: [Object, Array],
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
// Filter state
|
||||
const filters = ref({
|
||||
activity_action_id: null,
|
||||
activity_contract_uuid: null,
|
||||
activity_user_id: null,
|
||||
activity_date_from: null,
|
||||
activity_date_to: null,
|
||||
});
|
||||
|
||||
// Date range for calendar
|
||||
const dateRange = ref(undefined);
|
||||
|
||||
// Filter popover state
|
||||
const filterPopoverOpen = ref(false);
|
||||
const actionComboboxOpen = ref(false);
|
||||
const contractComboboxOpen = ref(false);
|
||||
const userComboboxOpen = ref(false);
|
||||
|
||||
// Date formatter
|
||||
const df = new DateFormatter("sl-SI", {
|
||||
dateStyle: "medium",
|
||||
});
|
||||
|
||||
// Get unique users from activities for filter dropdown
|
||||
const uniqueUsers = computed(() => {
|
||||
const users = new Map();
|
||||
if (props.activities?.data) {
|
||||
props.activities.data.forEach((activity) => {
|
||||
if (activity.user) {
|
||||
users.set(activity.user.id, activity.user);
|
||||
}
|
||||
});
|
||||
}
|
||||
return Array.from(users.values());
|
||||
});
|
||||
|
||||
// Get contract options - handle both array and object with data property
|
||||
const contractOptions = computed(() => {
|
||||
if (!props.contracts) return [];
|
||||
if (Array.isArray(props.contracts)) {
|
||||
return props.contracts;
|
||||
}
|
||||
if (props.contracts.data && Array.isArray(props.contracts.data)) {
|
||||
return props.contracts.data;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Get action options with their decisions
|
||||
const actionOptions = computed(() => {
|
||||
if (!props.actions || !Array.isArray(props.actions)) return [];
|
||||
return props.actions;
|
||||
});
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = computed(() => {
|
||||
return Object.values(filters.value).some((val) => val !== null && val !== "");
|
||||
});
|
||||
|
||||
// Selected items for combobox display
|
||||
const selectedAction = computed(() =>
|
||||
actionOptions.value.find((a) => a.id === filters.value.activity_action_id)
|
||||
);
|
||||
|
||||
const selectedContract = computed(() =>
|
||||
contractOptions.value.find((c) => c.uuid === filters.value.activity_contract_uuid)
|
||||
);
|
||||
|
||||
const selectedUser = computed(() =>
|
||||
uniqueUsers.value.find((u) => u.id === filters.value.activity_user_id)
|
||||
);
|
||||
|
||||
// Apply filters
|
||||
const applyFilters = () => {
|
||||
// Sync date range to filter values
|
||||
if (dateRange.value?.start) {
|
||||
filters.value.activity_date_from = dateRange.value.start.toString();
|
||||
}
|
||||
if (dateRange.value?.end) {
|
||||
filters.value.activity_date_to = dateRange.value.end.toString();
|
||||
}
|
||||
|
||||
// Build filter object with only non-null values
|
||||
const filterObj = {};
|
||||
if (filters.value.activity_action_id) {
|
||||
filterObj.action_id = filters.value.activity_action_id;
|
||||
}
|
||||
if (filters.value.activity_contract_uuid) {
|
||||
filterObj.contract_uuid = filters.value.activity_contract_uuid;
|
||||
}
|
||||
if (filters.value.activity_user_id) {
|
||||
filterObj.user_id = filters.value.activity_user_id;
|
||||
}
|
||||
if (filters.value.activity_date_from) {
|
||||
filterObj.date_from = filters.value.activity_date_from;
|
||||
}
|
||||
if (filters.value.activity_date_to) {
|
||||
filterObj.date_to = filters.value.activity_date_to;
|
||||
}
|
||||
|
||||
console.log("Applying filters:", filterObj);
|
||||
|
||||
// Build query params object
|
||||
const queryParams = {};
|
||||
|
||||
// Preserve existing query params (like segment)
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
if (key !== "filter_activities") {
|
||||
queryParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Only add filter param if there are active filters
|
||||
if (Object.keys(filterObj).length > 0) {
|
||||
const compressed = btoa(JSON.stringify(filterObj));
|
||||
console.log("Compressed filter:", compressed);
|
||||
queryParams.filter_activities = compressed;
|
||||
}
|
||||
|
||||
console.log("Query params:", queryParams);
|
||||
|
||||
router.get(
|
||||
route("clientCase.show", {
|
||||
client_case: props.client_case.uuid,
|
||||
...queryParams,
|
||||
}),
|
||||
{},
|
||||
{
|
||||
only: ["activities"],
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
}
|
||||
);
|
||||
filterPopoverOpen.value = false;
|
||||
};
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = () => {
|
||||
filters.value = {
|
||||
activity_action_id: null,
|
||||
activity_contract_uuid: null,
|
||||
activity_user_id: null,
|
||||
activity_date_from: null,
|
||||
activity_date_to: null,
|
||||
};
|
||||
dateRange.value = undefined;
|
||||
filterPopoverOpen.value = false;
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
// Load filters from URL on mount
|
||||
onMounted(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const filterParam = searchParams.get("filter_activities");
|
||||
|
||||
if (filterParam) {
|
||||
try {
|
||||
const decompressed = atob(filterParam);
|
||||
const filterObj = JSON.parse(decompressed);
|
||||
|
||||
if (filterObj) {
|
||||
filters.value.activity_action_id = filterObj.action_id || null;
|
||||
filters.value.activity_contract_uuid = filterObj.contract_uuid || null;
|
||||
filters.value.activity_user_id = filterObj.user_id || null;
|
||||
filters.value.activity_date_from = filterObj.date_from || null;
|
||||
filters.value.activity_date_to = filterObj.date_to || null;
|
||||
|
||||
// Parse dates for calendar
|
||||
if (filterObj.date_from && filterObj.date_to) {
|
||||
try {
|
||||
dateRange.value = {
|
||||
start: parseDate(filterObj.date_from),
|
||||
end: parseDate(filterObj.date_to),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to parse dates:", e);
|
||||
}
|
||||
} else if (filterObj.date_from) {
|
||||
try {
|
||||
dateRange.value = {
|
||||
start: parseDate(filterObj.date_from),
|
||||
end: parseDate(filterObj.date_from),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to parse date_from:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load filters from URL:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: "decision_dot", label: " ", class: "w-[6%]" },
|
||||
{ key: "contract", label: "Pogodba", class: "w-[14%]" },
|
||||
{ key: "decision", label: "Odločitev", class: "w-[26%]" },
|
||||
{ key: "note", label: "Opomba", class: "w-[14%]" },
|
||||
{ key: "promise", label: "Obljuba", class: "w-[20%]" },
|
||||
{ key: "user", label: "Dodal", class: "w-[10%]" },
|
||||
{ key: "decision_dot", label: "", sortable: false, align: "center" },
|
||||
{ key: "contract", label: "Pogodba", sortable: false },
|
||||
{ key: "decision", label: "Odločitev", sortable: false },
|
||||
{ key: "note", label: "Opomba", sortable: false },
|
||||
{ key: "promise", label: "Obljuba", sortable: false },
|
||||
{ key: "user", label: "Dodal", sortable: false },
|
||||
{ key: "actions", label: "", sortable: false, hideable: false, align: "center" },
|
||||
];
|
||||
|
||||
const rows = computed(() => props.activities?.data || []);
|
||||
|
|
@ -59,7 +279,9 @@ const fmtDateTime = (d) => {
|
|||
const fmtCurrency = (v) => {
|
||||
const n = Number(v ?? 0);
|
||||
try {
|
||||
return new Intl.NumberFormat("sl-SI", { style: "currency", currency: "EUR" }).format(n);
|
||||
return new Intl.NumberFormat("sl-SI", { style: "currency", currency: "EUR" }).format(
|
||||
n
|
||||
);
|
||||
} catch {
|
||||
return `${n.toFixed(2)} €`;
|
||||
}
|
||||
|
|
@ -109,141 +331,450 @@ const copyToClipboard = async (text) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
:show-toolbar="true"
|
||||
:data="rows"
|
||||
:meta="activities"
|
||||
route-name="clientCase.show"
|
||||
:route-params="{ client_case: client_case.uuid }"
|
||||
:only-props="['activities']"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
page-param-name="activities_page"
|
||||
per-page-param-name="activities_per_page"
|
||||
:show-pagination="false"
|
||||
:show-search="false"
|
||||
:show-page-size="false"
|
||||
:show-add="!!$slots.add"
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
row-key="id"
|
||||
empty-text="Ni aktivnosti."
|
||||
class="border-0"
|
||||
>
|
||||
<template #toolbar-add>
|
||||
<slot name="add" />
|
||||
</template>
|
||||
<template #toolbar-filters>
|
||||
<!-- Filter Popover -->
|
||||
<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"
|
||||
>
|
||||
{{ Object.values(filters).filter((v) => v !== null && v !== "").length }}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[400px]" align="start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium text-sm">Filtri aktivnosti</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Izberite filtre za prikaz aktivnosti
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #cell-decision_dot="{ row }">
|
||||
<div class="flex justify-center">
|
||||
<span
|
||||
v-if="row.decision?.color_tag"
|
||||
class="inline-block h-4 w-4 rounded-full ring-1 ring-gray-300"
|
||||
:style="{ backgroundColor: row.decision?.color_tag }"
|
||||
:title="row.decision?.color_tag"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-contract="{ row }">
|
||||
<span v-if="row.contract?.reference">{{ row.contract.reference }}</span>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-decision="{ row }">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
v-if="row.action?.name"
|
||||
class="inline-block w-fit px-2 py-0.5 rounded text-[10px] font-medium bg-indigo-100 text-indigo-700 tracking-wide uppercase"
|
||||
>
|
||||
{{ row.action.name }}
|
||||
</span>
|
||||
<span class="text-gray-800">{{ row.decision?.name || "" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-note="{ row }">
|
||||
<div class="max-w-[280px] whitespace-pre-wrap break-words leading-snug">
|
||||
<template v-if="row.note && row.note.length <= 60">
|
||||
{{ row.note }}
|
||||
</template>
|
||||
<template v-else-if="row.note">
|
||||
<span>{{ row.note.slice(0, 60) }}… </span>
|
||||
<Dropdown align="left" width="56" :content-classes="['p-2', 'bg-white', 'shadow', 'max-w-xs']">
|
||||
<template #trigger>
|
||||
<button type="button" class="inline-flex items-center text-[11px] text-indigo-600 hover:underline">
|
||||
Več
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="relative" @click.stop>
|
||||
<div class="flex items-center justify-between p-1 border-b border-gray-200">
|
||||
<span class="text-xs font-medium text-gray-600">Opomba</span>
|
||||
<button
|
||||
@click="copyToClipboard(row.note)"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded transition-colors"
|
||||
title="Kopiraj v odložišče"
|
||||
<div class="space-y-3">
|
||||
<!-- Action Filter -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium">Akcija</label>
|
||||
<Popover v-model:open="actionComboboxOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="actionComboboxOpen"
|
||||
class="w-full justify-between"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faCopy" class="w-3 h-3" />
|
||||
<span>Kopiraj</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-60 overflow-auto text-[12px] whitespace-pre-wrap break-words p-2">
|
||||
{{ row.note }}
|
||||
</div>
|
||||
{{ selectedAction?.name || "Vse akcije" }}
|
||||
<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 akcijo..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Akcija ni najdena.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
:value="'null'"
|
||||
@select="
|
||||
() => {
|
||||
filters.activity_action_id = null;
|
||||
actionComboboxOpen = false;
|
||||
}
|
||||
"
|
||||
>
|
||||
<Check
|
||||
:class="
|
||||
cn(
|
||||
'mr-2 h-4 w-4',
|
||||
filters.activity_action_id === null
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
Vse akcije
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
v-for="action in actionOptions"
|
||||
:key="action.id"
|
||||
:value="action.name"
|
||||
@select="
|
||||
() => {
|
||||
filters.activity_action_id = action.id;
|
||||
actionComboboxOpen = false;
|
||||
}
|
||||
"
|
||||
>
|
||||
<Check
|
||||
:class="
|
||||
cn(
|
||||
'mr-2 h-4 w-4',
|
||||
filters.activity_action_id === action.id
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{ action.name }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Contract Filter -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium">Pogodba</label>
|
||||
<Popover v-model:open="contractComboboxOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="contractComboboxOpen"
|
||||
class="w-full justify-between"
|
||||
>
|
||||
{{ selectedContract?.reference || "Vse pogodbe" }}
|
||||
<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 pogodbo..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Pogodba ni najdena.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
:value="'null'"
|
||||
@select="
|
||||
() => {
|
||||
filters.activity_contract_uuid = null;
|
||||
contractComboboxOpen = false;
|
||||
}
|
||||
"
|
||||
>
|
||||
<Check
|
||||
:class="
|
||||
cn(
|
||||
'mr-2 h-4 w-4',
|
||||
filters.activity_contract_uuid === null
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
Vse pogodbe
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
v-for="contract in contractOptions"
|
||||
:key="contract.uuid"
|
||||
:value="contract.reference"
|
||||
@select="
|
||||
() => {
|
||||
filters.activity_contract_uuid = contract.uuid;
|
||||
contractComboboxOpen = false;
|
||||
}
|
||||
"
|
||||
>
|
||||
<Check
|
||||
:class="
|
||||
cn(
|
||||
'mr-2 h-4 w-4',
|
||||
filters.activity_contract_uuid === contract.uuid
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{ contract.reference }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- User Filter -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium">Uporabnik</label>
|
||||
<Popover v-model:open="userComboboxOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="userComboboxOpen"
|
||||
class="w-full justify-between"
|
||||
>
|
||||
{{ selectedUser?.name || "Vsi uporabniki" }}
|
||||
<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 uporabnika..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Uporabnik ni najden.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
:value="'null'"
|
||||
@select="
|
||||
() => {
|
||||
filters.activity_user_id = null;
|
||||
userComboboxOpen = false;
|
||||
}
|
||||
"
|
||||
>
|
||||
<Check
|
||||
:class="
|
||||
cn(
|
||||
'mr-2 h-4 w-4',
|
||||
filters.activity_user_id === null
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
Vsi uporabniki
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
v-for="user in uniqueUsers"
|
||||
:key="user.id"
|
||||
:value="user.name"
|
||||
@select="
|
||||
() => {
|
||||
filters.activity_user_id = user.id;
|
||||
userComboboxOpen = false;
|
||||
}
|
||||
"
|
||||
>
|
||||
<Check
|
||||
:class="
|
||||
cn(
|
||||
'mr-2 h-4 w-4',
|
||||
filters.activity_user_id === user.id
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{ user.name }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Date Range -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium">Časovno obdobje</label>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
:class="[
|
||||
'w-full justify-start text-left font-normal',
|
||||
!dateRange?.start && 'text-muted-foreground',
|
||||
]"
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
<template v-if="dateRange?.start">
|
||||
<template v-if="dateRange?.end">
|
||||
{{ df.format(dateRange.start.toDate(getLocalTimeZone())) }}
|
||||
-
|
||||
{{ df.format(dateRange.end.toDate(getLocalTimeZone())) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ df.format(dateRange.start.toDate(getLocalTimeZone())) }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else> Izberite obdobje </template>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" align="start">
|
||||
<RangeCalendar
|
||||
v-model="dateRange"
|
||||
:number-of-months="2"
|
||||
locale="sl-SI"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between pt-2 border-t">
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="clearFilters"
|
||||
class="gap-2"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
Počisti
|
||||
</Button>
|
||||
<div v-else></div>
|
||||
<Button variant="default" size="sm" @click="applyFilters" class="gap-2">
|
||||
Uporabi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<template #toolbar-actions>
|
||||
<slot name="add" />
|
||||
</template>
|
||||
|
||||
<template #cell-decision_dot="{ row }">
|
||||
<div class="flex justify-center">
|
||||
<span
|
||||
v-if="row.decision?.color_tag"
|
||||
class="inline-block h-4 w-4 rounded-full ring-1 ring-gray-300"
|
||||
:style="{ backgroundColor: row.decision?.color_tag }"
|
||||
:title="row.decision?.color_tag"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-contract="{ row }">
|
||||
<span v-if="row.contract?.reference">{{ row.contract.reference }}</span>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-decision="{ row }">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
v-if="row.action?.name"
|
||||
class="inline-block w-fit px-2 py-0.5 rounded text-[10px] font-medium bg-indigo-100 text-indigo-700 tracking-wide uppercase"
|
||||
>
|
||||
{{ row.action.name }}
|
||||
</span>
|
||||
<span class="text-gray-800">{{ row.decision?.name || "" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-note="{ row }">
|
||||
<div class="max-w-[280px] whitespace-pre-wrap wrap-break-word leading-snug">
|
||||
<template v-if="row.note && row.note.length <= 60">
|
||||
{{ row.note }}
|
||||
</template>
|
||||
<template v-else-if="row.note">
|
||||
<span>{{ row.note.slice(0, 60) }}… </span>
|
||||
<Dropdown
|
||||
align="left"
|
||||
width="56"
|
||||
:content-classes="['p-2', 'bg-white', 'shadow', 'max-w-xs']"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center text-[11px] text-indigo-600 hover:underline"
|
||||
>
|
||||
Več
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="relative" @click.stop>
|
||||
<div
|
||||
class="flex items-center justify-between p-1 border-b border-gray-200"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600">Opomba</span>
|
||||
<button
|
||||
@click="copyToClipboard(row.note)"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded transition-colors"
|
||||
title="Kopiraj v odložišče"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faCopy" class="w-3 h-3" />
|
||||
<span>Kopiraj</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-gray-400">—</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
class="max-h-60 overflow-auto text-[12px] whitespace-pre-wrap wrap-break-word p-2"
|
||||
>
|
||||
{{ row.note }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-gray-400">—</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-promise="{ row }">
|
||||
<div class="flex flex-col gap-1 text-[12px]">
|
||||
<div v-if="row.amount && Number(row.amount) !== 0" class="leading-tight">
|
||||
<span class="text-gray-500">Z:</span>
|
||||
<span class="font-medium ml-1">{{ fmtCurrency(row.amount) }}</span>
|
||||
</div>
|
||||
<div v-if="row.due_date" class="leading-tight">
|
||||
<span class="text-gray-500">D:</span>
|
||||
<span class="ml-1">{{ fmtDate(row.due_date) }}</span>
|
||||
</div>
|
||||
<div v-if="!row.due_date && (!row.amount || Number(row.amount) === 0)" class="text-gray-400">
|
||||
—
|
||||
</div>
|
||||
<template #cell-promise="{ row }">
|
||||
<div class="flex flex-col gap-1 text-[12px]">
|
||||
<div v-if="row.amount && Number(row.amount) !== 0" class="leading-tight">
|
||||
<span class="text-gray-500">Z:</span>
|
||||
<span class="font-medium ml-1">{{ fmtCurrency(row.amount) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="row.due_date" class="leading-tight">
|
||||
<span class="text-gray-500">D:</span>
|
||||
<span class="ml-1">{{ fmtDate(row.due_date) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!row.due_date && (!row.amount || Number(row.amount) === 0)"
|
||||
class="text-gray-400"
|
||||
>
|
||||
—
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-user="{ row }">
|
||||
<div class="text-gray-800 font-medium leading-tight">
|
||||
{{ row.user?.name || row.user_name || "" }}
|
||||
</div>
|
||||
<div v-if="row.created_at" class="mt-1">
|
||||
<span class="inline-block px-2 py-0.5 rounded text-[11px] bg-gray-100 text-gray-600 tracking-wide">
|
||||
{{ fmtDateTime(row.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-user="{ row }">
|
||||
<div class="text-gray-800 font-medium leading-tight">
|
||||
{{ row.user?.name || row.user_name || "" }}
|
||||
</div>
|
||||
<div v-if="row.created_at" class="mt-1">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded text-[11px] bg-gray-100 text-gray-600 tracking-wide"
|
||||
>
|
||||
{{ fmtDateTime(row.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }" v-if="edit">
|
||||
<Dropdown align="right" width="30">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
|
||||
title="Možnosti"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4 text-gray-700" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-red-50 text-red-600"
|
||||
@click.stop="openDelete(row)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="['fas', 'trash']" class="w-4 h-4" />
|
||||
<span>Izbriši</span>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
<template #cell-actions="{ row }" v-if="edit">
|
||||
<TableActions align="right">
|
||||
<template #default>
|
||||
<ActionMenuItem
|
||||
:icon="faTrash"
|
||||
label="Izbriši"
|
||||
danger
|
||||
@click="openDelete(row)"
|
||||
/>
|
||||
</template>
|
||||
</TableActions>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
|
|
@ -256,4 +787,3 @@ const copyToClipboard = async (text) => {
|
|||
@confirm="confirmDeleteAction"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { router, useForm } from "@inertiajs/vue3";
|
||||
import DataTable from "@/Components/DataTable/DataTable.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import StatusBadge from "@/Components/DataTable/StatusBadge.vue";
|
||||
import TableActions from "@/Components/DataTable/TableActions.vue";
|
||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
|
||||
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
|
||||
import PaymentDialog from "./PaymentDialog.vue";
|
||||
|
|
@ -27,6 +32,7 @@ import {
|
|||
faFolderOpen,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import EmptyState from "@/Components/EmptyState.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
|
||||
const props = defineProps({
|
||||
client: { type: Object, default: null },
|
||||
|
|
@ -40,7 +46,7 @@ const props = defineProps({
|
|||
createDoc: { type: Boolean, default: () => false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["edit", "delete", "add-activity"]);
|
||||
const emit = defineEmits(["edit", "delete", "add-activity", "create", "attach-segment"]);
|
||||
|
||||
const formatDate = (d) => {
|
||||
if (!d) return "-";
|
||||
|
|
@ -106,7 +112,8 @@ const getMetaEntries = (c) => {
|
|||
const hasValue = Object.prototype.hasOwnProperty.call(node, "value");
|
||||
const hasTitle = Object.prototype.hasOwnProperty.call(node, "title");
|
||||
if (hasValue || hasTitle) {
|
||||
const title = (node.title || keyName || "").toString().trim() || keyName || "Meta";
|
||||
const title =
|
||||
(node.title || keyName || "").toString().trim() || keyName || "Meta";
|
||||
results.push({ title, value: node.value, type: node.type });
|
||||
return;
|
||||
}
|
||||
|
|
@ -428,36 +435,54 @@ const closePaymentsDialog = () => {
|
|||
|
||||
// Columns configuration
|
||||
const columns = computed(() => [
|
||||
{ key: "reference", label: "Ref.", sortable: false },
|
||||
{ key: "reference", label: "Ref.", sortable: false, align: "center" },
|
||||
{ key: "start_date", label: "Datum začetka", sortable: false },
|
||||
{ key: "type", label: "Tip", sortable: false },
|
||||
{ key: "segment", label: "Segment", sortable: false },
|
||||
{ key: "initial_amount", label: "Predano", sortable: false, align: "right" },
|
||||
{ key: "balance_amount", label: "Odprto", sortable: false, align: "right" },
|
||||
{ key: "meta_info", label: "Opis", sortable: false, align: "center" },
|
||||
{ key: "actions", label: "", sortable: false, hideable: false, align: "center" },
|
||||
]);
|
||||
|
||||
const onEdit = (c) => emit("edit", c);
|
||||
const onDelete = (c) => emit("delete", c);
|
||||
const onAddActivity = (c) => emit("add-activity", c);
|
||||
const onCreate = () => emit("create");
|
||||
const onAttachSegment = () => emit("attach-segment");
|
||||
|
||||
const availableSegmentsCount = computed(() => {
|
||||
const current = new Set((props.segments || []).map((s) => s.id));
|
||||
return (props.all_segments || []).filter((s) => !current.has(s.id)).length;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="contracts"
|
||||
:data="contracts"
|
||||
:empty-icon="faFolderOpen"
|
||||
empty-text="Ni pogodb"
|
||||
empty-description="Za ta primer še ni ustvarjenih pogodb. Ustvarite novo pogodbo za začetek."
|
||||
:show-pagination="false"
|
||||
:show-toolbar="false"
|
||||
:striped="true"
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
>
|
||||
<!-- Toolbar Actions -->
|
||||
<template #toolbar-actions v-if="edit">
|
||||
<Button variant="outline" @click="onCreate"> Nova </Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="onAttachSegment"
|
||||
:disabled="availableSegmentsCount === 0"
|
||||
>
|
||||
{{ availableSegmentsCount ? "Dodaj segment" : "Ni razpoložljivih segmentov" }}
|
||||
</Button>
|
||||
</template>
|
||||
<!-- Reference -->
|
||||
<template #cell-reference="{ row }">
|
||||
<span class="font-medium text-gray-900">{{ row.reference }}</span>
|
||||
<span class="font-medium text-gray-900 px-2">{{ row.reference }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Start Date -->
|
||||
|
|
@ -474,11 +499,11 @@ const onAddActivity = (c) => emit("add-activity", c);
|
|||
<template #cell-segment="{ row }">
|
||||
<div class="flex items-center gap-2" @click.stop>
|
||||
<span class="text-gray-700">{{ contractActiveSegment(row)?.name || "-" }}</span>
|
||||
<Dropdown align="left" v-if="edit">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-7 w-7 rounded-full hover:bg-gray-100 transition-colors"
|
||||
<DropdownMenu v-if="edit">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed':
|
||||
!segments || segments.length === 0 || !row.active,
|
||||
|
|
@ -493,64 +518,68 @@ const onAddActivity = (c) => emit("add-activity", c);
|
|||
:disabled="!row.active || !segments || !segments.length"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPenToSquare" class="h-4 w-4 text-gray-600" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="py-1">
|
||||
<template v-if="segments && segments.length">
|
||||
<button
|
||||
v-for="s in sortedSegments"
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<template v-if="segments && segments.length">
|
||||
<DropdownMenuItem
|
||||
v-for="s in sortedSegments"
|
||||
:key="s.id"
|
||||
@click="askChangeSegment(row, s.id)"
|
||||
>
|
||||
{{ s.name }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="all_segments && all_segments.length">
|
||||
<div class="px-3 py-2 text-xs text-gray-500">
|
||||
Ni segmentov v tem primeru. Dodaj in nastavi segment:
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
v-for="s in sortedAllSegments"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
|
||||
@click="askChangeSegment(row, s.id)"
|
||||
@click.stop
|
||||
@click="askChangeSegment(row, s.id, true)"
|
||||
>
|
||||
{{ s.name }}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="all_segments && all_segments.length">
|
||||
<div class="px-3 py-2 text-xs text-gray-500">
|
||||
Ni segmentov v tem primeru. Dodaj in nastavi segment:
|
||||
</div>
|
||||
<button
|
||||
v-for="s in sortedAllSegments"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
|
||||
@click="askChangeSegment(row, s.id, true)"
|
||||
>
|
||||
{{ s.name }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="px-3 py-2 text-sm text-gray-500">Ni konfiguriranih segmentov.</div>
|
||||
</template>
|
||||
<div class="px-3 py-2 text-sm text-gray-500">
|
||||
Ni konfiguriranih segmentov.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<StatusBadge v-if="!row.active" status="Arhivirano" variant="default" size="sm" />
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<StatusBadge
|
||||
v-if="!row.active"
|
||||
status="Arhivirano"
|
||||
variant="default"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Initial Amount -->
|
||||
<template #cell-initial_amount="{ row }">
|
||||
<div class="text-right">{{ formatCurrency(row?.account?.initial_amount ?? 0) }}</div>
|
||||
<div class="text-right">
|
||||
{{ formatCurrency(row?.account?.initial_amount ?? 0) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Balance Amount -->
|
||||
<template #cell-balance_amount="{ row }">
|
||||
<div class="text-right">{{ formatCurrency(row?.account?.balance_amount ?? 0) }}</div>
|
||||
<div class="text-right">
|
||||
{{ formatCurrency(row?.account?.balance_amount ?? 0) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Meta Info -->
|
||||
<template #cell-meta_info="{ row }">
|
||||
<div class="inline-flex items-center justify-center gap-0.5" @click.stop>
|
||||
<!-- Description -->
|
||||
<Dropdown align="right">
|
||||
<template #trigger>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-5 w-5 rounded-full transition-colors"
|
||||
|
|
@ -564,17 +593,17 @@ const onAddActivity = (c) => emit("add-activity", c);
|
|||
>
|
||||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<div class="max-w-sm px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap">
|
||||
{{ row.description }}
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Meta -->
|
||||
<Dropdown align="right">
|
||||
<template #trigger>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-5 w-5 rounded-full transition-colors"
|
||||
|
|
@ -588,29 +617,33 @@ const onAddActivity = (c) => emit("add-activity", c);
|
|||
>
|
||||
<FontAwesomeIcon :icon="faTags" class="h-4 w-4" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<div class="max-w-sm px-3 py-2 text-sm text-gray-700">
|
||||
<template v-if="hasMeta(row)">
|
||||
<div
|
||||
v-for="(m, idx) in getMetaEntries(row)"
|
||||
:key="idx"
|
||||
class="flex items-start gap-2 py-0.5"
|
||||
class="flex flex-col items-start gap-0.5 py-0.5 mb-0.5"
|
||||
>
|
||||
<span class="text-gray-500 whitespace-nowrap">{{ m.title }}:</span>
|
||||
<span class="text-gray-800">{{ formatMetaValue(m) }}</span>
|
||||
<span class="text-gray-500 text-xs whitespace-nowrap">{{
|
||||
m.title
|
||||
}}</span>
|
||||
<span class="text-gray-800 font-medium break-all">{{
|
||||
formatMetaValue(m)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-gray-500">Ni meta podatkov.</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Promise Date -->
|
||||
<Dropdown align="right">
|
||||
<template #trigger>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-5 w-5 rounded-full hover:bg-gray-100 focus:outline-none transition-colors"
|
||||
|
|
@ -621,13 +654,21 @@ const onAddActivity = (c) => emit("add-activity", c);
|
|||
"
|
||||
:disabled="!getPromiseDate(row)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faClock" class="h-4 w-4" :class="promiseColorClass(row)" />
|
||||
<FontAwesomeIcon
|
||||
:icon="faClock"
|
||||
class="h-4 w-4"
|
||||
:class="promiseColorClass(row)"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<div class="px-3 py-2 text-sm text-gray-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faClock" class="h-4 w-4" :class="promiseColorClass(row)" />
|
||||
<FontAwesomeIcon
|
||||
:icon="faClock"
|
||||
class="h-4 w-4"
|
||||
:class="promiseColorClass(row)"
|
||||
/>
|
||||
<span class="font-medium">Obljubljeno plačilo</span>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
|
|
@ -645,159 +686,174 @@ const onAddActivity = (c) => emit("add-activity", c);
|
|||
</div>
|
||||
<div class="mt-1 text-gray-500" v-else>Ni nastavljenega datuma.</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Actions -->
|
||||
<template #actions="{ row }">
|
||||
<template #cell-actions="{ row }">
|
||||
<div @click.stop>
|
||||
<TableActions align="right">
|
||||
<template #default="{ handleAction }">
|
||||
<!-- Editing -->
|
||||
<template v-if="edit">
|
||||
<div class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400">
|
||||
Urejanje
|
||||
</div>
|
||||
<template #default="{ handleAction }">
|
||||
<!-- Editing -->
|
||||
<template v-if="edit">
|
||||
<div
|
||||
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
Urejanje
|
||||
</div>
|
||||
<ActionMenuItem
|
||||
v-if="row.active"
|
||||
:icon="faPenToSquare"
|
||||
label="Uredi"
|
||||
@click="onEdit(row)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Add Activity -->
|
||||
<ActionMenuItem
|
||||
v-if="row.active"
|
||||
:icon="faPenToSquare"
|
||||
label="Uredi"
|
||||
@click="onEdit(row)"
|
||||
:icon="faListCheck"
|
||||
label="Dodaj aktivnost"
|
||||
@click="onAddActivity(row)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Add Activity -->
|
||||
<ActionMenuItem
|
||||
v-if="row.active"
|
||||
:icon="faListCheck"
|
||||
label="Dodaj aktivnost"
|
||||
@click="onAddActivity(row)"
|
||||
/>
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
<!-- Documents -->
|
||||
<template v-if="createDoc">
|
||||
<div
|
||||
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
Dokument
|
||||
</div>
|
||||
<ActionMenuItem
|
||||
:icon="generating[row.uuid] ? faSpinner : faFileWord"
|
||||
:label="
|
||||
generating[row.uuid]
|
||||
? 'Generiranje...'
|
||||
: templates && templates.length
|
||||
? 'Generiraj dokument'
|
||||
: 'Ni predlog'
|
||||
"
|
||||
:disabled="generating[row.uuid] || !templates || templates.length === 0"
|
||||
@click="openGenerateDialog(row)"
|
||||
/>
|
||||
<a
|
||||
v-if="generatedDocs[row.uuid]?.path"
|
||||
:href="'/storage/' + generatedDocs[row.uuid].path"
|
||||
target="_blank"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm text-indigo-600 hover:bg-indigo-50"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faFileWord" class="h-4 w-4" />
|
||||
<span>Prenesi zadnji</span>
|
||||
</a>
|
||||
<div
|
||||
v-if="generationError[row.uuid]"
|
||||
class="px-3 py-2 text-xs text-rose-600 whitespace-pre-wrap"
|
||||
>
|
||||
{{ generationError[row.uuid] }}
|
||||
</div>
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
</template>
|
||||
|
||||
<!-- Documents -->
|
||||
<template v-if="createDoc">
|
||||
<div class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400">
|
||||
Dokument
|
||||
</div>
|
||||
<ActionMenuItem
|
||||
:icon="generating[row.uuid] ? faSpinner : faFileWord"
|
||||
:label="
|
||||
generating[row.uuid]
|
||||
? 'Generiranje...'
|
||||
: templates && templates.length
|
||||
? 'Generiraj dokument'
|
||||
: 'Ni predlog'
|
||||
"
|
||||
:disabled="generating[row.uuid] || !templates || templates.length === 0"
|
||||
@click="openGenerateDialog(row)"
|
||||
/>
|
||||
<a
|
||||
v-if="generatedDocs[row.uuid]?.path"
|
||||
:href="'/storage/' + generatedDocs[row.uuid].path"
|
||||
target="_blank"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-left text-sm text-indigo-600 hover:bg-indigo-50"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faFileWord" class="h-4 w-4" />
|
||||
<span>Prenesi zadnji</span>
|
||||
</a>
|
||||
<!-- Objects -->
|
||||
<div
|
||||
v-if="generationError[row.uuid]"
|
||||
class="px-3 py-2 text-xs text-rose-600 whitespace-pre-wrap"
|
||||
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
{{ generationError[row.uuid] }}
|
||||
</div>
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
</template>
|
||||
|
||||
<!-- Objects -->
|
||||
<div class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400">
|
||||
Predmeti
|
||||
</div>
|
||||
<ActionMenuItem
|
||||
:icon="faCircleInfo"
|
||||
label="Seznam predmetov"
|
||||
@click="openObjectsList(row)"
|
||||
/>
|
||||
<ActionMenuItem
|
||||
v-if="row.active"
|
||||
:icon="faPlus"
|
||||
label="Dodaj predmet"
|
||||
@click="openObjectDialog(row)"
|
||||
/>
|
||||
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
|
||||
<!-- Payments -->
|
||||
<div class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400">
|
||||
Plačila
|
||||
</div>
|
||||
<ActionMenuItem
|
||||
:icon="faCircleInfo"
|
||||
label="Pokaži plačila"
|
||||
@click="openPaymentsDialog(row)"
|
||||
/>
|
||||
<ActionMenuItem
|
||||
v-if="row.active && row?.account"
|
||||
:icon="faPlus"
|
||||
label="Dodaj plačilo"
|
||||
@click="openPaymentDialog(row)"
|
||||
/>
|
||||
|
||||
<!-- Archive -->
|
||||
<template v-if="edit">
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
<div class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400">
|
||||
{{ row.active ? "Arhiviranje" : "Ponovna aktivacija" }}
|
||||
Predmeti
|
||||
</div>
|
||||
<ActionMenuItem
|
||||
:icon="faCircleInfo"
|
||||
label="Seznam predmetov"
|
||||
@click="openObjectsList(row)"
|
||||
/>
|
||||
<ActionMenuItem
|
||||
v-if="row.active"
|
||||
:icon="faBoxArchive"
|
||||
:label="'Arhiviraj'"
|
||||
@click="
|
||||
router.post(
|
||||
route('clientCase.contract.archive', {
|
||||
client_case: client_case.uuid,
|
||||
uuid: row.uuid,
|
||||
}),
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
only: ['contracts', 'activities', 'documents'],
|
||||
}
|
||||
)
|
||||
"
|
||||
:icon="faPlus"
|
||||
label="Dodaj predmet"
|
||||
@click="openObjectDialog(row)"
|
||||
/>
|
||||
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
|
||||
<!-- Payments -->
|
||||
<div
|
||||
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
Plačila
|
||||
</div>
|
||||
<ActionMenuItem
|
||||
:icon="faCircleInfo"
|
||||
label="Pokaži plačila"
|
||||
@click="openPaymentsDialog(row)"
|
||||
/>
|
||||
<ActionMenuItem
|
||||
v-else
|
||||
:icon="faBoxArchive"
|
||||
label="Ponovno aktiviraj"
|
||||
@click="
|
||||
router.post(
|
||||
route('clientCase.contract.archive', {
|
||||
client_case: client_case.uuid,
|
||||
uuid: row.uuid,
|
||||
}),
|
||||
{ reactivate: true },
|
||||
{
|
||||
preserveScroll: true,
|
||||
only: ['contracts', 'activities', 'documents'],
|
||||
}
|
||||
)
|
||||
"
|
||||
v-if="row.active && row?.account"
|
||||
:icon="faPlus"
|
||||
label="Dodaj plačilo"
|
||||
@click="openPaymentDialog(row)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Delete -->
|
||||
<template v-if="edit">
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
<ActionMenuItem :icon="faTrash" label="Izbriši" danger @click="onDelete(row)" />
|
||||
<!-- Archive -->
|
||||
<template v-if="edit">
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
<div
|
||||
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
{{ row.active ? "Arhiviranje" : "Ponovna aktivacija" }}
|
||||
</div>
|
||||
<ActionMenuItem
|
||||
v-if="row.active"
|
||||
:icon="faBoxArchive"
|
||||
:label="'Arhiviraj'"
|
||||
@click="
|
||||
router.post(
|
||||
route('clientCase.contract.archive', {
|
||||
client_case: client_case.uuid,
|
||||
uuid: row.uuid,
|
||||
}),
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
only: ['contracts', 'activities', 'documents'],
|
||||
}
|
||||
)
|
||||
"
|
||||
/>
|
||||
<ActionMenuItem
|
||||
v-else
|
||||
:icon="faBoxArchive"
|
||||
label="Ponovno aktiviraj"
|
||||
@click="
|
||||
router.post(
|
||||
route('clientCase.contract.archive', {
|
||||
client_case: client_case.uuid,
|
||||
uuid: row.uuid,
|
||||
}),
|
||||
{ reactivate: true },
|
||||
{
|
||||
preserveScroll: true,
|
||||
only: ['contracts', 'activities', 'documents'],
|
||||
}
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Delete -->
|
||||
<template v-if="edit">
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
<ActionMenuItem
|
||||
:icon="faTrash"
|
||||
label="Izbriši"
|
||||
danger
|
||||
@click="onDelete(row)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</TableActions>
|
||||
</TableActions>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
|
@ -806,7 +862,9 @@ const onAddActivity = (c) => emit("add-activity", c);
|
|||
<ConfirmationDialog
|
||||
:show="confirmChange.show"
|
||||
title="Spremeni segment"
|
||||
:message="`Ali želite spremeniti segment za pogodbo ${confirmChange.contract?.reference || ''}?`"
|
||||
:message="`Ali želite spremeniti segment za pogodbo ${
|
||||
confirmChange.contract?.reference || ''
|
||||
}?`"
|
||||
confirm-text="Potrdi"
|
||||
cancel-text="Prekliči"
|
||||
@close="closeConfirm"
|
||||
|
|
@ -855,67 +913,67 @@ const onAddActivity = (c) => emit("add-activity", c);
|
|||
@confirm="submitGenerate"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Predloga</label>
|
||||
<select
|
||||
v-model="selectedTemplateSlug"
|
||||
@change="onTemplateChange"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null">Izberi predlogo...</option>
|
||||
<option v-for="t in templates" :key="t.slug" :value="t.slug">
|
||||
{{ t.name }} (v{{ t.version }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Predloga</label>
|
||||
<select
|
||||
v-model="selectedTemplateSlug"
|
||||
@change="onTemplateChange"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null">Izberi predlogo...</option>
|
||||
<option v-for="t in templates" :key="t.slug" :value="t.slug">
|
||||
{{ t.name }} (v{{ t.version }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Custom inputs -->
|
||||
<template v-if="customTokenList.length > 0">
|
||||
<div class="border-t border-gray-200 pt-4">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-for="token in customTokenList" :key="token">
|
||||
<label class="block text-sm font-medium text-gray-700">
|
||||
{{ token.replace(/^custom\./, "") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="customInputs[token.replace(/^custom\./, '')]"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<!-- Custom inputs -->
|
||||
<template v-if="customTokenList.length > 0">
|
||||
<div class="border-t border-gray-200 pt-4">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">Prilagojene vrednosti</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-for="token in customTokenList" :key="token">
|
||||
<label class="block text-sm font-medium text-gray-700">
|
||||
{{ token.replace(/^custom\./, "") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="customInputs[token.replace(/^custom\./, '')]"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Address overrides -->
|
||||
<div class="border-t border-gray-200 pt-4 space-y-3">
|
||||
<h3 class="text-sm font-medium text-gray-700">Naslovi</h3>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Naslov stranke</label>
|
||||
<select
|
||||
v-model="clientAddressSource"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option value="client">Stranka</option>
|
||||
<option value="case_person">Oseba primera</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Naslov osebe</label>
|
||||
<select
|
||||
v-model="personAddressSource"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option value="case_person">Oseba primera</option>
|
||||
<option value="client">Stranka</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="generationError[generateFor?.uuid]" class="text-sm text-red-600">
|
||||
{{ generationError[generateFor?.uuid] }}
|
||||
<!-- Address overrides -->
|
||||
<div class="border-t border-gray-200 pt-4 space-y-3">
|
||||
<h3 class="text-sm font-medium text-gray-700">Naslovi</h3>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Naslov stranke</label>
|
||||
<select
|
||||
v-model="clientAddressSource"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option value="client">Stranka</option>
|
||||
<option value="case_person">Oseba primera</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Naslov osebe</label>
|
||||
<select
|
||||
v-model="personAddressSource"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option value="case_person">Oseba primera</option>
|
||||
<option value="client">Stranka</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="generationError[generateFor?.uuid]" class="text-sm text-red-600">
|
||||
{{ generationError[generateFor?.uuid] }}
|
||||
</div>
|
||||
</div>
|
||||
</CreateDialog>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import PersonInfoGrid from "@/Components/PersonInfo/PersonInfoGrid.vue";
|
|||
import SectionTitle from "@/Components/SectionTitle.vue";
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { onBeforeMount, ref, computed } from "vue";
|
||||
import ContractDrawer from "./Partials/ContractDrawer.vue";
|
||||
import ContractTable from "./Partials/ContractTable.vue";
|
||||
|
|
@ -21,38 +22,39 @@ import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
|||
import { hasPermission } from "@/Services/permissions";
|
||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { DropdownMenu } from "@/Components/ui/dropdown-menu";
|
||||
import DropdownMenuContent from "@/Components/ui/dropdown-menu/DropdownMenuContent.vue";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
client_case: Object,
|
||||
contracts: [Object, Array], // Can be paginated object or array (backward compatibility)
|
||||
activities: Object,
|
||||
contracts: Object, // Resource Collection with data/links/meta
|
||||
activities: Object, // Resource Collection with data/links/meta
|
||||
contract_types: Array,
|
||||
account_types: { type: Array, default: () => [] },
|
||||
actions: Array,
|
||||
types: Object,
|
||||
documents: Array,
|
||||
documents: Object, // Resource Collection with data property
|
||||
segments: { type: Array, default: () => [] },
|
||||
all_segments: { type: Array, default: () => [] },
|
||||
current_segment: { type: Object, default: null },
|
||||
contract_doc_templates: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
// Extract contracts array from paginated object or use array directly
|
||||
// Extract contracts array from Resource Collection
|
||||
const contractsArray = computed(() => {
|
||||
if (Array.isArray(props.contracts)) {
|
||||
return props.contracts;
|
||||
}
|
||||
// Handle paginated contracts
|
||||
if (props.contracts?.data) {
|
||||
return props.contracts.data;
|
||||
}
|
||||
return [];
|
||||
return props.contracts?.data || [];
|
||||
});
|
||||
|
||||
// Check if contracts are paginated
|
||||
// Contracts are always paginated now (Resource Collection)
|
||||
const contractsPaginated = computed(() => {
|
||||
return props.contracts && !Array.isArray(props.contracts) && props.contracts.data;
|
||||
return props.contracts?.links !== undefined;
|
||||
});
|
||||
|
||||
// Extract documents array from Resource Collection
|
||||
const documentsArray = computed(() => {
|
||||
return props.documents?.data || [];
|
||||
});
|
||||
|
||||
const page = usePage();
|
||||
|
|
@ -245,6 +247,7 @@ const submitAttachSegment = () => {
|
|||
<AppLayout title="Client case">
|
||||
<template #header></template>
|
||||
<div class="pt-12">
|
||||
<!-- Client details -->
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Current segment badge (right aligned, above the card) -->
|
||||
<div v-if="current_segment" class="flex justify-end pb-3">
|
||||
|
|
@ -257,9 +260,7 @@ const submitAttachSegment = () => {
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-500"
|
||||
>
|
||||
<Card class="border-l-4 border-blue-500">
|
||||
<div class="mx-auto max-w-4x1 p-3 flex justify-between items-center">
|
||||
<SectionTitle>
|
||||
<template #title>
|
||||
|
|
@ -268,21 +269,16 @@ const submitAttachSegment = () => {
|
|||
</a>
|
||||
</template>
|
||||
</SectionTitle>
|
||||
<button @click="showClientDetails" :hidden="clientDetails ? false : true">
|
||||
<AngleUpIcon />
|
||||
</button>
|
||||
<button :hidden="clientDetails" @click="hideClietnDetails">
|
||||
<AngleDownIcon />
|
||||
</button>
|
||||
<Badge variant="secondary" class="bg-blue-500 text-white dark:bg-blue-600">
|
||||
Naročnik
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-1" :hidden="clientDetails">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
|
||||
>
|
||||
<Card class="border-l-4 border-blue-400">
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<PersonInfoGrid
|
||||
:types="types"
|
||||
|
|
@ -290,37 +286,37 @@ const submitAttachSegment = () => {
|
|||
:edit="hasPerm('client-edit')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Case details -->
|
||||
<div class="pt-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-red-400"
|
||||
>
|
||||
<Card class="border-l-4 border-red-400">
|
||||
<div class="mx-auto max-w-4x1 p-3 flex items-center justify-between">
|
||||
<SectionTitle>
|
||||
<template #title> Primer - oseba </template>
|
||||
<template #title>{{ client_case.person.full_name }}</template>
|
||||
</SectionTitle>
|
||||
<div
|
||||
v-if="client_case && client_case.client_ref"
|
||||
class="text-xs text-gray-600"
|
||||
>
|
||||
<span class="mr-1">Ref:</span>
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded border bg-gray-50 font-mono text-gray-700"
|
||||
>{{ client_case.client_ref }}</span
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="client_case && client_case.client_ref"
|
||||
class="text-xs text-gray-600"
|
||||
>
|
||||
<span class="mr-1">Ref:</span>
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded border bg-gray-50 font-mono text-gray-700"
|
||||
>{{ client_case.client_ref }}</span
|
||||
>
|
||||
</div>
|
||||
<Badge variant="destructive" class="text-white"> Primer </Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-1">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-red-400"
|
||||
>
|
||||
<Card class="border-l-4 border-red-400">
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<PersonInfoGrid
|
||||
:types="types"
|
||||
|
|
@ -331,31 +327,18 @@ const submitAttachSegment = () => {
|
|||
:client-case-uuid="client_case.uuid"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Contracts section -->
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4">
|
||||
<Card>
|
||||
<div class="mx-auto max-w-4x1">
|
||||
<div class="flex justify-between p-3">
|
||||
<div class="p-3">
|
||||
<SectionTitle>
|
||||
<template #title> Pogodbe </template>
|
||||
</SectionTitle>
|
||||
<div class="flex items-center gap-2" v-if="hasPerm('contract-edit')">
|
||||
<Button @click="openDrawerCreateContract">Nova</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="availableSegments.length === 0"
|
||||
@click="openAttachSegment"
|
||||
>
|
||||
{{
|
||||
availableSegments.length
|
||||
? "Dodaj segment"
|
||||
: "Ni razpoložljivih segmentov"
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ContractTable
|
||||
:client="client"
|
||||
|
|
@ -363,29 +346,37 @@ const submitAttachSegment = () => {
|
|||
:contracts="contractsArray"
|
||||
:contract_types="contract_types"
|
||||
:segments="segments"
|
||||
:all_segments="all_segments"
|
||||
:templates="contract_doc_templates"
|
||||
:edit="hasPerm('contract-edit')"
|
||||
:create-doc="hasPerm('create-docs')"
|
||||
@edit="openDrawerEditContract"
|
||||
@delete="requestDeleteContract"
|
||||
@add-activity="openDrawerAddActivity"
|
||||
@create="openDrawerCreateContract"
|
||||
@attach-segment="openAttachSegment"
|
||||
/>
|
||||
<div v-if="contractsPaginated" class="border-t border-gray-200">
|
||||
<div v-if="contractsPaginated" class="border-t border-gray-200 p-4">
|
||||
<Pagination
|
||||
:links="contracts.links"
|
||||
:from="contracts.from"
|
||||
:to="contracts.to"
|
||||
:total="contracts.total"
|
||||
:per-page="contracts.per_page || 50"
|
||||
:last-page="contracts.last_page"
|
||||
:current-page="contracts.current_page"
|
||||
per-page-param="contracts_per_page"
|
||||
page-param="contracts_page"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-12 pb-6">
|
||||
<!-- Activities section -->
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4">
|
||||
<Card>
|
||||
<div class="mx-auto max-w-4x1">
|
||||
<div class="flex justify-between p-4">
|
||||
<SectionTitle>
|
||||
|
|
@ -396,39 +387,49 @@ const submitAttachSegment = () => {
|
|||
:client_case="client_case"
|
||||
:activities="activities"
|
||||
:edit="hasPerm('activity-edit')"
|
||||
:actions="actions"
|
||||
:contracts="contractsArray"
|
||||
:page-size="activities.per_page || 20"
|
||||
>
|
||||
<template #add>
|
||||
<ActionMenuItem
|
||||
label="Nova aktivnost"
|
||||
:icon="faPlus"
|
||||
@click="openDrawerAddActivity"
|
||||
/>
|
||||
<Button variant="outline" size="sm" @click="openDrawerAddActivity">
|
||||
Nova aktivnost
|
||||
</Button>
|
||||
</template>
|
||||
</ActivityTable>
|
||||
<Pagination
|
||||
:links="activities.links"
|
||||
:from="activities.from"
|
||||
:to="activities.to"
|
||||
:total="activities.total"
|
||||
/>
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<Pagination
|
||||
:links="activities.links"
|
||||
:from="activities.from"
|
||||
:to="activities.to"
|
||||
:total="activities.total"
|
||||
:per-page="activities.per_page || 15"
|
||||
:last-page="activities.last_page"
|
||||
:current-page="activities.current_page"
|
||||
per-page-param="activities_per_page"
|
||||
page-param="activities_page"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Documents section -->
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4">
|
||||
<Card>
|
||||
<div class="mx-auto max-w-4x1">
|
||||
<div class="flex justify-between p-4">
|
||||
<div class="p-4">
|
||||
<SectionTitle>
|
||||
<template #title>Dokumenti</template>
|
||||
</SectionTitle>
|
||||
<Button @click="openUpload">Dodaj</Button>
|
||||
</div>
|
||||
<DocumentsTable
|
||||
:documents="documents"
|
||||
:client-case="client_case"
|
||||
:edit="hasPerm('doc-edit')"
|
||||
:page-size="documents.per_page || 15"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
@view="openViewer"
|
||||
@edit="openDocEdit"
|
||||
:download-url-builder="
|
||||
|
|
@ -447,9 +448,26 @@ const submitAttachSegment = () => {
|
|||
});
|
||||
}
|
||||
"
|
||||
/>
|
||||
>
|
||||
<template #add>
|
||||
<Button variant="outline" @click="openUpload">Novi document</Button>
|
||||
</template>
|
||||
</DocumentsTable>
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<Pagination
|
||||
:links="documents.links"
|
||||
:from="documents.from"
|
||||
:to="documents.to"
|
||||
:total="documents.total"
|
||||
:per-page="documents.per_page || 15"
|
||||
:last-page="documents.last_page"
|
||||
:current-page="documents.current_page"
|
||||
per-page-param="documentsPerPage"
|
||||
page-param="documentsPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentUploadDialog
|
||||
|
|
@ -483,15 +501,15 @@ const submitAttachSegment = () => {
|
|||
:client_case="client_case"
|
||||
:contract="contractEditing"
|
||||
/>
|
||||
<ActivityDrawer
|
||||
:show="drawerAddActivity"
|
||||
@close="closeDrawer"
|
||||
:client_case="client_case"
|
||||
:actions="actions"
|
||||
:contract-uuid="activityContractUuid"
|
||||
:documents="documents"
|
||||
:contracts="contractsArray"
|
||||
/>
|
||||
<ActivityDrawer
|
||||
:show="drawerAddActivity"
|
||||
@close="closeDrawer"
|
||||
:client_case="client_case"
|
||||
:actions="actions"
|
||||
:contract-uuid="activityContractUuid"
|
||||
:documents="documentsArray"
|
||||
:contracts="contractsArray"
|
||||
/>
|
||||
<DeleteDialog
|
||||
:show="confirmDelete.show"
|
||||
title="Izbriši pogodbo"
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import { computed, ref } from "vue";
|
|||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link, router, usePage } from "@inertiajs/vue3";
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTable.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faUserGroup } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/Components/ui/card";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
|
|
@ -29,6 +30,7 @@ import { useForm } from "vee-validate";
|
|||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import ActionMessage from "@/Components/ActionMessage.vue";
|
||||
import { Mail, Plug2Icon } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
clients: Object,
|
||||
|
|
@ -121,29 +123,25 @@ const storeClient = async () => {
|
|||
},
|
||||
};
|
||||
|
||||
router.post(
|
||||
route("client.store"),
|
||||
payload,
|
||||
{
|
||||
onSuccess: () => {
|
||||
closeDrawer();
|
||||
formClient.resetForm();
|
||||
processing.value = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
formClient.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
router.post(route("client.store"), payload, {
|
||||
onSuccess: () => {
|
||||
closeDrawer();
|
||||
formClient.resetForm();
|
||||
processing.value = false;
|
||||
},
|
||||
onError: (errors) => {
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
formClient.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onConfirmCreate = formClient.handleSubmit(() => {
|
||||
|
|
@ -168,15 +166,14 @@ const fmtCurrency = (v) => {
|
|||
<template #header> </template>
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="mx-auto max-w-4x1 py-3 space-y-3">
|
||||
<!-- DataTable (server-side) -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Naročniki</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
:show-search="true"
|
||||
:show-page-size="true"
|
||||
:show-add="true"
|
||||
:columns="[
|
||||
{ key: 'nu', label: 'Št.', sortable: false, class: 'w-40' },
|
||||
{ key: 'nu', label: 'Št.', sortable: false },
|
||||
{ key: 'name', label: 'Naročnik', sortable: false },
|
||||
{
|
||||
key: 'cases',
|
||||
|
|
@ -191,35 +188,17 @@ const fmtCurrency = (v) => {
|
|||
align: 'right',
|
||||
},
|
||||
]"
|
||||
:rows="clients.data || []"
|
||||
:meta="{
|
||||
current_page: clients.current_page,
|
||||
per_page: clients.per_page,
|
||||
total: clients.total,
|
||||
last_page: clients.last_page,
|
||||
from: clients.from,
|
||||
to: clients.to,
|
||||
links: clients.links,
|
||||
}"
|
||||
:sort="{
|
||||
key: props.filters?.sort || null,
|
||||
direction: props.filters?.direction || null,
|
||||
}"
|
||||
:search="initialSearch"
|
||||
route-name="client"
|
||||
:data="clients.data || []"
|
||||
:show-pagination="false"
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
row-key="uuid"
|
||||
:only-props="['clients']"
|
||||
:empty-icon="faUserGroup"
|
||||
empty-text="Ni zadetkov"
|
||||
empty-description="Ni najdenih naročnikov. Ustvarite novega naročnika ali preverite iskalne kriterije."
|
||||
empty-text="Ni najdenih naročnikov."
|
||||
>
|
||||
<template #toolbar-add>
|
||||
<ActionMenuItem
|
||||
v-if="hasPerm('client-edit')"
|
||||
label="Dodaj naročnika"
|
||||
:icon="faPlus"
|
||||
@click="openDrawerCreateClient"
|
||||
/>
|
||||
<Button @click="openDrawerCreateClient">
|
||||
<Plug2Icon class="w-4 h-4 mr-2" /> Dodaj
|
||||
</Button>
|
||||
</template>
|
||||
<template #cell-nu="{ row }">
|
||||
{{ row.person?.nu || "-" }}
|
||||
|
|
@ -253,8 +232,8 @@ const fmtCurrency = (v) => {
|
|||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
|
@ -267,161 +246,161 @@ const fmtCurrency = (v) => {
|
|||
@confirm="onConfirmCreate"
|
||||
>
|
||||
<form @submit.prevent="onConfirmCreate">
|
||||
<div class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="full_name">
|
||||
<FormItem>
|
||||
<FormLabel>Naziv</FormLabel>
|
||||
<div class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="full_name">
|
||||
<FormItem>
|
||||
<FormLabel>Naziv</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="fullname"
|
||||
type="text"
|
||||
autocomplete="full-name"
|
||||
placeholder="Naziv"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="tax_number">
|
||||
<FormItem>
|
||||
<FormLabel>Davčna</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="taxnumber"
|
||||
type="text"
|
||||
autocomplete="tax-number"
|
||||
placeholder="Davčna številka"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="social_security_number">
|
||||
<FormItem>
|
||||
<FormLabel>Matična / Emšo</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="socialSecurityNumber"
|
||||
type="text"
|
||||
autocomplete="social-security-number"
|
||||
placeholder="Matična / Emšo"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="address.address">
|
||||
<FormItem>
|
||||
<FormLabel>Naslov</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
autocomplete="address"
|
||||
placeholder="Naslov"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="address.country">
|
||||
<FormItem>
|
||||
<FormLabel>Država</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="addressCountry"
|
||||
type="text"
|
||||
autocomplete="address-country"
|
||||
placeholder="Država"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="address.type_id">
|
||||
<FormItem>
|
||||
<FormLabel>Vrsta naslova</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<Input
|
||||
id="fullname"
|
||||
type="text"
|
||||
autocomplete="full-name"
|
||||
placeholder="Naziv"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi vrsto naslova" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<SelectContent>
|
||||
<SelectItem :value="1">Stalni</SelectItem>
|
||||
<SelectItem :value="2">Začasni</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="tax_number">
|
||||
<FormItem>
|
||||
<FormLabel>Davčna</FormLabel>
|
||||
<FormField v-slot="{ value, handleChange }" name="phone.country_code">
|
||||
<FormItem>
|
||||
<FormLabel>Koda države tel.</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<Input
|
||||
id="taxnumber"
|
||||
type="text"
|
||||
autocomplete="tax-number"
|
||||
placeholder="Davčna številka"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi kodo države" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<SelectContent>
|
||||
<SelectItem value="00386">+386 (Slovenija)</SelectItem>
|
||||
<SelectItem value="00385">+385 (Hrvaška)</SelectItem>
|
||||
<SelectItem value="0039">+39 (Italija)</SelectItem>
|
||||
<SelectItem value="0036">+36 (Madžarska)</SelectItem>
|
||||
<SelectItem value="0043">+43 (Avstrija)</SelectItem>
|
||||
<SelectItem value="00381">+381 (Srbija)</SelectItem>
|
||||
<SelectItem value="00387">+387 (Bosna in Hercegovina)</SelectItem>
|
||||
<SelectItem value="00382">+382 (Črna gora)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="social_security_number">
|
||||
<FormItem>
|
||||
<FormLabel>Matična / Emšo</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="socialSecurityNumber"
|
||||
type="text"
|
||||
autocomplete="social-security-number"
|
||||
placeholder="Matična / Emšo"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="phone.nu">
|
||||
<FormItem>
|
||||
<FormLabel>Telefonska št.</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="phoneNu"
|
||||
type="text"
|
||||
autocomplete="phone-nu"
|
||||
placeholder="Telefonska številka"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="address.address">
|
||||
<FormItem>
|
||||
<FormLabel>Naslov</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
autocomplete="address"
|
||||
placeholder="Naslov"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="address.country">
|
||||
<FormItem>
|
||||
<FormLabel>Država</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="addressCountry"
|
||||
type="text"
|
||||
autocomplete="address-country"
|
||||
placeholder="Država"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="address.type_id">
|
||||
<FormItem>
|
||||
<FormLabel>Vrsta naslova</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi vrsto naslova" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem :value="1">Stalni</SelectItem>
|
||||
<SelectItem :value="2">Začasni</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="phone.country_code">
|
||||
<FormItem>
|
||||
<FormLabel>Koda države tel.</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi kodo države" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="00386">+386 (Slovenija)</SelectItem>
|
||||
<SelectItem value="00385">+385 (Hrvaška)</SelectItem>
|
||||
<SelectItem value="0039">+39 (Italija)</SelectItem>
|
||||
<SelectItem value="0036">+36 (Madžarska)</SelectItem>
|
||||
<SelectItem value="0043">+43 (Avstrija)</SelectItem>
|
||||
<SelectItem value="00381">+381 (Srbija)</SelectItem>
|
||||
<SelectItem value="00387">+387 (Bosna in Hercegovina)</SelectItem>
|
||||
<SelectItem value="00382">+382 (Črna gora)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="phone.nu">
|
||||
<FormItem>
|
||||
<FormLabel>Telefonska št.</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="phoneNu"
|
||||
type="text"
|
||||
autocomplete="phone-nu"
|
||||
placeholder="Telefonska številka"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="description"
|
||||
type="text"
|
||||
autocomplete="description"
|
||||
placeholder="Opis"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</form>
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="description"
|
||||
type="text"
|
||||
autocomplete="description"
|
||||
placeholder="Opis"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</form>
|
||||
</CreateDialog>
|
||||
</template>
|
||||
|
|
|
|||
219
resources/js/Pages/Examples/DataTableExample.vue
Normal file
219
resources/js/Pages/Examples/DataTableExample.vue
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import DataTableNew2 from '@/Components/DataTable/DataTableNew2.vue';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
|
||||
// Simple columns example
|
||||
const simpleColumns = [
|
||||
{ key: 'id', label: 'ID', sortable: true, class: 'w-20' },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'status', label: 'Status', sortable: false },
|
||||
{ key: 'amount', label: 'Amount', sortable: true, align: 'right', class: 'text-right' },
|
||||
];
|
||||
|
||||
// Sample data
|
||||
const data = ref([
|
||||
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', status: 'active', amount: 1250.50 },
|
||||
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', status: 'inactive', amount: 890.25 },
|
||||
{ id: 3, name: 'Carol Williams', email: 'carol@example.com', status: 'active', amount: 2100.00 },
|
||||
{ id: 4, name: 'David Brown', email: 'david@example.com', status: 'pending', amount: 450.75 },
|
||||
{ id: 5, name: 'Eve Davis', email: 'eve@example.com', status: 'active', amount: 1875.30 },
|
||||
{ id: 6, name: 'Frank Miller', email: 'frank@example.com', status: 'inactive', amount: 670.00 },
|
||||
{ id: 7, name: 'Grace Wilson', email: 'grace@example.com', status: 'active', amount: 3200.50 },
|
||||
{ id: 8, name: 'Henry Moore', email: 'henry@example.com', status: 'pending', amount: 520.25 },
|
||||
{ id: 9, name: 'Ivy Taylor', email: 'ivy@example.com', status: 'active', amount: 1950.00 },
|
||||
{ id: 10, name: 'Jack Anderson', email: 'jack@example.com', status: 'inactive', amount: 780.40 },
|
||||
{ id: 11, name: 'Kate Thomas', email: 'kate@example.com', status: 'active', amount: 2450.75 },
|
||||
{ id: 12, name: 'Leo Jackson', email: 'leo@example.com', status: 'pending', amount: 930.60 },
|
||||
]);
|
||||
|
||||
const selectedRows = ref([]);
|
||||
|
||||
function handleRowClick(row) {
|
||||
console.log('Row clicked:', row);
|
||||
}
|
||||
|
||||
function handleSelectionChange(keys) {
|
||||
selectedRows.value = keys;
|
||||
console.log('Selection changed:', keys);
|
||||
}
|
||||
|
||||
function getStatusVariant(status) {
|
||||
const variants = {
|
||||
active: 'default',
|
||||
inactive: 'secondary',
|
||||
pending: 'outline',
|
||||
};
|
||||
return variants[status] || 'outline';
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('sl-SI', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="DataTable Example">
|
||||
<Head title="DataTable Example" />
|
||||
|
||||
<div class="py-12">
|
||||
<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-6">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900">
|
||||
DataTable Component Example
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
This is a working example of the new shadcn-vue style DataTable component
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Example 1: Basic Table -->
|
||||
<div class="mb-12">
|
||||
<h3 class="text-lg font-semibold mb-4">Basic Table with Simple Columns</h3>
|
||||
<DataTableNew2
|
||||
:columns="simpleColumns"
|
||||
:data="data"
|
||||
filter-column="email"
|
||||
filter-placeholder="Search by email..."
|
||||
@row:click="handleRowClick"
|
||||
>
|
||||
<!-- Custom cell for status using slot -->
|
||||
<template #cell-status="{ value }">
|
||||
<Badge :variant="getStatusVariant(value)">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<!-- Custom cell for amount using slot -->
|
||||
<template #cell-amount="{ value }">
|
||||
<span class="font-medium">{{ formatCurrency(value) }}</span>
|
||||
</template>
|
||||
</DataTableNew2>
|
||||
</div>
|
||||
|
||||
<!-- Example 2: Table with Row Selection -->
|
||||
<div class="mb-12">
|
||||
<h3 class="text-lg font-semibold mb-4">Table with Row Selection</h3>
|
||||
<div v-if="selectedRows.length" class="mb-4 p-4 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm font-medium text-blue-900">
|
||||
Selected {{ selectedRows.length }} row(s)
|
||||
</p>
|
||||
</div>
|
||||
<DataTableNew2
|
||||
:columns="simpleColumns"
|
||||
:data="data"
|
||||
enable-row-selection
|
||||
filter-column="name"
|
||||
filter-placeholder="Search by name..."
|
||||
@selection:change="handleSelectionChange"
|
||||
>
|
||||
<template #cell-status="{ value }">
|
||||
<Badge :variant="getStatusVariant(value)">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</template>
|
||||
<template #cell-amount="{ value }">
|
||||
<span class="font-medium">{{ formatCurrency(value) }}</span>
|
||||
</template>
|
||||
</DataTableNew2>
|
||||
</div>
|
||||
|
||||
<!-- Example 3: Striped & Hoverable -->
|
||||
<div class="mb-12">
|
||||
<h3 class="text-lg font-semibold mb-4">Striped Table with Hover</h3>
|
||||
<DataTableNew2
|
||||
:columns="simpleColumns"
|
||||
:data="data.slice(0, 5)"
|
||||
:page-size="5"
|
||||
striped
|
||||
hoverable
|
||||
>
|
||||
<template #cell-status="{ value }">
|
||||
<Badge :variant="getStatusVariant(value)">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</template>
|
||||
<template #cell-amount="{ value }">
|
||||
<span class="font-medium">{{ formatCurrency(value) }}</span>
|
||||
</template>
|
||||
</DataTableNew2>
|
||||
</div>
|
||||
|
||||
<!-- Example 4: Custom Toolbar -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4">Table with Custom Toolbar Actions</h3>
|
||||
<DataTableNew2
|
||||
:columns="simpleColumns"
|
||||
:data="data"
|
||||
filter-column="email"
|
||||
filter-placeholder="Search emails..."
|
||||
>
|
||||
<!-- Add custom buttons to toolbar -->
|
||||
<template #toolbar-actions="{ table }">
|
||||
<Button variant="outline" size="sm" @click="() => console.log('Export clicked')">
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="default" size="sm" @click="() => console.log('Add clicked')">
|
||||
Add New
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<Badge :variant="getStatusVariant(value)">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</template>
|
||||
<template #cell-amount="{ value }">
|
||||
<span class="font-medium">{{ formatCurrency(value) }}</span>
|
||||
</template>
|
||||
</DataTableNew2>
|
||||
</div>
|
||||
|
||||
<!-- Example 5: Full Custom Toolbar -->
|
||||
<div class="mt-12">
|
||||
<h3 class="text-lg font-semibold mb-4">Table with Custom Filters in Toolbar</h3>
|
||||
<DataTableNew2
|
||||
:columns="simpleColumns"
|
||||
:data="data"
|
||||
filter-column="name"
|
||||
filter-placeholder="Search names..."
|
||||
>
|
||||
<!-- Add custom filter controls -->
|
||||
<template #toolbar-filters="{ table }">
|
||||
<select
|
||||
class="h-8 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
@change="(e) => {
|
||||
const column = table.getColumn('status');
|
||||
column?.setFilterValue(e.target.value || undefined);
|
||||
}"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="pending">Pending</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<Badge :variant="getStatusVariant(value)">
|
||||
{{ value }}
|
||||
</Badge>
|
||||
</template>
|
||||
<template #cell-amount="{ value }">
|
||||
<span class="font-medium">{{ formatCurrency(value) }}</span>
|
||||
</template>
|
||||
</DataTableNew2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { ref } from "vue";
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
|
||||
import DataTableExample from "../Examples/DataTableExample.vue";
|
||||
|
||||
const props = defineProps({
|
||||
example: { type: String, default: "Demo" },
|
||||
|
|
@ -55,45 +56,7 @@ function onRowClick(row) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Testing Sandbox">
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="prose max-w-none">
|
||||
<h1 class="text-2xl font-semibold">Testing Page</h1>
|
||||
<p>
|
||||
This page is for quick UI or component experiments. Remove or adapt as needed.
|
||||
</p>
|
||||
<p class="text-slate-700 text-sm">
|
||||
Prop example value: <span class="font-mono">{{ props.example }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-slate-200 bg-white/70 p-4 shadow-sm"
|
||||
>
|
||||
<h2
|
||||
class="text-sm font-semibold tracking-wide uppercase text-slate-500 mb-3"
|
||||
>
|
||||
DataTable (Client-side)
|
||||
</h2>
|
||||
<DataTableClient
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
v-model:sort="sort"
|
||||
v-model:search="search"
|
||||
v-model:page="page"
|
||||
v-model:pageSize="pageSize"
|
||||
:search-keys="searchKeys"
|
||||
@row:click="onRowClick"
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
<button
|
||||
class="px-2 py-1 rounded border border-gray-300 hover:bg-gray-50 text-xs"
|
||||
>
|
||||
Akcija
|
||||
</button>
|
||||
</template>
|
||||
</DataTableClient>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<DataTableExample></DataTableExample>
|
||||
|
||||
</template>
|
||||
|
|
|
|||
19
resources/js/Utilities/functions.js
Normal file
19
resources/js/Utilities/functions.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export function fmtDateTime(d) {
|
||||
if (!d) return "";
|
||||
try {
|
||||
const dt = new Date(d);
|
||||
const datePart = dt.toLocaleDateString("sl-SI", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const timePart = dt.toLocaleTimeString("sl-SI", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
return `${datePart} ${timePart}`;
|
||||
} catch (e) {
|
||||
return String(d);
|
||||
}
|
||||
};
|
||||
|
|
@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge";
|
|||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a Vue ref with either a direct value or a function
|
||||
* Used by TanStack Table for state management
|
||||
* @param {*} updaterOrValue - Either a direct value or a function that takes current value
|
||||
* @param {import('vue').Ref} ref - The Vue ref to update
|
||||
*/
|
||||
export function valueUpdater(updaterOrValue, ref) {
|
||||
ref.value = typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(ref.value)
|
||||
: updaterOrValue;
|
||||
}
|
||||
|
|
|
|||
129
tests/Feature/Feature/ClientCaseShowTest.php
Normal file
129
tests/Feature/Feature/ClientCaseShowTest.php
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Document;
|
||||
use App\Models\Segment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('displays client case with contracts, activities, and documents', function () {
|
||||
$user = User::factory()->create();
|
||||
$case = ClientCase::factory()->create(['active' => 1]);
|
||||
$contract = Contract::factory()->for($case, 'clientCase')->create();
|
||||
$activity = Activity::factory()->for($case, 'clientCase')->create();
|
||||
$document = Document::factory()->create([
|
||||
'documentable_type' => ClientCase::class,
|
||||
'documentable_id' => $case->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('clientCase.show', $case->uuid))
|
||||
->assertSuccessful()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('Cases/Show')
|
||||
->has('client_case')
|
||||
->has('contracts.data')
|
||||
->has('activities.data')
|
||||
->has('documents.data')
|
||||
);
|
||||
});
|
||||
|
||||
it('filters contracts by segment when segment parameter is provided', function () {
|
||||
$user = User::factory()->create();
|
||||
$case = ClientCase::factory()->create(['active' => 1]);
|
||||
$segment = Segment::factory()->create(['active' => true]);
|
||||
|
||||
$contractInSegment = Contract::factory()->for($case, 'clientCase')->create();
|
||||
$contractOutSegment = Contract::factory()->for($case, 'clientCase')->create();
|
||||
|
||||
// Attach segment to the contract that should appear
|
||||
\DB::table('contract_segment')->insert([
|
||||
'contract_id' => $contractInSegment->id,
|
||||
'segment_id' => $segment->id,
|
||||
'active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('clientCase.show', ['client_case' => $case->uuid, 'segment' => $segment->id]))
|
||||
->assertSuccessful();
|
||||
|
||||
$contracts = $response->viewData('page')['props']['contracts']['data'] ?? [];
|
||||
expect(collect($contracts)->pluck('id')->contains($contractInSegment->id))->toBeTrue()
|
||||
->and(collect($contracts)->pluck('id')->contains($contractOutSegment->id))->toBeFalse();
|
||||
});
|
||||
|
||||
it('paginates contracts with custom per_page parameter', function () {
|
||||
$user = User::factory()->create();
|
||||
$case = ClientCase::factory()->create(['active' => 1]);
|
||||
|
||||
// Create 60 contracts
|
||||
Contract::factory()->count(60)->for($case, 'clientCase')->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('clientCase.show', ['client_case' => $case->uuid, 'contracts_per_page' => 25]))
|
||||
->assertSuccessful();
|
||||
|
||||
$meta = $response->viewData('page')['props']['contracts']['meta'] ?? [];
|
||||
expect($meta['per_page'])->toBe(25)
|
||||
->and($meta['total'])->toBe(60);
|
||||
});
|
||||
|
||||
it('paginates activities with custom per_page parameter', function () {
|
||||
$user = User::factory()->create();
|
||||
$case = ClientCase::factory()->create(['active' => 1]);
|
||||
|
||||
// Create 30 activities
|
||||
Activity::factory()->count(30)->for($case, 'clientCase')->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('clientCase.show', ['client_case' => $case->uuid, 'activities_per_page' => 15]))
|
||||
->assertSuccessful();
|
||||
|
||||
$meta = $response->viewData('page')['props']['activities']['meta'] ?? [];
|
||||
expect($meta['per_page'])->toBe(15)
|
||||
->and($meta['total'])->toBe(30);
|
||||
});
|
||||
|
||||
it('includes archive metadata in response', function () {
|
||||
$user = User::factory()->create();
|
||||
$case = ClientCase::factory()->create(['active' => 1]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('clientCase.show', $case->uuid))
|
||||
->assertSuccessful()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->has('archive_meta')
|
||||
->has('archive_meta.archive_segment_id')
|
||||
->has('archive_meta.related_tables')
|
||||
);
|
||||
});
|
||||
|
||||
it('merges case and contract documents correctly', function () {
|
||||
$user = User::factory()->create();
|
||||
$case = ClientCase::factory()->create(['active' => 1]);
|
||||
$contract = Contract::factory()->for($case, 'clientCase')->create();
|
||||
|
||||
$caseDoc = Document::factory()->create([
|
||||
'documentable_type' => ClientCase::class,
|
||||
'documentable_id' => $case->id,
|
||||
]);
|
||||
|
||||
$contractDoc = Document::factory()->create([
|
||||
'documentable_type' => Contract::class,
|
||||
'documentable_id' => $contract->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('clientCase.show', $case->uuid))
|
||||
->assertSuccessful();
|
||||
|
||||
$documents = $response->viewData('page')['props']['documents']['data'] ?? [];
|
||||
expect($documents)->toHaveCount(2);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user