Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8031501d25 |
@@ -22,7 +22,7 @@ ## Foundational Context
|
||||
- pestphp/pest (PEST) - v3
|
||||
- phpunit/phpunit (PHPUNIT) - v11
|
||||
- @inertiajs/vue3 (INERTIA) - v2
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
- tailwindcss (TAILWINDCSS) - v3
|
||||
- vue (VUE) - v3
|
||||
|
||||
|
||||
@@ -359,39 +359,11 @@ ### 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/v4 rules ===
|
||||
=== tailwindcss/v3 rules ===
|
||||
|
||||
## Tailwind 4
|
||||
## Tailwind 3
|
||||
|
||||
- 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 |
|
||||
- Always use Tailwind CSS v3 - verify you're using only classes supported by this version.
|
||||
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
@@ -19,4 +19,3 @@ yarn-error.log
|
||||
/.idea
|
||||
/.vscode
|
||||
/.zed
|
||||
/shadcn-vue
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RefreshMaterializedViews extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'reports:refresh-mviews {--concurrently : Use CONCURRENTLY (Postgres 9.4+; requires indexes)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Refresh configured Postgres materialized views for reporting';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$views = (array) config('reports.materialized_views', []);
|
||||
if (empty($views)) {
|
||||
$this->info('No materialized views configured.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$concurrently = $this->option('concurrently') ? ' CONCURRENTLY' : '';
|
||||
|
||||
foreach ($views as $view) {
|
||||
$name = trim((string) $view);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$sql = 'REFRESH MATERIALIZED VIEW'.$concurrently.' '.DB::getPdo()->quote($name);
|
||||
// PDO::quote wraps with single quotes; for identifiers we need double quotes or no quotes.
|
||||
// Use a safe fallback: wrap with " if not already quoted
|
||||
$safe = 'REFRESH MATERIALIZED VIEW'.$concurrently.' "'.str_replace('"', '""', $name).'"';
|
||||
try {
|
||||
DB::statement($safe);
|
||||
$this->info("Refreshed: {$name}");
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to refresh {$name}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -22,15 +22,6 @@ protected function schedule(Schedule $schedule): void
|
||||
'--days' => $days,
|
||||
])->dailyAt('02:00');
|
||||
}
|
||||
|
||||
// Optional: refresh configured materialized views for reporting
|
||||
$views = (array) config('reports.materialized_views', []);
|
||||
if (! empty($views)) {
|
||||
$time = (string) (config('reports.refresh_time', '03:00') ?: '03:00');
|
||||
$schedule->command('reports:refresh-mviews', [
|
||||
'--concurrently' => true,
|
||||
])->dailyAt($time);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
<?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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,20 +290,6 @@ public function cancel(Package $package): RedirectResponse
|
||||
return back()->with('success', 'Package canceled');
|
||||
}
|
||||
|
||||
public function destroy(Package $package): RedirectResponse
|
||||
{
|
||||
// Allow deletion only for drafts (not yet dispatched)
|
||||
if ($package->status !== Package::STATUS_DRAFT) {
|
||||
return back()->with('error', 'Package not in a deletable state.');
|
||||
}
|
||||
|
||||
// Remove items first to avoid FK issues
|
||||
$package->items()->delete();
|
||||
$package->delete();
|
||||
|
||||
return back()->with('success', 'Package deleted');
|
||||
}
|
||||
|
||||
/**
|
||||
* List contracts for a given segment and include selected phone per person.
|
||||
*/
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -3,51 +3,57 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Services\ReferenceDataCache;
|
||||
use DB;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
||||
|
||||
public function index(Client $client, Request $request)
|
||||
{
|
||||
$search = $request->input('search');
|
||||
|
||||
$query = $client::query()
|
||||
->select('clients.*')
|
||||
->when($search, function ($que) use ($search) {
|
||||
$que->join('person', 'person.id', '=', 'clients.person_id')
|
||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||
->groupBy('clients.id');
|
||||
->with('person')
|
||||
->when($request->input('search'), function ($que, $search) {
|
||||
$que->whereHas('person', function ($q) use ($search) {
|
||||
$q->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
})
|
||||
->where('clients.active', 1)
|
||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
||||
->leftJoin('contracts', function ($join) {
|
||||
$join->on('contracts.client_case_id', '=', 'client_cases.id')
|
||||
->whereNull('contracts.deleted_at');
|
||||
})
|
||||
->leftJoin('contract_segment', function ($join) {
|
||||
$join->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
})
|
||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->groupBy('clients.id')
|
||||
->where('active', 1)
|
||||
->addSelect([
|
||||
// Number of client cases for this client that have at least one active contract
|
||||
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
|
||||
// Sum of account balances for active contracts
|
||||
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
'cases_with_active_contracts_count' => DB::query()
|
||||
->from('client_cases')
|
||||
->join('contracts', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->selectRaw('COUNT(DISTINCT client_cases.id)')
|
||||
->whereColumn('client_cases.client_id', 'clients.id')
|
||||
->whereNull('contracts.deleted_at')
|
||||
->whereExists(function ($q) {
|
||||
$q->from('contract_segment')
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
}),
|
||||
// Sum of account balances for active contracts that belong to this client's cases
|
||||
'active_contracts_balance_sum' => DB::query()
|
||||
->from('contracts')
|
||||
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
|
||||
->whereExists(function ($q) {
|
||||
$q->from('client_cases')
|
||||
->whereColumn('client_cases.id', 'contracts.client_case_id')
|
||||
->whereColumn('client_cases.client_id', 'clients.id');
|
||||
})
|
||||
->whereNull('contracts.deleted_at')
|
||||
->whereExists(function ($q) {
|
||||
$q->from('contract_segment')
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
}),
|
||||
])
|
||||
->with('person')
|
||||
->orderByDesc('clients.created_at');
|
||||
->orderByDesc('created_at');
|
||||
|
||||
return Inertia::render('Client/Index', [
|
||||
'clients' => $query
|
||||
->paginate($request->integer('per_page', 15))
|
||||
->paginate($request->integer('perPage', 15))
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
@@ -61,37 +67,44 @@ public function show(Client $client, Request $request)
|
||||
->findOrFail($client->id);
|
||||
|
||||
$types = [
|
||||
'address_types' => $this->referenceCache->getAddressTypes(),
|
||||
'phone_types' => $this->referenceCache->getPhoneTypes(),
|
||||
'address_types' => \App\Models\Person\AddressType::all(),
|
||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
];
|
||||
|
||||
return Inertia::render('Client/Show', [
|
||||
'client' => $data,
|
||||
'client_cases' => $data->clientCases()
|
||||
->select('client_cases.*')
|
||||
->when($request->input('search'), function ($que, $search) {
|
||||
$que->join('person', 'person.id', '=', 'client_cases.person_id')
|
||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||
->groupBy('client_cases.id');
|
||||
})
|
||||
->leftJoin('contracts', function ($join) {
|
||||
$join->on('contracts.client_case_id', '=', 'client_cases.id')
|
||||
->whereNull('contracts.deleted_at');
|
||||
})
|
||||
->leftJoin('contract_segment', function ($join) {
|
||||
$join->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
})
|
||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->groupBy('client_cases.id')
|
||||
->addSelect([
|
||||
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
||||
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->with(['person', 'client.person'])
|
||||
->where('client_cases.active', 1)
|
||||
->orderByDesc('client_cases.created_at')
|
||||
->paginate($request->integer('per_page', 15))
|
||||
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
|
||||
'person',
|
||||
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
|
||||
))
|
||||
->addSelect([
|
||||
'active_contracts_count' => \DB::query()
|
||||
->from('contracts')
|
||||
->selectRaw('COUNT(*)')
|
||||
->whereColumn('contracts.client_case_id', 'client_cases.id')
|
||||
->whereNull('contracts.deleted_at')
|
||||
->whereExists(function ($q) {
|
||||
$q->from('contract_segment')
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
}),
|
||||
'active_contracts_balance_sum' => \DB::query()
|
||||
->from('contracts')
|
||||
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
|
||||
->whereColumn('contracts.client_case_id', 'client_cases.id')
|
||||
->whereNull('contracts.deleted_at')
|
||||
->whereExists(function ($q) {
|
||||
$q->from('contract_segment')
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
}),
|
||||
])
|
||||
->where('active', 1)
|
||||
->orderByDesc('created_at')
|
||||
->paginate($request->integer('perPage', 15))
|
||||
->withQueryString(),
|
||||
'types' => $types,
|
||||
'filters' => $request->only(['search']),
|
||||
@@ -109,30 +122,8 @@ public function contracts(Client $client, Request $request)
|
||||
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
|
||||
|
||||
$contractsQuery = \App\Models\Contract::query()
|
||||
->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.start_date', 'contracts.client_case_id'])
|
||||
->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
|
||||
->where('client_cases.client_id', $client->id)
|
||||
->whereNull('contracts.deleted_at')
|
||||
->when($from || $to, function ($q) use ($from, $to) {
|
||||
if (! empty($from)) {
|
||||
$q->whereDate('contracts.start_date', '>=', $from);
|
||||
}
|
||||
if (! empty($to)) {
|
||||
$q->whereDate('contracts.start_date', '<=', $to);
|
||||
}
|
||||
})
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->leftJoin('person', 'person.id', '=', 'client_cases.person_id')
|
||||
->where(function ($inner) use ($search) {
|
||||
$inner->where('contracts.reference', 'ilike', '%'.$search.'%')
|
||||
->orWhere('person.full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
})
|
||||
->when($segmentIds, function ($q) use ($segmentIds) {
|
||||
$q->whereHas('segments', function ($s) use ($segmentIds) {
|
||||
$s->whereIn('segments.id', $segmentIds)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
->whereHas('clientCase', function ($q) use ($client) {
|
||||
$q->where('client_id', $client->id);
|
||||
})
|
||||
->with([
|
||||
'clientCase:id,uuid,person_id',
|
||||
@@ -142,25 +133,43 @@ public function contracts(Client $client, Request $request)
|
||||
},
|
||||
'account:id,accounts.contract_id,balance_amount',
|
||||
])
|
||||
->orderByDesc('contracts.start_date');
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
|
||||
->whereNull('deleted_at')
|
||||
->when($from || $to, function ($q) use ($from, $to) {
|
||||
if (! empty($from)) {
|
||||
$q->whereDate('start_date', '>=', $from);
|
||||
}
|
||||
if (! empty($to)) {
|
||||
$q->whereDate('start_date', '<=', $to);
|
||||
}
|
||||
})
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->where(function ($inner) use ($search) {
|
||||
$inner->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
||||
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
})
|
||||
->when($segmentIds, function ($q) use ($segmentIds) {
|
||||
$q->whereHas('segments', function ($s) use ($segmentIds) {
|
||||
$s->whereIn('segments.id', $segmentIds)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
})
|
||||
->orderByDesc('start_date');
|
||||
|
||||
$segments = \App\Models\Segment::orderBy('name')->get(['id', 'name']);
|
||||
|
||||
$types = [
|
||||
'address_types' => $this->referenceCache->getAddressTypes(),
|
||||
'phone_types' => $this->referenceCache->getPhoneTypes(),
|
||||
'address_types' => \App\Models\Person\AddressType::all(),
|
||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
];
|
||||
|
||||
// Support custom pagination parameter names used by DataTableNew2
|
||||
$perPage = $request->integer('contracts_per_page', $request->integer('per_page', 15));
|
||||
$pageNumber = $request->integer('contracts_page', $request->integer('page', 1));
|
||||
|
||||
return Inertia::render('Client/Contracts', [
|
||||
'client' => $data,
|
||||
'contracts' => $contractsQuery
|
||||
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['from', 'to', 'search', 'segment']),
|
||||
'contracts' => $contractsQuery->paginate($request->integer('perPage', 20))->withQueryString(),
|
||||
'filters' => $request->only(['from', 'to', 'search', 'segments']),
|
||||
'segments' => $segments,
|
||||
'types' => $types,
|
||||
]);
|
||||
@@ -203,14 +212,14 @@ public function store(Request $request)
|
||||
|
||||
// \App\Models\Person\PersonAddress::create($address);
|
||||
|
||||
return back()->with('success', 'Client created')->with('flash_method', 'POST');
|
||||
return to_route('client');
|
||||
|
||||
}
|
||||
|
||||
public function update(Client $client, Request $request)
|
||||
{
|
||||
|
||||
return back()->with('success', 'Client updated')->with('flash_method', 'PUT');
|
||||
return to_route('client.show', $client);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,7 +49,7 @@ public function store(Request $request)
|
||||
});
|
||||
}
|
||||
|
||||
return back()->with('success', 'Contract created')->with('flash_method', 'POST');
|
||||
return to_route('clientCase.show', $clientCase);
|
||||
}
|
||||
|
||||
public function update(Contract $contract, Request $request)
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Client;
|
||||
use App\Models\Contract;
|
||||
// assuming model name Import
|
||||
use App\Models\Document; // assuming model name Import
|
||||
use App\Models\FieldJob; // if this model exists
|
||||
use App\Models\Import;
|
||||
use App\Models\SmsLog;
|
||||
use App\Models\SmsProfile;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
@@ -23,188 +21,256 @@ class DashboardController extends Controller
|
||||
public function __invoke(SmsService $sms): Response
|
||||
{
|
||||
$today = now()->startOfDay();
|
||||
$cacheMinutes = 5;
|
||||
$yesterday = now()->subDay()->startOfDay();
|
||||
$staleThreshold = now()->subDays(7); // assumption: stale if no activity in last 7 days
|
||||
|
||||
// Active clients count - cached
|
||||
$activeClientsCount = Cache::remember('dashboard:active_clients:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
return Client::where('active', true)->count();
|
||||
});
|
||||
$clientsTotal = Client::count();
|
||||
$clientsNew7d = Client::where('created_at', '>=', now()->subDays(7))->count();
|
||||
// FieldJob table does not have a scheduled_at column (schema shows: assigned_at, completed_at, cancelled_at)
|
||||
// Temporary logic: if scheduled_at ever added we'll use it; otherwise fall back to assigned_at then created_at.
|
||||
if (Schema::hasColumn('field_jobs', 'scheduled_at')) {
|
||||
$fieldJobsToday = FieldJob::whereDate('scheduled_at', $today)->count();
|
||||
} else {
|
||||
// Prefer assigned_at when present, otherwise created_at
|
||||
$fieldJobsToday = FieldJob::whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)->count();
|
||||
}
|
||||
$documentsToday = Document::whereDate('created_at', $today)->count();
|
||||
$activeImports = Import::whereIn('status', ['queued', 'processing'])->count();
|
||||
$activeContracts = Contract::where('active', 1)->count();
|
||||
|
||||
// Active contracts count - cached
|
||||
$activeContractsCount = Cache::remember('dashboard:active_contracts:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
return Contract::whereNull('deleted_at')->count();
|
||||
});
|
||||
// Basic activities deferred list (limit 10)
|
||||
$activities = Activity::query()
|
||||
->with(['clientCase:id,uuid'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get(['id', 'note', 'created_at', 'client_case_id', 'contract_id', 'action_id', 'decision_id'])
|
||||
->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'note' => $a->note,
|
||||
'created_at' => $a->created_at,
|
||||
'client_case_id' => $a->client_case_id,
|
||||
'client_case_uuid' => $a->clientCase?->uuid,
|
||||
'contract_id' => $a->contract_id,
|
||||
'action_id' => $a->action_id,
|
||||
'decision_id' => $a->decision_id,
|
||||
]);
|
||||
|
||||
// Sum of active contracts' account balance - cached
|
||||
$totalBalance = Cache::remember('dashboard:total_balance:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
return Account::whereHas('contract', function ($q) {
|
||||
$q->whereNull('deleted_at');
|
||||
})->sum('balance_amount') ?? 0;
|
||||
});
|
||||
// 7-day trends (including today)
|
||||
$start = now()->subDays(6)->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
|
||||
// Active promises count (not expired or expires today) - cached
|
||||
$activePromisesCount = Cache::remember('dashboard:active_promises:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
||||
return Account::whereHas('contract', function ($q) {
|
||||
$q->whereNull('deleted_at');
|
||||
$dateKeys = collect(range(0, 6))
|
||||
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
||||
|
||||
$clientTrendRaw = Client::whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$documentTrendRaw = Document::whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
||||
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$importTrendRaw = Import::whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
// Completed field jobs last 7 days
|
||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||
->whereBetween('completed_at', [$start, $end])
|
||||
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
$trends = [
|
||||
'clients_new' => $dateKeys->map(fn ($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(),
|
||||
'documents_new' => $dateKeys->map(fn ($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(),
|
||||
'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
|
||||
'imports_new' => $dateKeys->map(fn ($d) => (int) ($importTrendRaw[$d] ?? 0))->values(),
|
||||
'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
|
||||
'labels' => $dateKeys,
|
||||
];
|
||||
|
||||
// Stale client cases (no activity in last 7 days)
|
||||
$staleCases = \App\Models\ClientCase::query()
|
||||
->leftJoin('activities', function ($join) {
|
||||
$join->on('activities.client_case_id', '=', 'client_cases.id')
|
||||
->whereNull('activities.deleted_at');
|
||||
})
|
||||
->whereNotNull('promise_date')
|
||||
->whereDate('promise_date', '>=', $today)
|
||||
->count();
|
||||
});
|
||||
->selectRaw('client_cases.id, client_cases.uuid, client_cases.client_ref, MAX(activities.created_at) as last_activity_at, client_cases.created_at')
|
||||
->groupBy('client_cases.id', 'client_cases.uuid', 'client_cases.client_ref', 'client_cases.created_at')
|
||||
->havingRaw('(MAX(activities.created_at) IS NULL OR MAX(activities.created_at) < ?) AND client_cases.created_at < ?', [$staleThreshold, $staleThreshold])
|
||||
->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(function ($c) {
|
||||
// Reference point: last activity if exists, else creation.
|
||||
$reference = $c->last_activity_at ? \Illuminate\Support\Carbon::parse($c->last_activity_at) : $c->created_at;
|
||||
// Use minute precision to avoid jumping to 1 too early (e.g. created just before midnight).
|
||||
$minutes = $reference ? max(0, $reference->diffInMinutes(now())) : 0;
|
||||
$daysFraction = $minutes / 1440; // 60 * 24
|
||||
// Provide both fractional and integer versions (integer preserved for backwards compatibility if needed)
|
||||
$daysInteger = (int) floor($daysFraction);
|
||||
|
||||
// Activities (limit 10) - cached
|
||||
$activities = Cache::remember('dashboard:activities', $cacheMinutes * 60, function () {
|
||||
return Activity::query()
|
||||
->with(['clientCase:id,uuid'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get(['id', 'note', 'created_at', 'client_case_id', 'contract_id', 'action_id', 'decision_id'])
|
||||
->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'note' => $a->note,
|
||||
'created_at' => $a->created_at,
|
||||
'client_case_id' => $a->client_case_id,
|
||||
'client_case_uuid' => $a->clientCase?->uuid,
|
||||
'contract_id' => $a->contract_id,
|
||||
'action_id' => $a->action_id,
|
||||
'decision_id' => $a->decision_id,
|
||||
]);
|
||||
});
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'uuid' => $c->uuid,
|
||||
'client_ref' => $c->client_ref,
|
||||
'last_activity_at' => $c->last_activity_at,
|
||||
'created_at' => $c->created_at,
|
||||
'days_without_activity' => round($daysFraction, 4), // fractional for finer UI decision (<1 day)
|
||||
'days_stale' => $daysInteger, // legacy key (integer)
|
||||
'has_activity' => (bool) $c->last_activity_at,
|
||||
];
|
||||
});
|
||||
|
||||
// 7-day trends for field jobs - cached
|
||||
$trends = Cache::remember('dashboard:field_jobs_trends:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
$start = now()->subDays(6)->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
|
||||
$dateKeys = collect(range(0, 6))
|
||||
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
||||
|
||||
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
||||
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
// Completed field jobs last 7 days
|
||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||
->whereBetween('completed_at', [$start, $end])
|
||||
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
return [
|
||||
'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
|
||||
'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
|
||||
'labels' => $dateKeys,
|
||||
];
|
||||
});
|
||||
|
||||
// Field jobs assigned today - cached
|
||||
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
||||
return FieldJob::query()
|
||||
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
||||
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
||||
->with(['contract' => function ($q) {
|
||||
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
||||
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
|
||||
}])
|
||||
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
|
||||
->limit(15)
|
||||
->get()
|
||||
->map(function ($fj) {
|
||||
$contract = $fj->contract;
|
||||
$segmentId = null;
|
||||
if ($contract && method_exists($contract, 'segments')) {
|
||||
$activeSeg = $contract->segments->first();
|
||||
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
|
||||
$segmentId = $activeSeg->id;
|
||||
}
|
||||
// Field jobs assigned today
|
||||
$fieldJobsAssignedToday = FieldJob::query()
|
||||
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
||||
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
||||
->with(['contract' => function ($q) {
|
||||
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
||||
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
|
||||
}])
|
||||
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
|
||||
->limit(15)
|
||||
->get()
|
||||
->map(function ($fj) {
|
||||
$contract = $fj->contract;
|
||||
$segmentId = null;
|
||||
if ($contract && method_exists($contract, 'segments')) {
|
||||
// Determine active segment via pivot active flag if present
|
||||
$activeSeg = $contract->segments->first();
|
||||
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
|
||||
$segmentId = $activeSeg->id;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $fj->id,
|
||||
'priority' => $fj->priority,
|
||||
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
||||
'created_at' => $fj->created_at?->toIso8601String(),
|
||||
'contract' => $contract ? [
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
||||
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
||||
'segment_id' => $segmentId,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
});
|
||||
return [
|
||||
'id' => $fj->id,
|
||||
'priority' => $fj->priority,
|
||||
// Normalize to ISO8601 strings so FE retains timezone & time component
|
||||
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
||||
'created_at' => $fj->created_at?->toIso8601String(),
|
||||
'contract' => $contract ? [
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
||||
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
||||
'segment_id' => $segmentId,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
|
||||
// System health for timestamp
|
||||
// Imports in progress (queued / processing)
|
||||
$importsInProgress = Import::query()
|
||||
->whereIn('status', ['queued', 'processing'])
|
||||
->latest('created_at')
|
||||
->limit(10)
|
||||
->get(['id', 'uuid', 'file_name', 'status', 'total_rows', 'imported_rows', 'valid_rows', 'invalid_rows', 'started_at'])
|
||||
->map(fn ($i) => [
|
||||
'id' => $i->id,
|
||||
'uuid' => $i->uuid,
|
||||
'file_name' => $i->file_name,
|
||||
'status' => $i->status,
|
||||
'total_rows' => $i->total_rows,
|
||||
'imported_rows' => $i->imported_rows,
|
||||
'valid_rows' => $i->valid_rows,
|
||||
'invalid_rows' => $i->invalid_rows,
|
||||
'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1, $i->total_rows)) * 100, 1) : null,
|
||||
'started_at' => $i->started_at,
|
||||
]);
|
||||
|
||||
// Active document templates summary (active versions)
|
||||
$activeTemplates = \App\Models\DocumentTemplate::query()
|
||||
->where('active', true)
|
||||
->latest('updated_at')
|
||||
->limit(10)
|
||||
->get(['id', 'name', 'slug', 'version', 'updated_at']);
|
||||
|
||||
// System health (deferred)
|
||||
$queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null;
|
||||
$failedJobs = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : null;
|
||||
$recentActivity = Activity::query()->latest('created_at')->value('created_at');
|
||||
$lastActivityMinutes = null;
|
||||
if ($recentActivity) {
|
||||
// diffInMinutes is absolute (non-negative) but guard anyway & cast to int
|
||||
$lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity));
|
||||
}
|
||||
$systemHealth = [
|
||||
'queue_backlog' => $queueBacklog,
|
||||
'failed_jobs' => $failedJobs,
|
||||
'last_activity_minutes' => $lastActivityMinutes,
|
||||
'last_activity_iso' => $recentActivity?->toIso8601String(),
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
return Inertia::render('Dashboard/Index', [
|
||||
return Inertia::render('Dashboard', [
|
||||
'kpis' => [
|
||||
'active_clients' => $activeClientsCount,
|
||||
'active_contracts' => $activeContractsCount,
|
||||
'total_balance' => $totalBalance,
|
||||
'active_promises' => $activePromisesCount,
|
||||
'clients_total' => $clientsTotal,
|
||||
'clients_new_7d' => $clientsNew7d,
|
||||
'field_jobs_today' => $fieldJobsToday,
|
||||
'documents_today' => $documentsToday,
|
||||
'active_imports' => $activeImports,
|
||||
'active_contracts' => $activeContracts,
|
||||
],
|
||||
'trends' => $trends,
|
||||
])->with([
|
||||
])->with([ // deferred props (Inertia v2 style)
|
||||
'activities' => fn () => $activities,
|
||||
'systemHealth' => fn () => $systemHealth,
|
||||
'staleCases' => fn () => $staleCases,
|
||||
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
|
||||
'smsStats' => function () use ($sms, $today, $cacheMinutes) {
|
||||
// SMS stats - cached
|
||||
return Cache::remember('dashboard:sms_stats:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($sms, $today) {
|
||||
$counts = SmsLog::query()
|
||||
->whereDate('created_at', $today)
|
||||
->selectRaw('profile_id, status, COUNT(*) as c')
|
||||
->groupBy('profile_id', 'status')
|
||||
->get()
|
||||
->groupBy('profile_id')
|
||||
->map(function ($rows) {
|
||||
$map = [
|
||||
'queued' => 0,
|
||||
'sent' => 0,
|
||||
'delivered' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
foreach ($rows as $r) {
|
||||
$map[$r->status] = (int) $r->c;
|
||||
}
|
||||
$map['total'] = array_sum($map);
|
||||
|
||||
return $map;
|
||||
});
|
||||
|
||||
$profiles = SmsProfile::query()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
|
||||
|
||||
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
|
||||
try {
|
||||
$balance = $sms->getCreditBalance($p);
|
||||
} catch (\Throwable $e) {
|
||||
$balance = '—';
|
||||
}
|
||||
$c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0];
|
||||
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'active' => (bool) $p->active,
|
||||
'balance' => $balance,
|
||||
'today' => $c,
|
||||
'importsInProgress' => fn () => $importsInProgress,
|
||||
'activeTemplates' => fn () => $activeTemplates,
|
||||
'smsStats' => function () use ($sms, $today) {
|
||||
// Aggregate counts per profile for today
|
||||
$counts = SmsLog::query()
|
||||
->whereDate('created_at', $today)
|
||||
->selectRaw('profile_id, status, COUNT(*) as c')
|
||||
->groupBy('profile_id', 'status')
|
||||
->get()
|
||||
->groupBy('profile_id')
|
||||
->map(function ($rows) {
|
||||
$map = [
|
||||
'queued' => 0,
|
||||
'sent' => 0,
|
||||
'delivered' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
})->values();
|
||||
});
|
||||
foreach ($rows as $r) {
|
||||
$map[$r->status] = (int) $r->c;
|
||||
}
|
||||
$map['total'] = array_sum($map);
|
||||
|
||||
return $map;
|
||||
});
|
||||
|
||||
// Important: include credential fields so provider calls have proper credentials
|
||||
$profiles = SmsProfile::query()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
|
||||
|
||||
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
|
||||
// Provider balance may fail; guard and present a placeholder.
|
||||
try {
|
||||
$balance = $sms->getCreditBalance($p);
|
||||
} catch (\Throwable $e) {
|
||||
$balance = '—';
|
||||
}
|
||||
$c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0];
|
||||
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'active' => (bool) $p->active,
|
||||
'balance' => $balance,
|
||||
'today' => $c,
|
||||
];
|
||||
})->values();
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -26,10 +26,18 @@ public function update(Person $person, Request $request)
|
||||
|
||||
$person->update($attributes);
|
||||
|
||||
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
|
||||
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Person updated');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'person' => [
|
||||
'full_name' => $person->full_name,
|
||||
'tax_number' => $person->tax_number,
|
||||
'social_security_number' => $person->social_security_number,
|
||||
'description' => $person->description,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function createAddress(Person $person, Request $request)
|
||||
@@ -52,8 +60,13 @@ public function createAddress(Person $person, Request $request)
|
||||
], $attributes);
|
||||
|
||||
// Support Inertia form submissions (redirect back) and JSON (for API/axios)
|
||||
return back()->with('success', 'Address created')->with('flash_method', 'POST');
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Address created');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateAddress(Person $person, int $address_id, Request $request)
|
||||
@@ -71,8 +84,13 @@ public function updateAddress(Person $person, int $address_id, Request $request)
|
||||
|
||||
$address->update($attributes);
|
||||
|
||||
return back()->with('success', 'Address updated')->with('flash_method', 'PUT');
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Address updated');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'address' => $address,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteAddress(Person $person, int $address_id, Request $request)
|
||||
@@ -80,8 +98,11 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
||||
$address = $person->addresses()->findOrFail($address_id);
|
||||
$address->delete(); // soft delete
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Address deleted');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
public function createPhone(Person $person, Request $request)
|
||||
@@ -101,7 +122,7 @@ public function createPhone(Person $person, Request $request)
|
||||
'country_code' => $attributes['country_code'] ?? null,
|
||||
], $attributes);
|
||||
|
||||
return back()->with('success', 'Phone added successfully')->with('flash_method', 'POST');
|
||||
return back()->with('success', 'Phone added successfully');
|
||||
}
|
||||
|
||||
public function updatePhone(Person $person, int $phone_id, Request $request)
|
||||
@@ -119,7 +140,7 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
|
||||
|
||||
$phone->update($attributes);
|
||||
|
||||
return back()->with('success', 'Phone updated successfully')->with('flash_method', 'PUT');
|
||||
return back()->with('success', 'Phone updated successfully');
|
||||
}
|
||||
|
||||
public function deletePhone(Person $person, int $phone_id, Request $request)
|
||||
@@ -127,7 +148,7 @@ public function deletePhone(Person $person, int $phone_id, Request $request)
|
||||
$phone = $person->phones()->findOrFail($phone_id);
|
||||
$phone->delete(); // soft delete
|
||||
|
||||
return back()->with('success', 'Phone deleted')->with('flash_method', 'DELETE');
|
||||
return back()->with('success', 'Phone deleted');
|
||||
}
|
||||
|
||||
public function createEmail(Person $person, Request $request)
|
||||
@@ -149,7 +170,7 @@ public function createEmail(Person $person, Request $request)
|
||||
'value' => $attributes['value'],
|
||||
], $attributes);
|
||||
|
||||
return back()->with('success', 'Email added successfully')->with('flash_method', 'POST');
|
||||
return back()->with('success', 'Email added successfully');
|
||||
}
|
||||
|
||||
public function updateEmail(Person $person, int $email_id, Request $request)
|
||||
@@ -170,7 +191,7 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
||||
|
||||
$email->update($attributes);
|
||||
|
||||
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
|
||||
return back()->with('success', 'Email updated successfully');
|
||||
}
|
||||
|
||||
public function deleteEmail(Person $person, int $email_id, Request $request)
|
||||
@@ -182,7 +203,7 @@ public function deleteEmail(Person $person, int $email_id, Request $request)
|
||||
return back()->with('success', 'Email deleted');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Email deleted')->with('flash_method', 'DELETE');
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
// TRR (bank account) CRUD
|
||||
@@ -204,10 +225,13 @@ public function createTrr(Person $person, Request $request)
|
||||
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||
$trr = $person->bankAccounts()->create($attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'TRR added successfully');
|
||||
}
|
||||
|
||||
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
|
||||
|
||||
|
||||
return response()->json([
|
||||
'trr' => BankAccount::findOrFail($trr->id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateTrr(Person $person, int $trr_id, Request $request)
|
||||
@@ -229,8 +253,13 @@ public function updateTrr(Person $person, int $trr_id, Request $request)
|
||||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||
$trr->update($attributes);
|
||||
|
||||
return back()->with('success', 'TRR updated successfully')->with('flash_method', 'PUT');
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'TRR updated successfully');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'trr' => $trr,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteTrr(Person $person, int $trr_id, Request $request)
|
||||
@@ -238,8 +267,10 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
||||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||
$trr->delete();
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'TRR deleted');
|
||||
}
|
||||
|
||||
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\FieldJob;
|
||||
use App\Services\ReferenceDataCache;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PhoneViewController extends Controller
|
||||
{
|
||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
||||
public function index(Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Reports\ReportRegistry;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
// facades referenced with fully-qualified names below to satisfy static analysis
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
public function __construct(protected ReportRegistry $registry) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$reports = collect($this->registry->all())
|
||||
->map(fn ($r) => [
|
||||
'slug' => $r->slug(),
|
||||
'name' => $r->name(),
|
||||
'description' => $r->description(),
|
||||
])
|
||||
->values();
|
||||
|
||||
return Inertia::render('Reports/Index', [
|
||||
'reports' => $reports,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $slug, Request $request)
|
||||
{
|
||||
$report = $this->registry->findBySlug($slug);
|
||||
abort_if(! $report, 404);
|
||||
$report->authorize($request);
|
||||
|
||||
// Accept filters & pagination from query and return initial data for server-driven table
|
||||
$filters = $this->validateFilters($report->inputs(), $request);
|
||||
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
|
||||
$perPage = (int) ($request->integer('per_page') ?: 25);
|
||||
$paginator = $report->paginate($filters, $perPage);
|
||||
|
||||
$rows = collect($paginator->items())
|
||||
->map(fn ($row) => $this->normalizeRow($row))
|
||||
->values();
|
||||
|
||||
return Inertia::render('Reports/Show', [
|
||||
'slug' => $report->slug(),
|
||||
'name' => $report->name(),
|
||||
'description' => $report->description(),
|
||||
'inputs' => $report->inputs(),
|
||||
'columns' => $report->columns(),
|
||||
'rows' => $rows,
|
||||
'meta' => [
|
||||
'total' => $paginator->total(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
],
|
||||
'query' => array_filter($filters, fn ($v) => $v !== null && $v !== ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(string $slug, Request $request)
|
||||
{
|
||||
$report = $this->registry->findBySlug($slug);
|
||||
abort_if(! $report, 404);
|
||||
$report->authorize($request);
|
||||
|
||||
$filters = $this->validateFilters($report->inputs(), $request);
|
||||
$perPage = (int) ($request->integer('per_page') ?: 25);
|
||||
|
||||
$paginator = $report->paginate($filters, $perPage);
|
||||
|
||||
$rows = collect($paginator->items())
|
||||
->map(fn ($row) => $this->normalizeRow($row))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $rows,
|
||||
'total' => $paginator->total(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(string $slug, Request $request)
|
||||
{
|
||||
$report = $this->registry->findBySlug($slug);
|
||||
abort_if(! $report, 404);
|
||||
$report->authorize($request);
|
||||
|
||||
$filters = $this->validateFilters($report->inputs(), $request);
|
||||
$format = strtolower((string) $request->get('format', 'csv'));
|
||||
|
||||
$rows = $report->query($filters)->get()->map(fn ($row) => $this->normalizeRow($row));
|
||||
$columns = $report->columns();
|
||||
$filename = $report->slug().'-'.now()->format('Ymd_His');
|
||||
|
||||
if ($format === 'pdf') {
|
||||
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [
|
||||
'name' => $report->name(),
|
||||
'columns' => $columns,
|
||||
'rows' => $rows,
|
||||
]);
|
||||
|
||||
return $pdf->download($filename.'.pdf');
|
||||
}
|
||||
|
||||
if ($format === 'xlsx') {
|
||||
$keys = array_map(fn ($c) => $c['key'], $columns);
|
||||
$headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns);
|
||||
|
||||
// Convert values for correct Excel rendering (dates, numbers, text)
|
||||
$array = $this->prepareXlsxArray($rows, $keys);
|
||||
|
||||
// Build base column formats: text for contracts, EU datetime for *_at; numbers are formatted per-cell in AfterSheet
|
||||
$columnFormats = [];
|
||||
$textColumns = [];
|
||||
$dateColumns = [];
|
||||
foreach ($keys as $i => $key) {
|
||||
$letter = $this->excelColumnLetter($i + 1);
|
||||
if ($key === 'contract_reference') {
|
||||
$columnFormats[$letter] = '@';
|
||||
$textColumns[] = $letter;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($key, '_at')) {
|
||||
$columnFormats[$letter] = 'dd.mm.yyyy hh:mm';
|
||||
$dateColumns[] = $letter;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous export with custom value binder to force text where needed
|
||||
$export = new class($array, $headings, $columnFormats, $textColumns, $dateColumns) extends \Maatwebsite\Excel\DefaultValueBinder implements \Maatwebsite\Excel\Concerns\FromArray, \Maatwebsite\Excel\Concerns\ShouldAutoSize, \Maatwebsite\Excel\Concerns\WithColumnFormatting, \Maatwebsite\Excel\Concerns\WithCustomValueBinder, \Maatwebsite\Excel\Concerns\WithEvents, \Maatwebsite\Excel\Concerns\WithHeadings
|
||||
{
|
||||
public function __construct(private array $array, private array $headings, private array $formats, private array $textColumns, private array $dateColumns) {}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return $this->array;
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return $this->headings;
|
||||
}
|
||||
|
||||
public function columnFormats(): array
|
||||
{
|
||||
return $this->formats;
|
||||
}
|
||||
|
||||
public function bindValue(\PhpOffice\PhpSpreadsheet\Cell\Cell $cell, $value): bool
|
||||
{
|
||||
$col = preg_replace('/\d+/', '', $cell->getCoordinate()); // e.g., B from B2
|
||||
// Force text for configured columns or very long digit-only strings (>15)
|
||||
if (in_array($col, $this->textColumns, true) || (is_string($value) && ctype_digit($value) && strlen($value) > 15)) {
|
||||
$cell->setValueExplicit((string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return parent::bindValue($cell, $value);
|
||||
}
|
||||
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [
|
||||
\Maatwebsite\Excel\Events\AfterSheet::class => function (\Maatwebsite\Excel\Events\AfterSheet $event) {
|
||||
$sheet = $event->sheet->getDelegate();
|
||||
// Data starts at row 2 (row 1 is headings)
|
||||
$rowIndex = 2;
|
||||
foreach ($this->array as $row) {
|
||||
foreach (array_values($row) as $i => $val) {
|
||||
$colLetter = $this->colLetter($i + 1);
|
||||
if (in_array($colLetter, $this->textColumns, true) || in_array($colLetter, $this->dateColumns, true)) {
|
||||
continue; // already handled via columnFormats or binder
|
||||
}
|
||||
$coord = $colLetter.$rowIndex;
|
||||
$fmt = null;
|
||||
if (is_int($val)) {
|
||||
// Integer: thousands separator, no decimals
|
||||
$fmt = '#,##0';
|
||||
} elseif (is_float($val)) {
|
||||
// Float: show decimals only if fractional part exists
|
||||
$fmt = (floor($val) != $val) ? '#,##0.00' : '#,##0';
|
||||
}
|
||||
if ($fmt) {
|
||||
$sheet->getStyle($coord)->getNumberFormat()->setFormatCode($fmt);
|
||||
}
|
||||
}
|
||||
$rowIndex++;
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private function colLetter(int $index): string
|
||||
{
|
||||
$letter = '';
|
||||
while ($index > 0) {
|
||||
$mod = ($index - 1) % 26;
|
||||
$letter = chr(65 + $mod).$letter;
|
||||
$index = intdiv($index - $mod, 26) - 1;
|
||||
}
|
||||
|
||||
return $letter;
|
||||
}
|
||||
};
|
||||
|
||||
return \Maatwebsite\Excel\Facades\Excel::download($export, $filename.'.xlsx');
|
||||
}
|
||||
|
||||
// Default CSV export
|
||||
$keys = array_map(fn ($c) => $c['key'], $columns);
|
||||
$headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns);
|
||||
|
||||
$csv = fopen('php://temp', 'r+');
|
||||
fputcsv($csv, $headings);
|
||||
foreach ($rows as $r) {
|
||||
$line = collect($keys)->map(fn ($k) => data_get($r, $k))->toArray();
|
||||
fputcsv($csv, $line);
|
||||
}
|
||||
rewind($csv);
|
||||
$content = stream_get_contents($csv) ?: '';
|
||||
fclose($csv);
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'.csv"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight users lookup for filters: id + name, optional search and limit.
|
||||
*/
|
||||
public function users(Request $request)
|
||||
{
|
||||
$search = trim((string) $request->get('search', ''));
|
||||
$limit = (int) ($request->integer('limit') ?: 10);
|
||||
|
||||
$q = \App\Models\User::query()->orderBy('name');
|
||||
if ($search !== '') {
|
||||
$like = '%'.mb_strtolower($search).'%';
|
||||
$q->where(function ($qq) use ($like) {
|
||||
$qq->whereRaw('LOWER(name) LIKE ?', [$like])
|
||||
->orWhereRaw('LOWER(email) LIKE ?', [$like]);
|
||||
});
|
||||
}
|
||||
|
||||
$users = $q->limit(max(1, min(50, $limit)))->get(['id', 'name']);
|
||||
|
||||
return response()->json($users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight clients lookup for filters: uuid + name (person full_name), optional search and limit.
|
||||
*/
|
||||
public function clients(Request $request)
|
||||
{
|
||||
$clients = \App\Models\Client::query()
|
||||
->with('person:id,full_name')
|
||||
->get()
|
||||
->map(fn($c) => [
|
||||
'id' => $c->uuid,
|
||||
'name' => $c->person->full_name ?? 'Unknown'
|
||||
])
|
||||
->sortBy('name')
|
||||
->values();
|
||||
|
||||
return response()->json($clients);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build validation rules based on inputs descriptor and validate.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $inputs
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function validateFilters(array $inputs, Request $request): array
|
||||
{
|
||||
$rules = [];
|
||||
foreach ($inputs as $inp) {
|
||||
$key = $inp['key'];
|
||||
$type = $inp['type'] ?? 'string';
|
||||
$nullable = ($inp['nullable'] ?? true) ? 'nullable' : 'required';
|
||||
$rules[$key] = match ($type) {
|
||||
'date' => [$nullable, 'date'],
|
||||
'integer' => [$nullable, 'integer'],
|
||||
'select:user' => [$nullable, 'integer', 'exists:users,id'],
|
||||
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
|
||||
default => [$nullable, 'string'],
|
||||
};
|
||||
}
|
||||
|
||||
return $request->validate($rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure derived export/display fields exist on row objects.
|
||||
*/
|
||||
protected function normalizeRow(object $row): object
|
||||
{
|
||||
if (isset($row->contract) && ! isset($row->contract_reference)) {
|
||||
$row->contract_reference = $row->contract->reference ?? null;
|
||||
}
|
||||
if (isset($row->assignedUser) && ! isset($row->assigned_user_name)) {
|
||||
$row->assigned_user_name = $row->assignedUser->name ?? null;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert rows for XLSX export: dates to Excel serial numbers, numbers to numeric, contract refs to text.
|
||||
*
|
||||
* @param iterable<int, object|array> $rows
|
||||
* @param array<int, string> $keys
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
protected function prepareXlsxArray(iterable $rows, array $keys): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$line = [];
|
||||
foreach ($keys as $k) {
|
||||
$v = data_get($r, $k);
|
||||
if ($k === 'contract_reference') {
|
||||
$line[] = (string) $v;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($k, '_at')) {
|
||||
if (empty($v)) {
|
||||
$line[] = null;
|
||||
} else {
|
||||
try {
|
||||
$dt = \Carbon\Carbon::parse($v);
|
||||
$line[] = \PhpOffice\PhpSpreadsheet\Shared\Date::dateTimeToExcel($dt);
|
||||
} catch (\Throwable $e) {
|
||||
$line[] = (string) $v;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
if (is_int($v) || is_float($v)) {
|
||||
$line[] = $v;
|
||||
} elseif (is_numeric($v) && is_string($v)) {
|
||||
// cast numeric-like strings unless they are identifiers that we want as text
|
||||
$line[] = (strpos($k, 'id') !== false) ? (int) $v : ($v + 0);
|
||||
} else {
|
||||
$line[] = $v;
|
||||
}
|
||||
}
|
||||
$out[] = $line;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert 1-based index to Excel column letter.
|
||||
*/
|
||||
protected function excelColumnLetter(int $index): string
|
||||
{
|
||||
$letter = '';
|
||||
while ($index > 0) {
|
||||
$mod = ($index - 1) % 26;
|
||||
$letter = chr(65 + $mod).$letter;
|
||||
$index = intdiv($index - $mod, 26) - 1;
|
||||
}
|
||||
|
||||
return $letter;
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,6 @@ public function share(Request $request): array
|
||||
'error' => fn () => $request->session()->get('error'),
|
||||
'warning' => fn () => $request->session()->get('warning'),
|
||||
'info' => fn () => $request->session()->get('info'),
|
||||
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
|
||||
],
|
||||
'notifications' => function () use ($request) {
|
||||
try {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?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,8 +2,6 @@
|
||||
|
||||
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;
|
||||
@@ -59,69 +57,6 @@ 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,8 +3,6 @@
|
||||
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;
|
||||
@@ -80,20 +78,6 @@ protected function endDate(): Attribute
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Reports\ActionsDecisionsCountReport;
|
||||
use App\Reports\ActivitiesPerPeriodReport;
|
||||
use App\Reports\ActiveContractsReport;
|
||||
use App\Reports\FieldJobsCompletedReport;
|
||||
use App\Reports\DecisionsCountReport;
|
||||
use App\Reports\ReportRegistry;
|
||||
use App\Reports\SegmentActivityCountsReport;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ReportServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(ReportRegistry::class, function () {
|
||||
$registry = new ReportRegistry;
|
||||
// Register built-in reports here
|
||||
$registry->register(new FieldJobsCompletedReport);
|
||||
$registry->register(new SegmentActivityCountsReport);
|
||||
$registry->register(new ActionsDecisionsCountReport);
|
||||
$registry->register(new ActivitiesPerPeriodReport);
|
||||
$registry->register(new DecisionsCountReport);
|
||||
$registry->register(new ActiveContractsReport);
|
||||
|
||||
return $registry;
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Reports\Contracts\Report;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ActionsDecisionsCountReport extends BaseEloquentReport implements Report
|
||||
{
|
||||
public function slug(): string
|
||||
{
|
||||
return 'actions-decisions-counts';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Dejanja / Odločitve – štetje';
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return 'Število aktivnosti po dejanjih in odločitvah v obdobju.';
|
||||
}
|
||||
|
||||
public function inputs(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
|
||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'action_name', 'label' => 'Dejanje'],
|
||||
['key' => 'decision_name', 'label' => 'Odločitev'],
|
||||
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
return Activity::query()
|
||||
->leftJoin('actions', 'activities.action_id', '=', 'actions.id')
|
||||
->leftJoin('decisions', 'activities.decision_id', '=', 'decisions.id')
|
||||
->when(! empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
|
||||
->when(! empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
|
||||
->groupBy('actions.name', 'decisions.name')
|
||||
->selectRaw("COALESCE(actions.name, '—') as action_name, COALESCE(decisions.name, '—') as decision_name, COUNT(*) as activities_count");
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Models\Contract;
|
||||
use App\Reports\Contracts\Report;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ActiveContractsReport extends BaseEloquentReport implements Report
|
||||
{
|
||||
public function slug(): string
|
||||
{
|
||||
return 'active-contracts';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Aktivne pogodbe';
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return 'Pogodbe, ki so aktivne na izbrani dan, z možnostjo filtriranja po stranki.';
|
||||
}
|
||||
|
||||
public function inputs(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'client_uuid', 'type' => 'select:client', 'label' => 'Stranka', 'nullable' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'contract_reference', 'label' => 'Pogodba'],
|
||||
['key' => 'client_name', 'label' => 'Stranka'],
|
||||
['key' => 'person_name', 'label' => 'Zadeva (oseba)'],
|
||||
['key' => 'start_date', 'label' => 'Začetek'],
|
||||
['key' => 'end_date', 'label' => 'Konec'],
|
||||
['key' => 'balance_amount', 'label' => 'Saldo'],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
$asOf = now()->toDateString();
|
||||
|
||||
return Contract::query()
|
||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->leftJoin('clients', 'client_cases.client_id', '=', 'clients.id')
|
||||
->leftJoin('person as client_people', 'clients.person_id', '=', 'client_people.id')
|
||||
->leftJoin('person as subject_people', 'client_cases.person_id', '=', 'subject_people.id')
|
||||
->leftJoin('accounts', 'contracts.id', '=', 'accounts.contract_id')
|
||||
->when(! empty($filters['client_uuid']), fn ($q) => $q->where('clients.uuid', $filters['client_uuid']))
|
||||
// Active as of date: start_date <= as_of (or null) AND (end_date is null OR end_date >= as_of)
|
||||
->where(function ($q) use ($asOf) {
|
||||
$q->whereNull('contracts.start_date')
|
||||
->orWhereDate('contracts.start_date', '<=', $asOf);
|
||||
})
|
||||
->where(function ($q) use ($asOf) {
|
||||
$q->whereNull('contracts.end_date')
|
||||
->orWhereDate('contracts.end_date', '>=', $asOf);
|
||||
})
|
||||
->select([
|
||||
'contracts.id',
|
||||
'contracts.start_date',
|
||||
'contracts.end_date',
|
||||
])
|
||||
->addSelect([
|
||||
\DB::raw('contracts.reference as contract_reference'),
|
||||
\DB::raw('client_people.full_name as client_name'),
|
||||
\DB::raw('subject_people.full_name as person_name'),
|
||||
\DB::raw('CAST(accounts.balance_amount AS FLOAT) as balance_amount'),
|
||||
])
|
||||
->orderBy('contracts.start_date', 'asc');
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Reports\Contracts\Report;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ActivitiesPerPeriodReport extends BaseEloquentReport implements Report
|
||||
{
|
||||
public function slug(): string
|
||||
{
|
||||
return 'activities-per-period';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Aktivnosti po obdobjih';
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return 'Seštevek aktivnosti po dneh/tednih/mesecih v obdobju.';
|
||||
}
|
||||
|
||||
public function inputs(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
|
||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
|
||||
['key' => 'period', 'type' => 'string', 'label' => 'Obdobje (day|week|month)', 'default' => 'day'],
|
||||
];
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'period', 'label' => 'Obdobje'],
|
||||
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
$periodRaw = $filters['period'] ?? 'day';
|
||||
$period = in_array($periodRaw, ['day', 'week', 'month'], true) ? $periodRaw : 'day';
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
// Build database-compatible period expressions
|
||||
if ($driver === 'sqlite') {
|
||||
if ($period === 'day') {
|
||||
// Use string slice to avoid timezone conversion differences in SQLite
|
||||
$selectExpr = DB::raw('SUBSTR(activities.created_at, 1, 10) as period');
|
||||
$groupExpr = DB::raw('SUBSTR(activities.created_at, 1, 10)');
|
||||
$orderExpr = DB::raw('SUBSTR(activities.created_at, 1, 10)');
|
||||
} elseif ($period === 'month') {
|
||||
$selectExpr = DB::raw("strftime('%Y-%m-01', activities.created_at) as period");
|
||||
$groupExpr = DB::raw("strftime('%Y-%m-01', activities.created_at)");
|
||||
$orderExpr = DB::raw("strftime('%Y-%m-01', activities.created_at)");
|
||||
} else { // week
|
||||
$selectExpr = DB::raw("strftime('%Y-%W', activities.created_at) as period");
|
||||
$groupExpr = DB::raw("strftime('%Y-%W', activities.created_at)");
|
||||
$orderExpr = DB::raw("strftime('%Y-%W', activities.created_at)");
|
||||
}
|
||||
} elseif ($driver === 'mysql') {
|
||||
if ($period === 'day') {
|
||||
$selectExpr = DB::raw('DATE(activities.created_at) as period');
|
||||
$groupExpr = DB::raw('DATE(activities.created_at)');
|
||||
$orderExpr = DB::raw('DATE(activities.created_at)');
|
||||
} elseif ($period === 'month') {
|
||||
$selectExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01') as period");
|
||||
$groupExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01')");
|
||||
$orderExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01')");
|
||||
} else { // week
|
||||
// ISO week-year-week number for grouping; adequate for summary grouping
|
||||
$selectExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v') as period");
|
||||
$groupExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v')");
|
||||
$orderExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v')");
|
||||
}
|
||||
} else { // postgres and others supporting date_trunc
|
||||
$selectExpr = DB::raw("date_trunc('".$period."', activities.created_at) as period");
|
||||
$groupExpr = DB::raw("date_trunc('".$period."', activities.created_at)");
|
||||
$orderExpr = DB::raw("date_trunc('".$period."', activities.created_at)");
|
||||
}
|
||||
|
||||
return Activity::query()
|
||||
->when(! empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
|
||||
->when(! empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
|
||||
->groupBy($groupExpr)
|
||||
->orderBy($orderExpr)
|
||||
->select($selectExpr)
|
||||
->selectRaw('COUNT(*) as activities_count');
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Reports\Contracts\Report;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
abstract class BaseEloquentReport implements Report
|
||||
{
|
||||
public function description(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function authorize(Request $request): void
|
||||
{
|
||||
// Default: no extra checks. Controllers can gate via middleware.
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator
|
||||
{
|
||||
/** @var EloquentBuilder|QueryBuilder $query */
|
||||
$query = $this->query($filters);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports\Contracts;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
interface Report
|
||||
{
|
||||
public function slug(): string;
|
||||
|
||||
public function name(): string;
|
||||
|
||||
public function description(): ?string;
|
||||
|
||||
/**
|
||||
* Return an array describing input filters (type, label, default, options) for UI.
|
||||
* Example item: ['key' => 'from', 'type' => 'date', 'label' => 'Od', 'default' => today()]
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function inputs(): array;
|
||||
|
||||
/**
|
||||
* Return column definitions for the table and exports.
|
||||
* Example: [ ['key' => 'id', 'label' => '#'], ['key' => 'user', 'label' => 'Uporabnik'] ]
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function columns(): array;
|
||||
|
||||
/**
|
||||
* Build the data source query for the report based on validated filters.
|
||||
* Should return an Eloquent or Query builder.
|
||||
*
|
||||
* @param array<string, mixed> $filters
|
||||
* @return EloquentBuilder|QueryBuilder
|
||||
*/
|
||||
public function query(array $filters);
|
||||
|
||||
/**
|
||||
* Optional per-report authorization logic.
|
||||
*/
|
||||
public function authorize(Request $request): void;
|
||||
|
||||
/**
|
||||
* Execute the report and return a paginator for UI.
|
||||
*
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Reports\Contracts\Report;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class DecisionsCountReport extends BaseEloquentReport implements Report
|
||||
{
|
||||
public function slug(): string
|
||||
{
|
||||
return 'decisions-counts';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Odločitve – štetje';
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return 'Število aktivnosti po odločitvah v izbranem obdobju.';
|
||||
}
|
||||
|
||||
public function inputs(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
|
||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'decision_name', 'label' => 'Odločitev'],
|
||||
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
return Activity::query()
|
||||
->leftJoin('decisions', 'activities.decision_id', '=', 'decisions.id')
|
||||
->when(!empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
|
||||
->when(!empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
|
||||
->groupBy('decisions.name')
|
||||
->selectRaw("COALESCE(decisions.name, '—') as decision_name, COUNT(*) as activities_count");
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Models\FieldJob;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
|
||||
class FieldJobsCompletedReport extends BaseEloquentReport
|
||||
{
|
||||
public function slug(): string
|
||||
{
|
||||
return 'field-jobs-completed';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Zaključeni tereni';
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return 'Pregled zaključenih terenov po datumu in uporabniku.';
|
||||
}
|
||||
|
||||
public function inputs(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'default' => now()->startOfMonth()->toDateString()],
|
||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'default' => now()->toDateString()],
|
||||
['key' => 'user_id', 'type' => 'select:user', 'label' => 'Uporabnik', 'default' => null],
|
||||
];
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'id', 'label' => '#'],
|
||||
['key' => 'contract_reference', 'label' => 'Pogodba'],
|
||||
['key' => 'assigned_user_name', 'label' => 'Terenski'],
|
||||
['key' => 'completed_at', 'label' => 'Zaključeno'],
|
||||
['key' => 'notes', 'label' => 'Opombe'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function query(array $filters): EloquentBuilder
|
||||
{
|
||||
$from = isset($filters['from']) ? now()->parse($filters['from'])->startOfDay() : now()->startOfMonth();
|
||||
$to = isset($filters['to']) ? now()->parse($filters['to'])->endOfDay() : now()->endOfDay();
|
||||
|
||||
return FieldJob::query()
|
||||
->whereNull('cancelled_at')
|
||||
->whereBetween('completed_at', [$from, $to])
|
||||
->when(! empty($filters['user_id']), fn ($q) => $q->where('assigned_user_id', $filters['user_id']))
|
||||
->with(['assignedUser:id,name', 'contract:id,reference'])
|
||||
->select(['id', 'assigned_user_id', 'contract_id', 'completed_at', 'notes']);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Reports\Contracts\Report;
|
||||
|
||||
class ReportRegistry
|
||||
{
|
||||
/** @var array<string, Report> */
|
||||
protected array $reports = [];
|
||||
|
||||
public function register(Report $report): void
|
||||
{
|
||||
$this->reports[$report->slug()] = $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Report>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->reports;
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?Report
|
||||
{
|
||||
return $this->reports[$slug] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Reports\Contracts\Report;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class SegmentActivityCountsReport extends BaseEloquentReport implements Report
|
||||
{
|
||||
public function slug(): string
|
||||
{
|
||||
return 'segment-activity-counts';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Aktivnosti po segmentih';
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return 'Število aktivnosti po segmentih v izbranem obdobju (glede na segment dejanja).';
|
||||
}
|
||||
|
||||
public function inputs(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
|
||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'segment_name', 'label' => 'Segment'],
|
||||
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
$q = Activity::query()
|
||||
->join('actions', 'activities.action_id', '=', 'actions.id')
|
||||
->leftJoin('segments', 'actions.segment_id', '=', 'segments.id')
|
||||
->when(! empty($filters['from']), fn ($qq) => $qq->whereDate('activities.created_at', '>=', $filters['from']))
|
||||
->when(! empty($filters['to']), fn ($qq) => $qq->whereDate('activities.created_at', '<=', $filters['to']))
|
||||
->groupBy('segments.name')
|
||||
->selectRaw("COALESCE(segments.name, 'Brez segmenta') as segment_name, COUNT(*) as activities_count");
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Documents;
|
||||
|
||||
use App\Models\Document;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class DocumentStreamService
|
||||
{
|
||||
/**
|
||||
* Stream a document either inline or as attachment with all Windows/public fallbacks.
|
||||
*/
|
||||
public function stream(Document $document, bool $inline = true): StreamedResponse|Response
|
||||
{
|
||||
$disk = $document->disk ?: 'public';
|
||||
$relPath = $this->normalizePath($document->path ?? '');
|
||||
|
||||
// Handle DOC/DOCX previews for inline viewing
|
||||
if ($inline) {
|
||||
$previewResponse = $this->tryPreview($document);
|
||||
if ($previewResponse) {
|
||||
return $previewResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find the file using multiple path candidates
|
||||
$found = $this->findFile($disk, $relPath);
|
||||
|
||||
if (! $found) {
|
||||
// Try public/ fallback
|
||||
$found = $this->tryPublicFallback($relPath);
|
||||
if (! $found) {
|
||||
abort(404, 'Document file not found');
|
||||
}
|
||||
}
|
||||
|
||||
$headers = $this->buildHeaders($document, $inline);
|
||||
|
||||
// Try streaming first
|
||||
$stream = Storage::disk($disk)->readStream($found);
|
||||
if ($stream !== false) {
|
||||
return response()->stream(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
}, 200, $headers);
|
||||
}
|
||||
|
||||
// Fallbacks on readStream failure
|
||||
return $this->fallbackStream($disk, $found, $document, $relPath, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path for Windows and legacy prefixes.
|
||||
*/
|
||||
protected function normalizePath(string $path): string
|
||||
{
|
||||
$path = str_replace('\\', '/', $path);
|
||||
$path = ltrim($path, '/');
|
||||
if (str_starts_with($path, 'public/')) {
|
||||
$path = substr($path, 7);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build path candidates to try.
|
||||
*/
|
||||
protected function buildPathCandidates(string $relPath, ?string $documentPath): array
|
||||
{
|
||||
$candidates = [$relPath];
|
||||
$raw = $documentPath ? ltrim(str_replace('\\', '/', $documentPath), '/') : null;
|
||||
|
||||
if ($raw && $raw !== $relPath) {
|
||||
$candidates[] = $raw;
|
||||
}
|
||||
if (str_starts_with($relPath, 'storage/')) {
|
||||
$candidates[] = substr($relPath, 8);
|
||||
}
|
||||
if ($raw && str_starts_with($raw, 'storage/')) {
|
||||
$candidates[] = substr($raw, 8);
|
||||
}
|
||||
|
||||
return array_unique($candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find file using path candidates.
|
||||
*/
|
||||
protected function findFile(string $disk, string $relPath, ?string $documentPath = null): ?string
|
||||
{
|
||||
$candidates = $this->buildPathCandidates($relPath, $documentPath);
|
||||
|
||||
foreach ($candidates as $cand) {
|
||||
if (Storage::disk($disk)->exists($cand)) {
|
||||
return $cand;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try public/ fallback path.
|
||||
*/
|
||||
protected function tryPublicFallback(string $relPath): ?string
|
||||
{
|
||||
$publicFull = public_path($relPath);
|
||||
$real = @realpath($publicFull);
|
||||
$publicRoot = @realpath(public_path());
|
||||
$realN = $real ? str_replace('\\\\', '/', $real) : null;
|
||||
$rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null;
|
||||
|
||||
if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) {
|
||||
return $real;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to stream preview for DOC/DOCX files.
|
||||
*/
|
||||
protected function tryPreview(Document $document): StreamedResponse|Response|null
|
||||
{
|
||||
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
|
||||
if (! in_array($ext, ['doc', 'docx'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$previewDisk = config('files.preview_disk', 'public');
|
||||
if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) {
|
||||
$stream = Storage::disk($previewDisk)->readStream($document->preview_path);
|
||||
if ($stream !== false) {
|
||||
$previewNameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
|
||||
|
||||
return response()->stream(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
}, 200, [
|
||||
'Content-Type' => $document->preview_mime ?: 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.addslashes($previewNameBase.'.pdf').'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Queue preview generation if not available
|
||||
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
|
||||
|
||||
return response('Preview is being generated. Please try again shortly.', 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build response headers.
|
||||
*/
|
||||
protected function buildHeaders(Document $document, bool $inline): array
|
||||
{
|
||||
$nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
|
||||
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
|
||||
$name = $ext ? ($nameBase.'.'.$ext) : $nameBase;
|
||||
|
||||
return [
|
||||
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
|
||||
'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($name).'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback streaming methods when readStream fails.
|
||||
*/
|
||||
protected function fallbackStream(string $disk, string $found, Document $document, string $relPath, array $headers): StreamedResponse|Response
|
||||
{
|
||||
// Fallback 1: get() the bytes directly
|
||||
try {
|
||||
$bytes = Storage::disk($disk)->get($found);
|
||||
if (! is_null($bytes) && $bytes !== false) {
|
||||
return response($bytes, 200, $headers);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Continue to next fallback
|
||||
}
|
||||
|
||||
// Fallback 2: open via absolute storage path
|
||||
$abs = null;
|
||||
try {
|
||||
if (method_exists(Storage::disk($disk), 'path')) {
|
||||
$abs = Storage::disk($disk)->path($found);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$abs = null;
|
||||
}
|
||||
|
||||
if ($abs && is_file($abs)) {
|
||||
$fp = @fopen($abs, 'rb');
|
||||
if ($fp !== false) {
|
||||
return response()->stream(function () use ($fp) {
|
||||
fpassthru($fp);
|
||||
}, 200, $headers);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 3: serve from public path if available
|
||||
$publicFull = public_path($found);
|
||||
$real = @realpath($publicFull);
|
||||
if ($real && is_file($real)) {
|
||||
$fp = @fopen($real, 'rb');
|
||||
if ($fp !== false) {
|
||||
return response()->stream(function () use ($fp) {
|
||||
fpassthru($fp);
|
||||
}, 200, $headers);
|
||||
}
|
||||
}
|
||||
|
||||
abort(404, 'Document file could not be streamed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2301,10 +2301,31 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
||||
|
||||
return ['action' => 'skipped_history', 'contract' => $existing, 'message' => 'Existing contract left unchanged (history import)'];
|
||||
}
|
||||
|
||||
// Check if contract is soft-deleted and needs reactivation
|
||||
$isTrashed = $existing->trashed();
|
||||
|
||||
// 1) Prepare contract field changes (non-null)
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
|
||||
// 2) Prepare meta changes if provided via mapping
|
||||
// 2) Handle reactivation defaults when contract is soft-deleted
|
||||
if ($isTrashed || $existing->active == 0) {
|
||||
// Check if start_date is in the mappings
|
||||
$hasStartDateMapping = $this->mappingIncludes($mappings, 'contract.start_date');
|
||||
if (!$hasStartDateMapping) {
|
||||
// Default to current date when not in mappings
|
||||
$changes['start_date'] = now()->toDateString();
|
||||
}
|
||||
|
||||
// Check if end_date is in the mappings
|
||||
$hasEndDateMapping = $this->mappingIncludes($mappings, 'contract.end_date');
|
||||
if (!$hasEndDateMapping) {
|
||||
// Default to null when not in mappings
|
||||
$changes['end_date'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Prepare meta changes if provided via mapping
|
||||
$metaUpdated = false;
|
||||
$metaAppliedKeys = [];
|
||||
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
|
||||
@@ -2347,7 +2368,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($changes) && ! $metaUpdated) {
|
||||
if (empty($changes) && ! $metaUpdated && ! $isTrashed) {
|
||||
// Nothing to change
|
||||
return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing];
|
||||
}
|
||||
@@ -2355,6 +2376,12 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
||||
if (! empty($changes)) {
|
||||
$existing->fill($changes);
|
||||
}
|
||||
|
||||
// Restore soft-deleted contract if it was trashed
|
||||
if ($isTrashed) {
|
||||
$existing->restore();
|
||||
}
|
||||
|
||||
$existing->save();
|
||||
|
||||
// Build applied fields info, include meta keys if any
|
||||
@@ -2365,7 +2392,9 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
||||
}
|
||||
}
|
||||
|
||||
return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $applied];
|
||||
$actionType = $isTrashed ? 'reactivated' : 'updated';
|
||||
|
||||
return ['action' => $actionType, 'contract' => $existing, 'applied_fields' => $applied];
|
||||
} else {
|
||||
if (empty($applyInsert)) {
|
||||
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AccountType;
|
||||
use App\Models\ContractType;
|
||||
use App\Models\Person\AddressType;
|
||||
use App\Models\Person\PhoneType;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ReferenceDataCache
|
||||
{
|
||||
private const TTL = 3600; // 1 hour
|
||||
|
||||
public function getAddressTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:address_types', self::TTL, fn () => AddressType::all());
|
||||
}
|
||||
|
||||
public function getPhoneTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:phone_types', self::TTL, fn () => PhoneType::all());
|
||||
}
|
||||
|
||||
public function getAccountTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:account_types', self::TTL, fn () => AccountType::all());
|
||||
}
|
||||
|
||||
public function getContractTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:contract_types', self::TTL, fn () => ContractType::whereNull('deleted_at')->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all reference data cache.
|
||||
*/
|
||||
public function clearAll(): void
|
||||
{
|
||||
Cache::forget('reference_data:address_types');
|
||||
Cache::forget('reference_data:phone_types');
|
||||
Cache::forget('reference_data:account_types');
|
||||
Cache::forget('reference_data:contract_types');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear specific reference data cache.
|
||||
*/
|
||||
public function clear(string $type): void
|
||||
{
|
||||
Cache::forget("reference_data:{$type}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all types as an array for convenience.
|
||||
*/
|
||||
public function getAllTypes(): array
|
||||
{
|
||||
return [
|
||||
'address_types' => $this->getAddressTypes(),
|
||||
'phone_types' => $this->getPhoneTypes(),
|
||||
'account_types' => $this->getAccountTypes(),
|
||||
'contract_types' => $this->getContractTypes(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,5 +5,4 @@
|
||||
App\Providers\AuthServiceProvider::class,
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
App\Providers\JetstreamServiceProvider::class,
|
||||
App\Providers\ReportServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": false,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "resources/css/app.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/Components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/Components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"arielmejiadev/larapex-charts": "^2.1",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"diglactic/laravel-breadcrumbs": "^10.0",
|
||||
"http-interop/http-factory-guzzle": "^1.2",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
|
||||
Generated
+8
-373
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "d28e6760b713feea1c4ad6058f96287a",
|
||||
"content-hash": "d29c47a4d6813ee8e80a7c8112c2f17e",
|
||||
"packages": [
|
||||
{
|
||||
"name": "arielmejiadev/larapex-charts",
|
||||
@@ -113,83 +113,6 @@
|
||||
},
|
||||
"time": "2024-10-01T13:55:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "barryvdh/laravel-dompdf",
|
||||
"version": "v3.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/barryvdh/laravel-dompdf.git",
|
||||
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
|
||||
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/dompdf": "^3.0",
|
||||
"illuminate/support": "^9|^10|^11|^12",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^2.7|^3.0",
|
||||
"orchestra/testbench": "^7|^8|^9|^10",
|
||||
"phpro/grumphp": "^2.5",
|
||||
"squizlabs/php_codesniffer": "^3.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
|
||||
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
|
||||
},
|
||||
"providers": [
|
||||
"Barryvdh\\DomPDF\\ServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Barryvdh\\DomPDF\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Barry vd. Heuvel",
|
||||
"email": "barryvdh@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A DOMPDF Wrapper for Laravel",
|
||||
"keywords": [
|
||||
"dompdf",
|
||||
"laravel",
|
||||
"pdf"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
|
||||
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://fruitcake.nl",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/barryvdh",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-02-13T15:07:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.12.3",
|
||||
@@ -838,161 +761,6 @@
|
||||
],
|
||||
"time": "2024-02-05T11:56:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/dompdf",
|
||||
"version": "v3.1.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/dompdf.git",
|
||||
"reference": "db712c90c5b9868df3600e64e68da62e78a34623"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623",
|
||||
"reference": "db712c90c5b9868df3600e64e68da62e78a34623",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/php-font-lib": "^1.0.0",
|
||||
"dompdf/php-svg-lib": "^1.0.0",
|
||||
"ext-dom": "*",
|
||||
"ext-mbstring": "*",
|
||||
"masterminds/html5": "^2.0",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"mockery/mockery": "^1.3",
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Needed to process images",
|
||||
"ext-gmagick": "Improves image processing performance",
|
||||
"ext-imagick": "Improves image processing performance",
|
||||
"ext-zlib": "Needed for pdf stream compression"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dompdf\\": "src/"
|
||||
},
|
||||
"classmap": [
|
||||
"lib/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The Dompdf Community",
|
||||
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
|
||||
"homepage": "https://github.com/dompdf/dompdf",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/dompdf/issues",
|
||||
"source": "https://github.com/dompdf/dompdf/tree/v3.1.4"
|
||||
},
|
||||
"time": "2025-10-29T12:43:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-font-lib",
|
||||
"version": "1.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-font-lib.git",
|
||||
"reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
|
||||
"reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FontLib\\": "src/FontLib"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The FontLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse, export and make subsets of different types of font files.",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-font-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.1"
|
||||
},
|
||||
"time": "2024-12-02T14:37:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-svg-lib",
|
||||
"version": "1.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-svg-lib.git",
|
||||
"reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
|
||||
"reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabberworm/php-css-parser": "^8.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Svg\\": "src/Svg"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The SvgLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse and export to PDF SVG files.",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-svg-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0"
|
||||
},
|
||||
"time": "2024-04-29T13:26:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dragonmantank/cron-expression",
|
||||
"version": "v3.4.0",
|
||||
@@ -3227,16 +2995,16 @@
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "3.2.0",
|
||||
"version": "3.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416"
|
||||
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
|
||||
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3247,7 +3015,7 @@
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.7",
|
||||
"ext-zip": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.16",
|
||||
"friendsofphp/php-cs-fixer": "^3.86",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"php-coveralls/php-coveralls": "^2.5",
|
||||
@@ -3293,7 +3061,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0"
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3301,7 +3069,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-07-17T11:15:13+00:00"
|
||||
"time": "2025-12-10T09:58:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/complex",
|
||||
@@ -3410,73 +3178,6 @@
|
||||
},
|
||||
"time": "2022-12-02T22:17:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Masterminds/html5-php.git",
|
||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
|
||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"php": ">=5.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.7-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Masterminds\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Butcher",
|
||||
"email": "technosophos@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Matt Farina",
|
||||
"email": "matt@mattfarina.com"
|
||||
},
|
||||
{
|
||||
"name": "Asmir Mustafic",
|
||||
"email": "goetas@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "An HTML5 parser and serializer.",
|
||||
"homepage": "http://masterminds.github.io/html5-php",
|
||||
"keywords": [
|
||||
"HTML5",
|
||||
"dom",
|
||||
"html",
|
||||
"parser",
|
||||
"querypath",
|
||||
"serializer",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
|
||||
},
|
||||
"time": "2025-07-25T09:04:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "meilisearch/meilisearch-php",
|
||||
"version": "v1.13.0",
|
||||
@@ -5330,72 +5031,6 @@
|
||||
],
|
||||
"time": "2025-02-28T15:16:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabberworm/php-css-parser",
|
||||
"version": "v8.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||
"reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9",
|
||||
"reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41",
|
||||
"rawr/cross-data-providers": "^2.0.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "9.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Sabberworm\\CSS\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Raphael Schweikert"
|
||||
},
|
||||
{
|
||||
"name": "Oliver Klee",
|
||||
"email": "github@oliverklee.de"
|
||||
},
|
||||
{
|
||||
"name": "Jake Hotson",
|
||||
"email": "jake.github@qzdesign.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "Parser for CSS Files written in PHP",
|
||||
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
|
||||
"keywords": [
|
||||
"css",
|
||||
"parser",
|
||||
"stylesheet"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
||||
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0"
|
||||
},
|
||||
"time": "2025-07-11T13:20:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-package-tools",
|
||||
"version": "1.92.0",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Optionally list Postgres materialized view names to refresh on schedule
|
||||
'materialized_views' => [
|
||||
// e.g., 'mv_activities_daily', 'mv_segment_activity_counts'
|
||||
],
|
||||
// Time for scheduled refresh (24h format HH:MM)
|
||||
'refresh_time' => '03:00',
|
||||
];
|
||||
@@ -1,143 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Contracts table indexes
|
||||
Schema::table('contracts', function (Blueprint $table) {
|
||||
if (! $this->indexExists('contracts', 'contracts_client_case_id_active_deleted_at_index')) {
|
||||
$table->index(['client_case_id', 'active', 'deleted_at'], 'contracts_client_case_id_active_deleted_at_index');
|
||||
}
|
||||
if (! $this->indexExists('contracts', 'contracts_start_date_end_date_index')) {
|
||||
$table->index(['start_date', 'end_date'], 'contracts_start_date_end_date_index');
|
||||
}
|
||||
});
|
||||
|
||||
// Contract segment pivot table indexes
|
||||
Schema::table('contract_segment', function (Blueprint $table) {
|
||||
if (! $this->indexExists('contract_segment', 'contract_segment_contract_id_active_index')) {
|
||||
$table->index(['contract_id', 'active'], 'contract_segment_contract_id_active_index');
|
||||
}
|
||||
if (! $this->indexExists('contract_segment', 'contract_segment_segment_id_active_index')) {
|
||||
$table->index(['segment_id', 'active'], 'contract_segment_segment_id_active_index');
|
||||
}
|
||||
});
|
||||
|
||||
// Activities table indexes
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
if (! $this->indexExists('activities', 'activities_client_case_id_created_at_index')) {
|
||||
$table->index(['client_case_id', 'created_at'], 'activities_client_case_id_created_at_index');
|
||||
}
|
||||
if (! $this->indexExists('activities', 'activities_contract_id_created_at_index')) {
|
||||
$table->index(['contract_id', 'created_at'], 'activities_contract_id_created_at_index');
|
||||
}
|
||||
});
|
||||
|
||||
// Client cases table indexes
|
||||
Schema::table('client_cases', function (Blueprint $table) {
|
||||
if (! $this->indexExists('client_cases', 'client_cases_client_id_active_index')) {
|
||||
$table->index(['client_id', 'active'], 'client_cases_client_id_active_index');
|
||||
}
|
||||
if (! $this->indexExists('client_cases', 'client_cases_person_id_active_index')) {
|
||||
$table->index(['person_id', 'active'], 'client_cases_person_id_active_index');
|
||||
}
|
||||
});
|
||||
|
||||
// Documents table indexes for polymorphic relations
|
||||
Schema::table('documents', function (Blueprint $table) {
|
||||
if (! $this->indexExists('documents', 'documents_documentable_type_documentable_id_index')) {
|
||||
$table->index(['documentable_type', 'documentable_id'], 'documents_documentable_type_documentable_id_index');
|
||||
}
|
||||
if (! $this->indexExists('documents', 'documents_created_at_index')) {
|
||||
$table->index(['created_at'], 'documents_created_at_index');
|
||||
}
|
||||
});
|
||||
|
||||
// Field jobs indexes
|
||||
Schema::table('field_jobs', function (Blueprint $table) {
|
||||
if (! $this->indexExists('field_jobs', 'field_jobs_assigned_user_id_index')) {
|
||||
$table->index(['assigned_user_id'], 'field_jobs_assigned_user_id_index');
|
||||
}
|
||||
if (! $this->indexExists('field_jobs', 'field_jobs_contract_id_index')) {
|
||||
$table->index(['contract_id'], 'field_jobs_contract_id_index');
|
||||
}
|
||||
if (! $this->indexExists('field_jobs', 'field_jobs_completed_at_index')) {
|
||||
$table->index(['completed_at'], 'field_jobs_completed_at_index');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('contracts', function (Blueprint $table) {
|
||||
$table->dropIndex('contracts_client_case_id_active_deleted_at_index');
|
||||
$table->dropIndex('contracts_start_date_end_date_index');
|
||||
});
|
||||
|
||||
Schema::table('contract_segment', function (Blueprint $table) {
|
||||
$table->dropIndex('contract_segment_contract_id_active_index');
|
||||
$table->dropIndex('contract_segment_segment_id_active_index');
|
||||
});
|
||||
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
$table->dropIndex('activities_client_case_id_created_at_index');
|
||||
$table->dropIndex('activities_contract_id_created_at_index');
|
||||
});
|
||||
|
||||
Schema::table('client_cases', function (Blueprint $table) {
|
||||
$table->dropIndex('client_cases_client_id_active_index');
|
||||
$table->dropIndex('client_cases_person_id_active_index');
|
||||
});
|
||||
|
||||
Schema::table('documents', function (Blueprint $table) {
|
||||
$table->dropIndex('documents_documentable_type_documentable_id_index');
|
||||
$table->dropIndex('documents_created_at_index');
|
||||
});
|
||||
|
||||
Schema::table('field_jobs', function (Blueprint $table) {
|
||||
$table->dropIndex('field_jobs_assigned_user_id_index');
|
||||
$table->dropIndex('field_jobs_contract_id_index');
|
||||
$table->dropIndex('field_jobs_completed_at_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an index exists on a table.
|
||||
*/
|
||||
protected function indexExists(string $table, string $index): bool
|
||||
{
|
||||
$connection = Schema::getConnection();
|
||||
$driver = $connection->getDriverName();
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
// PostgreSQL uses pg_indexes system catalog
|
||||
$result = $connection->select(
|
||||
"SELECT COUNT(*) as count FROM pg_indexes
|
||||
WHERE schemaname = 'public' AND tablename = ? AND indexname = ?",
|
||||
[$table, $index]
|
||||
);
|
||||
} else {
|
||||
// MySQL/MariaDB uses information_schema.statistics
|
||||
$databaseName = $connection->getDatabaseName();
|
||||
$result = $connection->select(
|
||||
"SELECT COUNT(*) as count FROM information_schema.statistics
|
||||
WHERE table_schema = ? AND table_name = ? AND index_name = ?",
|
||||
[$databaseName, $table, $index]
|
||||
);
|
||||
}
|
||||
|
||||
return $result[0]->count > 0;
|
||||
}
|
||||
};
|
||||
|
||||
Generated
+1646
-3190
File diff suppressed because it is too large
Load Diff
+28
-45
@@ -3,63 +3,46 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inertiajs/vue3": "2.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^24.10.3",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"axios": "^1.13.2",
|
||||
"laravel-vite-plugin": "^2.0.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"vue": "^3.3.13",
|
||||
"vue-tsc": "^3.1.8"
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"axios": "^1.7.4",
|
||||
"laravel-vite-plugin": "^2.0.1",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^7.1.7",
|
||||
"vue": "^3.3.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.1.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||
"quill": "^1.3.7",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@unovis/ts": "^1.6.2",
|
||||
"@unovis/vue": "^1.6.2",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vuepic/vue-datepicker": "^11.0.3",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"apexcharts": "^4.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@internationalized/date": "^3.9.0",
|
||||
"@vuepic/vue-datepicker": "^11.0.2",
|
||||
"apexcharts": "^4.0.0",
|
||||
"flowbite": "^2.5.2",
|
||||
"flowbite-vue": "^0.1.6",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-vue-next": "^0.552.0",
|
||||
"material-design-icons-iconfont": "^6.7.0",
|
||||
"preline": "^2.7.0",
|
||||
"quill": "^1.3.7",
|
||||
"reka-ui": "^2.6.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"reka-ui": "^2.5.1",
|
||||
"tailwindcss-inner-border": "^0.2.0",
|
||||
"v-calendar": "^3.1.2",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vue-currency-input": "^3.2.1",
|
||||
"vue-multiselect": "^3.4.0",
|
||||
"vue-search-input": "^1.1.19",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"vue3-apexcharts": "^1.10.0",
|
||||
"vue-multiselect": "^3.1.0",
|
||||
"vue-search-input": "^1.1.16",
|
||||
"vue3-apexcharts": "^1.7.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"zod": "^3.25.76"
|
||||
"vue-currency-input": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-7
@@ -1,9 +1,6 @@
|
||||
import tailwindcss from '@tailwindcss/postcss';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
autoprefixer(),
|
||||
],
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
+6
-263
@@ -1,143 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
@import "./themes.css";
|
||||
@import '/node_modules/floating-vue/dist/style.css';
|
||||
@import '/node_modules/vue-search-input/dist/styles.css';
|
||||
@import '/node_modules/vue-multiselect/dist/vue-multiselect.min.css';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
/* Disable dark mode */
|
||||
--default-transition-duration: 150ms;
|
||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Font Family */
|
||||
--font-family-sans: 'Figtree', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
|
||||
/* Primary brand colors */
|
||||
--color-primary-50: #eef2ff;
|
||||
--color-primary-100: #e0e7ff;
|
||||
--color-primary-200: #c7d2fe;
|
||||
--color-primary-300: #a5b4fc;
|
||||
--color-primary-400: #818cf8;
|
||||
--color-primary-500: #6366f1;
|
||||
--color-primary-600: #4f46e5;
|
||||
--color-primary-700: #4338ca;
|
||||
--color-primary-800: #3730a3;
|
||||
--color-primary-900: #312e81;
|
||||
--color-primary-950: #1e1b4b;
|
||||
|
||||
/* Semantic colors - Success */
|
||||
--color-success-50: #f0fdf4;
|
||||
--color-success-100: #dcfce7;
|
||||
--color-success-200: #bbf7d0;
|
||||
--color-success-300: #86efac;
|
||||
--color-success-400: #4ade80;
|
||||
--color-success-500: #22c55e;
|
||||
--color-success-600: #16a34a;
|
||||
--color-success-700: #15803d;
|
||||
--color-success-800: #166534;
|
||||
--color-success-900: #14532d;
|
||||
|
||||
/* Semantic colors - Warning */
|
||||
--color-warning-50: #fffbeb;
|
||||
--color-warning-100: #fef3c7;
|
||||
--color-warning-200: #fde68a;
|
||||
--color-warning-300: #fcd34d;
|
||||
--color-warning-400: #fbbf24;
|
||||
--color-warning-500: #f59e0b;
|
||||
--color-warning-600: #d97706;
|
||||
--color-warning-700: #b45309;
|
||||
--color-warning-800: #92400e;
|
||||
--color-warning-900: #78350f;
|
||||
|
||||
/* Semantic colors - Error */
|
||||
--color-error-50: #fef2f2;
|
||||
--color-error-100: #fee2e2;
|
||||
--color-error-200: #fecaca;
|
||||
--color-error-300: #fca5a5;
|
||||
--color-error-400: #f87171;
|
||||
--color-error-500: #ef4444;
|
||||
--color-error-600: #dc2626;
|
||||
--color-error-700: #b91c1c;
|
||||
--color-error-800: #991b1b;
|
||||
--color-error-900: #7f1d1d;
|
||||
|
||||
/* Semantic colors - Info */
|
||||
--color-info-50: #eff6ff;
|
||||
--color-info-100: #dbeafe;
|
||||
--color-info-200: #bfdbfe;
|
||||
--color-info-300: #93c5fd;
|
||||
--color-info-400: #60a5fa;
|
||||
--color-info-500: #3b82f6;
|
||||
--color-info-600: #2563eb;
|
||||
--color-info-700: #1d4ed8;
|
||||
--color-info-800: #1e40af;
|
||||
--color-info-900: #1e3a8a;
|
||||
|
||||
/* Neutral grays */
|
||||
--color-neutral-50: #f9fafb;
|
||||
--color-neutral-100: #f3f4f6;
|
||||
--color-neutral-200: #e5e7eb;
|
||||
--color-neutral-300: #d1d5db;
|
||||
--color-neutral-400: #9ca3af;
|
||||
--color-neutral-500: #6b7280;
|
||||
--color-neutral-600: #4b5563;
|
||||
--color-neutral-700: #374151;
|
||||
--color-neutral-800: #1f2937;
|
||||
--color-neutral-900: #111827;
|
||||
|
||||
/* Spacing scale */
|
||||
--spacing-18: 4.5rem;
|
||||
--spacing-88: 22rem;
|
||||
--spacing-112: 28rem;
|
||||
--spacing-128: 32rem;
|
||||
|
||||
/* Border radius */
|
||||
--radius-4xl: 2rem;
|
||||
|
||||
/* Box shadows */
|
||||
--shadow-soft: 0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-medium: 0 4px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
--shadow-strong: 0 10px 40px -10px rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* Animations */
|
||||
--animate-fade-in: fade-in 0.2s ease-in-out;
|
||||
--animate-slide-up: slide-up 0.3s ease-out;
|
||||
--animate-slide-down: slide-down 0.3s ease-out;
|
||||
--animate-shimmer: shimmer 2s infinite linear;
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from {
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from { background-position: -1000px 0; }
|
||||
to { background-position: 1000px 0; }
|
||||
}
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none;
|
||||
@@ -145,127 +12,3 @@ [x-cloak] {
|
||||
|
||||
/* Ensure dropdowns/menus render above dialog overlays when appended to body */
|
||||
.multiselect__content-wrapper { z-index: 2147483647 !important; }
|
||||
|
||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||
/* @theme is a valid Tailwind CSS v4 at-rule */
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
button:not([disabled]),
|
||||
[role="button"]:not([disabled]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
.theme-default .theme-container,
|
||||
.theme-default [data-reka-popper-content-wrapper] {
|
||||
--chart-1: var(--color-blue-300);
|
||||
--chart-2: var(--color-blue-500);
|
||||
--chart-3: var(--color-blue-600);
|
||||
--chart-4: var(--color-blue-700);
|
||||
--chart-5: var(--color-blue-800);
|
||||
}
|
||||
|
||||
.theme-mono .theme-container,
|
||||
.theme-mono [data-reka-popper-content-wrapper] {
|
||||
--font-sans: var(--font-mono);
|
||||
--primary: var(--color-stone-600);
|
||||
--primary-foreground: var(--color-stone-50);
|
||||
--chart-1: var(--color-stone-300);
|
||||
--chart-2: var(--color-stone-500);
|
||||
--chart-3: var(--color-stone-600);
|
||||
--chart-4: var(--color-stone-700);
|
||||
--chart-5: var(--color-stone-800);
|
||||
--sidebar-primary: var(--color-stone-600);
|
||||
--sidebar-primary-foreground: var(--color-stone-50);
|
||||
--sidebar-ring: var(--color-stone-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-stone-500);
|
||||
--primary-foreground: var(--color-stone-50);
|
||||
--sidebar-primary: var(--color-stone-500);
|
||||
--sidebar-primary-foreground: var(--color-stone-50);
|
||||
--sidebar-ring: var(--color-stone-900);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
--font-sans: var(--font-mono);
|
||||
--radius: 0.45em;
|
||||
--text-lg: 1rem;
|
||||
--text-xl: 1.1rem;
|
||||
--text-2xl: 1.2rem;
|
||||
--text-3xl: 1.3rem;
|
||||
--text-4xl: 1.4rem;
|
||||
--text-5xl: 1.5rem;
|
||||
--text-6xl: 1.6rem;
|
||||
--text-7xl: 1.7rem;
|
||||
--text-8xl: 1.8rem;
|
||||
--text-base: 0.85rem;
|
||||
--text-sm: 0.8rem;
|
||||
--spacing: 0.222222rem;
|
||||
}
|
||||
|
||||
.rounded-xs,
|
||||
.rounded-sm,
|
||||
.rounded-md,
|
||||
.rounded-lg,
|
||||
.rounded-xl {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.shadow-xs,
|
||||
.shadow-sm,
|
||||
.shadow-md,
|
||||
.shadow-lg,
|
||||
.shadow-xl {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-slot="toggle-group"],
|
||||
[data-slot="toggle-group-item"],
|
||||
[data-slot="checkbox"],
|
||||
[data-slot="radio"],
|
||||
[data-slot="switch"],
|
||||
[data-slot="switch-thumb"] {
|
||||
@apply !rounded-none !shadow-none;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-scaled .theme-container,
|
||||
.theme-scaled [data-reka-popper-content-wrapper] {
|
||||
--chart-1: var(--color-blue-300);
|
||||
--chart-2: var(--color-blue-500);
|
||||
--chart-3: var(--color-blue-600);
|
||||
--chart-4: var(--color-blue-700);
|
||||
--chart-5: var(--color-blue-800);
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
--radius: 0.45em;
|
||||
--text-lg: 1rem;
|
||||
--text-xl: 1.1rem;
|
||||
--text-2xl: 1.2rem;
|
||||
--text-3xl: 1.3rem;
|
||||
--text-4xl: 1.4rem;
|
||||
--text-5xl: 1.5rem;
|
||||
--text-6xl: 1.6rem;
|
||||
--text-7xl: 1.7rem;
|
||||
--text-8xl: 1.8rem;
|
||||
--text-base: 0.85rem;
|
||||
--text-sm: 0.8rem;
|
||||
--spacing: 0.2rem;
|
||||
}
|
||||
|
||||
[data-slot="select-trigger"],
|
||||
[data-slot="toggle-group-item"] {
|
||||
--spacing: 0.2rem;
|
||||
}
|
||||
|
||||
[data-slot="card"] {
|
||||
border-radius: var(--radius);
|
||||
padding-block: calc(var(--spacing) * 4);
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
|
||||
[data-slot="card"].pb-0 {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-rounded-none .theme-container,
|
||||
.theme-rounded-none [data-reka-popper-content-wrapper] {
|
||||
--radius: 0;
|
||||
}
|
||||
|
||||
.theme-rounded-small .theme-container,
|
||||
.theme-rounded-small [data-reka-popper-content-wrapper] {
|
||||
--radius: 0.4rem;
|
||||
}
|
||||
|
||||
.theme-rounded-medium .theme-container,
|
||||
.theme-rounded-medium [data-reka-popper-content-wrapper] {
|
||||
--radius: 0.65rem;
|
||||
}
|
||||
|
||||
.theme-rounded-large .theme-container,
|
||||
.theme-rounded-large [data-reka-popper-content-wrapper] {
|
||||
--radius: 1rem;
|
||||
}
|
||||
|
||||
.theme-rounded-full .theme-container,
|
||||
.theme-rounded-full [data-reka-popper-content-wrapper] {
|
||||
--radius: 1.2rem;
|
||||
}
|
||||
|
||||
.theme-inter .theme-container,
|
||||
.theme-inter [data-reka-popper-content-wrapper] {
|
||||
--font-sans: var(--font-inter);
|
||||
}
|
||||
|
||||
.theme-noto-sans .theme-container,
|
||||
.theme-noto-sans [data-reka-popper-content-wrapper] {
|
||||
--font-sans: var(--font-noto-sans);
|
||||
}
|
||||
|
||||
.theme-nunito-sans .theme-container,
|
||||
.theme-nunito-sans [data-reka-popper-content-wrapper] {
|
||||
--font-sans: var(--font-nunito-sans);
|
||||
}
|
||||
|
||||
.theme-figtree .theme-container,
|
||||
.theme-figtree [data-reka-popper-content-wrapper] {
|
||||
--font-sans: var(--font-figtree);
|
||||
}
|
||||
|
||||
.theme-blue .theme-container,
|
||||
.theme-blue [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-blue-700);
|
||||
--primary-foreground: var(--color-blue-50);
|
||||
/* --ring: var(--color-blue-100); */
|
||||
--sidebar-primary: var(--color-blue-600);
|
||||
--sidebar-primary-foreground: var(--color-blue-50);
|
||||
/* --sidebar-ring: var(--color-blue-400); */
|
||||
--chart-1: var(--color-blue-300);
|
||||
--chart-2: var(--color-blue-500);
|
||||
--chart-3: var(--color-blue-600);
|
||||
--chart-4: var(--color-blue-700);
|
||||
--chart-5: var(--color-blue-800);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-blue-700);
|
||||
--primary-foreground: var(--color-blue-50);
|
||||
/* --ring: var(--color-blue-900); */
|
||||
--sidebar-primary: var(--color-blue-500);
|
||||
--sidebar-primary-foreground: var(--color-blue-50);
|
||||
/* --sidebar-ring: var(--color-blue-900); */
|
||||
}
|
||||
}
|
||||
|
||||
.theme-green .theme-container,
|
||||
.theme-green [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-lime-600);
|
||||
--primary-foreground: var(--color-lime-50);
|
||||
--ring: var(--color-lime-400);
|
||||
--chart-1: var(--color-green-300);
|
||||
--chart-2: var(--color-green-500);
|
||||
--chart-3: var(--color-green-600);
|
||||
--chart-4: var(--color-green-700);
|
||||
--chart-5: var(--color-green-800);
|
||||
--sidebar-primary: var(--color-lime-600);
|
||||
--sidebar-primary-foreground: var(--color-lime-50);
|
||||
--sidebar-ring: var(--color-lime-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-lime-600);
|
||||
--primary-foreground: var(--color-lime-50);
|
||||
--ring: var(--color-lime-900);
|
||||
--sidebar-primary: var(--color-lime-500);
|
||||
--sidebar-primary-foreground: var(--color-lime-50);
|
||||
--sidebar-ring: var(--color-lime-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-amber .theme-container,
|
||||
.theme-amber [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-amber-600);
|
||||
--primary-foreground: var(--color-amber-50);
|
||||
--ring: var(--color-amber-400);
|
||||
--chart-1: var(--color-amber-300);
|
||||
--chart-2: var(--color-amber-500);
|
||||
--chart-3: var(--color-amber-600);
|
||||
--chart-4: var(--color-amber-700);
|
||||
--chart-5: var(--color-amber-800);
|
||||
--sidebar-primary: var(--color-amber-600);
|
||||
--sidebar-primary-foreground: var(--color-amber-50);
|
||||
--sidebar-ring: var(--color-amber-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-amber-500);
|
||||
--primary-foreground: var(--color-amber-50);
|
||||
--ring: var(--color-amber-900);
|
||||
--sidebar-primary: var(--color-amber-500);
|
||||
--sidebar-primary-foreground: var(--color-amber-50);
|
||||
--sidebar-ring: var(--color-amber-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-rose .theme-container,
|
||||
.theme-rose [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-rose-600);
|
||||
--primary-foreground: var(--color-rose-50);
|
||||
--ring: var(--color-rose-400);
|
||||
--chart-1: var(--color-rose-300);
|
||||
--chart-2: var(--color-rose-500);
|
||||
--chart-3: var(--color-rose-600);
|
||||
--chart-4: var(--color-rose-700);
|
||||
--chart-5: var(--color-rose-800);
|
||||
--sidebar-primary: var(--color-rose-600);
|
||||
--sidebar-primary-foreground: var(--color-rose-50);
|
||||
--sidebar-ring: var(--color-rose-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-rose-500);
|
||||
--primary-foreground: var(--color-rose-50);
|
||||
--ring: var(--color-rose-900);
|
||||
--sidebar-primary: var(--color-rose-500);
|
||||
--sidebar-primary-foreground: var(--color-rose-50);
|
||||
--sidebar-ring: var(--color-rose-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-purple .theme-container,
|
||||
.theme-purple [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-purple-600);
|
||||
--primary-foreground: var(--color-purple-50);
|
||||
--ring: var(--color-purple-400);
|
||||
--chart-1: var(--color-purple-300);
|
||||
--chart-2: var(--color-purple-500);
|
||||
--chart-3: var(--color-purple-600);
|
||||
--chart-4: var(--color-purple-700);
|
||||
--chart-5: var(--color-purple-800);
|
||||
--sidebar-primary: var(--color-purple-600);
|
||||
--sidebar-primary-foreground: var(--color-purple-50);
|
||||
--sidebar-ring: var(--color-purple-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-purple-500);
|
||||
--primary-foreground: var(--color-purple-50);
|
||||
--ring: var(--color-purple-900);
|
||||
--sidebar-primary: var(--color-purple-500);
|
||||
--sidebar-primary-foreground: var(--color-purple-50);
|
||||
--sidebar-ring: var(--color-purple-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-orange .theme-container,
|
||||
.theme-orange [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-orange-600);
|
||||
--primary-foreground: var(--color-orange-50);
|
||||
--ring: var(--color-orange-400);
|
||||
--chart-1: var(--color-orange-300);
|
||||
--chart-2: var(--color-orange-500);
|
||||
--chart-3: var(--color-orange-600);
|
||||
--chart-4: var(--color-orange-700);
|
||||
--chart-5: var(--color-orange-800);
|
||||
--sidebar-primary: var(--color-orange-600);
|
||||
--sidebar-primary-foreground: var(--color-orange-50);
|
||||
--sidebar-ring: var(--color-orange-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-orange-500);
|
||||
--primary-foreground: var(--color-orange-50);
|
||||
--ring: var(--color-orange-900);
|
||||
--sidebar-primary: var(--color-orange-500);
|
||||
--sidebar-primary-foreground: var(--color-orange-50);
|
||||
--sidebar-ring: var(--color-orange-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-teal .theme-container,
|
||||
.theme-teal [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-teal-600);
|
||||
--primary-foreground: var(--color-teal-50);
|
||||
--chart-1: var(--color-teal-300);
|
||||
--chart-2: var(--color-teal-500);
|
||||
--chart-3: var(--color-teal-600);
|
||||
--chart-4: var(--color-teal-700);
|
||||
--chart-5: var(--color-teal-800);
|
||||
--sidebar-primary: var(--color-teal-600);
|
||||
--sidebar-primary-foreground: var(--color-teal-50);
|
||||
--sidebar-ring: var(--color-teal-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-teal-500);
|
||||
--primary-foreground: var(--color-teal-50);
|
||||
--sidebar-primary: var(--color-teal-500);
|
||||
--sidebar-primary-foreground: var(--color-teal-50);
|
||||
--sidebar-ring: var(--color-teal-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-red .theme-container,
|
||||
.theme-red [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-red-600);
|
||||
--primary-foreground: var(--color-red-50);
|
||||
--ring: var(--color-red-400);
|
||||
--chart-1: var(--color-red-300);
|
||||
--chart-2: var(--color-red-500);
|
||||
--chart-3: var(--color-red-600);
|
||||
--chart-4: var(--color-red-700);
|
||||
--chart-5: var(--color-red-800);
|
||||
--sidebar-primary: var(--color-red-600);
|
||||
--sidebar-primary-foreground: var(--color-red-50);
|
||||
--sidebar-ring: var(--color-red-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-red-500);
|
||||
--primary-foreground: var(--color-red-50);
|
||||
--ring: var(--color-red-900);
|
||||
--sidebar-primary: var(--color-red-500);
|
||||
--sidebar-primary-foreground: var(--color-red-50);
|
||||
--sidebar-ring: var(--color-red-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-yellow .theme-container,
|
||||
.theme-yellow [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-yellow-400);
|
||||
--primary-foreground: var(--color-yellow-900);
|
||||
--ring: var(--color-yellow-400);
|
||||
--chart-1: var(--color-yellow-300);
|
||||
--chart-2: var(--color-yellow-500);
|
||||
--chart-3: var(--color-yellow-600);
|
||||
--chart-4: var(--color-yellow-700);
|
||||
--chart-5: var(--color-yellow-800);
|
||||
--sidebar-primary: var(--color-yellow-600);
|
||||
--sidebar-primary-foreground: var(--color-yellow-50);
|
||||
--sidebar-ring: var(--color-yellow-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-yellow-500);
|
||||
--primary-foreground: var(--color-yellow-900);
|
||||
--ring: var(--color-yellow-900);
|
||||
--sidebar-primary: var(--color-yellow-500);
|
||||
--sidebar-primary-foreground: var(--color-yellow-50);
|
||||
--sidebar-ring: var(--color-yellow-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-violet .theme-container,
|
||||
.theme-violet [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-violet-600);
|
||||
--primary-foreground: var(--color-violet-50);
|
||||
--ring: var(--color-violet-400);
|
||||
--chart-1: var(--color-violet-300);
|
||||
--chart-2: var(--color-violet-500);
|
||||
--chart-3: var(--color-violet-600);
|
||||
--chart-4: var(--color-violet-700);
|
||||
--chart-5: var(--color-violet-800);
|
||||
--sidebar-primary: var(--color-violet-600);
|
||||
--sidebar-primary-foreground: var(--color-violet-50);
|
||||
--sidebar-ring: var(--color-violet-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-violet-500);
|
||||
--primary-foreground: var(--color-violet-50);
|
||||
--ring: var(--color-violet-900);
|
||||
--sidebar-primary: var(--color-violet-500);
|
||||
--sidebar-primary-foreground: var(--color-violet-50);
|
||||
--sidebar-ring: var(--color-violet-900);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { useForm, router, usePage } from "@inertiajs/vue3";
|
||||
import DialogModal from "./DialogModal.vue";
|
||||
import InputLabel from "./InputLabel.vue";
|
||||
import SectionTitle from "./SectionTitle.vue";
|
||||
import TextInput from "./TextInput.vue";
|
||||
import InputError from "./InputError.vue";
|
||||
import PrimaryButton from "./PrimaryButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
person: Object,
|
||||
types: Array,
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
id: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const processing = ref(false);
|
||||
const errors = ref({});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const close = () => {
|
||||
emit("close");
|
||||
setTimeout(() => {
|
||||
errors.value = {};
|
||||
try {
|
||||
form.clearErrors && form.clearErrors();
|
||||
} catch {}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
address: "",
|
||||
country: "",
|
||||
post_code: "",
|
||||
city: "",
|
||||
type_id: props.types?.[0]?.id ?? null,
|
||||
description: "",
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
form.address = "";
|
||||
form.country = "";
|
||||
form.post_code = "";
|
||||
form.city = "";
|
||||
form.type_id = props.types?.[0]?.id ?? null;
|
||||
form.description = "";
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
processing.value = true;
|
||||
errors.value = {};
|
||||
|
||||
form.post(route("person.address.create", props.person), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
// Optimistically append from last created record in DB by refetch or expose via flash if needed.
|
||||
// For now, trigger a lightweight reload of person's addresses via a GET if you have an endpoint, else trust parent reactivity.
|
||||
processing.value = false;
|
||||
close();
|
||||
form.reset();
|
||||
},
|
||||
onError: (e) => {
|
||||
errors.value = e || {};
|
||||
processing.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true;
|
||||
errors.value = {};
|
||||
|
||||
form.put(
|
||||
route("person.address.update", { person: props.person, address_id: props.id }),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processing.value = false;
|
||||
close();
|
||||
form.reset();
|
||||
},
|
||||
onError: (e) => {
|
||||
errors.value = e || {};
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.id,
|
||||
(id) => {
|
||||
if (props.edit && id !== 0) {
|
||||
console.log(props.edit);
|
||||
props.person.addresses.filter((a) => {
|
||||
if (a.id === props.id) {
|
||||
form.address = a.address;
|
||||
form.country = a.country;
|
||||
form.post_code = a.post_code || a.postal_code || "";
|
||||
form.city = a.city || "";
|
||||
form.type_id = a.type_id;
|
||||
form.description = a.description;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
}
|
||||
);
|
||||
|
||||
const callSubmit = () => {
|
||||
if (props.edit) {
|
||||
update();
|
||||
} else {
|
||||
create();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
<template #title>
|
||||
<span v-if="edit">Spremeni naslov</span>
|
||||
<span v-else>Dodaj novi naslov</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<form @submit.prevent="callSubmit">
|
||||
<SectionTitle class="border-b mb-4">
|
||||
<template #title> Naslov </template>
|
||||
</SectionTitle>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="cr_address" value="Naslov" />
|
||||
<TextInput
|
||||
id="cr_address"
|
||||
ref="cr_addressInput"
|
||||
v-model="form.address"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="address"
|
||||
/>
|
||||
|
||||
<InputError
|
||||
v-if="errors.address !== undefined"
|
||||
v-for="err in errors.address"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="cr_country" value="Država" />
|
||||
<TextInput
|
||||
id="cr_country"
|
||||
ref="cr_countryInput"
|
||||
v-model="form.country"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="country"
|
||||
/>
|
||||
|
||||
<InputError
|
||||
v-if="errors.address !== undefined"
|
||||
v-for="err in errors.address"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="cr_post_code" value="Poštna številka" />
|
||||
<TextInput
|
||||
id="cr_post_code"
|
||||
v-model="form.post_code"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="postal-code"
|
||||
/>
|
||||
<InputError
|
||||
v-if="errors.post_code !== undefined"
|
||||
v-for="err in errors.post_code"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="cr_city" value="Mesto" />
|
||||
<TextInput
|
||||
id="cr_city"
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="address-level2"
|
||||
/>
|
||||
<InputError
|
||||
v-if="errors.city !== undefined"
|
||||
v-for="err in errors.city"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="cr_type" value="Tip" />
|
||||
<select
|
||||
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
id="cr_type"
|
||||
v-model="form.type_id"
|
||||
>
|
||||
<option v-for="type in types" :key="type.id" :value="type.id">
|
||||
{{ type.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<PrimaryButton :class="{ 'opacity-25': processing }" :disabled="processing">
|
||||
Shrani
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
import DialogModal from "./DialogModal.vue";
|
||||
import InputLabel from "./InputLabel.vue";
|
||||
import SectionTitle from "./SectionTitle.vue";
|
||||
import TextInput from "./TextInput.vue";
|
||||
import InputError from "./InputError.vue";
|
||||
import PrimaryButton from "./PrimaryButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
person: Object,
|
||||
types: Array,
|
||||
id: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const processing = ref(false);
|
||||
const errors = ref({});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
const close = () => {
|
||||
emit("close");
|
||||
setTimeout(() => {
|
||||
errors.value = {};
|
||||
try {
|
||||
form.clearErrors && form.clearErrors();
|
||||
} catch {}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
address: "",
|
||||
country: "",
|
||||
post_code: "",
|
||||
city: "",
|
||||
type_id: props.types?.[0]?.id ?? null,
|
||||
description: "",
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
form.address = "";
|
||||
form.country = "";
|
||||
form.post_code = "";
|
||||
form.city = "";
|
||||
form.type_id = props.types?.[0]?.id ?? null;
|
||||
form.description = "";
|
||||
};
|
||||
|
||||
const hydrate = () => {
|
||||
const id = props.id;
|
||||
if (id) {
|
||||
const a = (props.person.addresses || []).find((x) => x.id === id);
|
||||
if (a) {
|
||||
form.address = a.address;
|
||||
form.country = a.country;
|
||||
form.post_code = a.post_code || a.postal_code || "";
|
||||
form.city = a.city || "";
|
||||
form.type_id = a.type_id;
|
||||
form.description = a.description || "";
|
||||
}
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.id,
|
||||
() => hydrate(),
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) hydrate();
|
||||
}
|
||||
);
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true;
|
||||
errors.value = {};
|
||||
|
||||
form.put(
|
||||
route("person.address.update", { person: props.person, address_id: props.id }),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processing.value = false;
|
||||
close();
|
||||
form.reset();
|
||||
},
|
||||
onError: (e) => {
|
||||
errors.value = e || {};
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
<template #title>Spremeni naslov</template>
|
||||
<template #content>
|
||||
<form @submit.prevent="update">
|
||||
<SectionTitle class="border-b mb-4"
|
||||
><template #title>Naslov</template></SectionTitle
|
||||
>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="up_address" value="Naslov" />
|
||||
<TextInput
|
||||
id="up_address"
|
||||
v-model="form.address"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="address"
|
||||
/>
|
||||
<InputError
|
||||
v-if="errors.address !== undefined"
|
||||
v-for="err in errors.address"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="up_country" value="Država" />
|
||||
<TextInput
|
||||
id="up_country"
|
||||
v-model="form.country"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="country"
|
||||
/>
|
||||
<InputError
|
||||
v-if="errors.country !== undefined"
|
||||
v-for="err in errors.country"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="up_post_code" value="Poštna številka" />
|
||||
<TextInput
|
||||
id="up_post_code"
|
||||
v-model="form.post_code"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="postal-code"
|
||||
/>
|
||||
<InputError
|
||||
v-if="errors.post_code !== undefined"
|
||||
v-for="err in errors.post_code"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="up_city" value="Mesto" />
|
||||
<TextInput
|
||||
id="up_city"
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="address-level2"
|
||||
/>
|
||||
<InputError
|
||||
v-if="errors.city !== undefined"
|
||||
v-for="err in errors.city"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="up_type" value="Tip" />
|
||||
<select
|
||||
id="up_type"
|
||||
v-model="form.type_id"
|
||||
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option v-for="type in types" :key="type.id" :value="type.id">
|
||||
{{ type.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<PrimaryButton :class="{ 'opacity-25': processing }" :disabled="processing"
|
||||
>Shrani</PrimaryButton
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,3 +1,3 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-auto" viewBox="0 -960 960 960" fill="#5985E1" preserveAspectRatio="xMidYMid meet"><path d="M480-80q-140-35-230-162.5T160-522v-238l320-120 320 120v238q0 78-21.5 154.5T703-225L563-359q-19 11-40.16 18-21.16 7-42.84 7-62 0-105.5-43T331-482.5q0-62.5 43.5-106T480-632q62 0 105.5 43.5T629-482q0 21-6 42t-19 38l88 84q24-43 36-96.5T740-522v-198.48L480-815l-260 94.52V-522q0 131 72.5 236.5T480.2-142q28.8-8 70.3-33t65.5-48l42 43q-35 32-83.5 60.5T480-80Zm.2-314q36.8 0 62.8-25.5t26-63q0-37.5-26.2-63.5-26.21-26-63-26-36.8 0-62.8 26t-26 63.5q0 37.5 26.2 63 26.21 25.5 63 25.5Zm-1.2-90Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-20 h-20" viewBox="0 -960 960 960" fill="#5985E1"><path d="M480-80q-140-35-230-162.5T160-522v-238l320-120 320 120v238q0 78-21.5 154.5T703-225L563-359q-19 11-40.16 18-21.16 7-42.84 7-62 0-105.5-43T331-482.5q0-62.5 43.5-106T480-632q62 0 105.5 43.5T629-482q0 21-6 42t-19 38l88 84q24-43 36-96.5T740-522v-198.48L480-815l-260 94.52V-522q0 131 72.5 236.5T480.2-142q28.8-8 70.3-33t65.5-48l42 43q-35 32-83.5 60.5T480-80Zm.2-314q36.8 0 62.8-25.5t26-63q0-37.5-26.2-63.5-26.21-26-63-26-36.8 0-62.8 26t-26 63.5q0 37.5 26.2 63 26.21 25.5 63 25.5Zm-1.2-90Z"/></svg>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { FwbButton, FwbTable, FwbTableBody, FwbTableCell, FwbTableHead, FwbTableHeadCell, FwbTableRow } from 'flowbite-vue';
|
||||
import DialogModal from './DialogModal.vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
@@ -9,7 +9,6 @@ import ActionMessage from './ActionMessage.vue';
|
||||
import PrimaryButton from './PrimaryButton.vue';
|
||||
import Modal from './Modal.vue';
|
||||
import SecondaryButton from './SecondaryButton.vue';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
@@ -122,32 +121,30 @@ const remove = () => {
|
||||
</div>
|
||||
|
||||
<div :class="['relative rounded-lg border border-gray-200 bg-white shadow-sm overflow-x-auto overflow-y-auto', bodyMaxHeight, stickyHeader ? 'table-sticky' : '']">
|
||||
<Table class="text-sm">
|
||||
<TableHeader class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur supports-[backdrop-filter]:bg-gray-50/80 border-b border-gray-200 shadow-sm">
|
||||
<TableRow class="border-b">
|
||||
<TableHead
|
||||
v-for="(h, hIndex) in header"
|
||||
:key="hIndex"
|
||||
class="sticky top-0 z-10 uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6 bg-gray-50/90"
|
||||
>{{ h.data }}</TableHead>
|
||||
<TableHead v-if="editor" class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90"></TableHead>
|
||||
<TableHead v-else class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="(row, key, parent_index) in body" :key="key" :class="[row.options.class, 'hover:bg-gray-50/50']">
|
||||
<TableCell v-for="(col, colIndex) in row.cols" :key="colIndex" class="align-middle">
|
||||
<FwbTable hoverable striped class="text-sm">
|
||||
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur supports-[backdrop-filter]:bg-gray-50/80 border-b border-gray-200 shadow-sm">
|
||||
<FwbTableHeadCell
|
||||
v-for="(h, hIndex) in header"
|
||||
:key="hIndex"
|
||||
class="sticky top-0 z-10 uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6 bg-gray-50/90"
|
||||
>{{ h.data }}</FwbTableHeadCell>
|
||||
<FwbTableHeadCell v-if="editor" class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90"></FwbTableHeadCell>
|
||||
<FwbTableHeadCell v-else class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90" />
|
||||
</FwbTableHead>
|
||||
<FwbTableBody>
|
||||
<FwbTableRow v-for="(row, key, parent_index) in body" :key="key" :class="row.options.class">
|
||||
<FwbTableCell v-for="(col, colIndex) in row.cols" :key="colIndex" class="align-middle">
|
||||
<a v-if="col.link !== undefined" :class="col.link.css" :href="route(col.link.route, col.link.options)">{{ col.data }}</a>
|
||||
<span v-else>{{ col.data }}</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="editor" class="text-right whitespace-nowrap">
|
||||
<Button class="mr-2" size="sm" variant="outline" @click="openEditor(row.options.ref, row.options.editable)">Edit</Button>
|
||||
<Button size="sm" variant="destructive" @click="showModal(row.options.ref.val, row.options.title)">Remove</Button>
|
||||
</TableCell>
|
||||
<TableCell v-else />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell v-if="editor" class="text-right whitespace-nowrap">
|
||||
<fwb-button class="mr-2" size="sm" color="default" @click="openEditor(row.options.ref, row.options.editable)" outline>Edit</fwb-button>
|
||||
<fwb-button size="sm" color="red" @click="showModal(row.options.ref.val, row.options.title)" outline>Remove</fwb-button>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell v-else />
|
||||
</FwbTableRow>
|
||||
</FwbTableBody>
|
||||
</FwbTable>
|
||||
<div v-if="!body || body.length === 0" class="p-6 text-center text-sm text-gray-500">No records found.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,8 @@ defineProps({
|
||||
<svg v-if="index !== 0" class="rtl:rotate-180 w-3 h-3 text-gray-400 mx-1" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
|
||||
</svg>
|
||||
<a v-if="page.current === undefined" :href="page.url" class="ms-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ms-2">{{ page.title }}</a>
|
||||
<span v-else class="ms-1 text-sm font-medium text-gray-500 md:ms-2">{{ page.title }}</span>
|
||||
<a v-if="page.current === undefined" :href="page.url" class="ms-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ms-2 dark:text-gray-400 dark:hover:text-white">{{ page.title }}</a>
|
||||
<span v-else class="ms-1 text-sm font-medium text-gray-500 md:ms-2 dark:text-gray-400">{{ page.title }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import DeleteDialog from './Dialogs/DeleteDialog.vue';
|
||||
import DialogModal from './DialogModal.vue';
|
||||
import PrimaryButton from './PrimaryButton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
@@ -8,8 +9,6 @@ const props = defineProps({
|
||||
confirmText: { type: String, default: 'Potrdi' },
|
||||
cancelText: { type: String, default: 'Prekliči' },
|
||||
danger: { type: Boolean, default: false },
|
||||
itemName: { type: String, default: null },
|
||||
processing: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'confirm']);
|
||||
@@ -19,15 +18,21 @@ const onConfirm = () => emit('confirm');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DeleteDialog
|
||||
:show="show"
|
||||
:title="title"
|
||||
:message="message"
|
||||
:item-name="itemName"
|
||||
:confirm-text="confirmText"
|
||||
:cancel-text="cancelText"
|
||||
:processing="processing"
|
||||
@close="onClose"
|
||||
@confirm="onConfirm"
|
||||
/>
|
||||
<DialogModal :show="show" @close="onClose">
|
||||
<template #title>
|
||||
{{ title }}
|
||||
</template>
|
||||
<template #content>
|
||||
<p class="text-sm text-gray-700">{{ message }}</p>
|
||||
<div class="mt-6 flex items-center justify-end gap-3">
|
||||
<button type="button" class="px-4 py-2 rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50" @click="onClose">
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<PrimaryButton :class="danger ? 'bg-red-600 hover:bg-red-700 focus:ring-red-500' : ''" @click="onConfirm">
|
||||
{{ confirmText }}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup>
|
||||
import { watch, onMounted } from "vue";
|
||||
import { useCurrencyInput } from "vue-currency-input";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Number, String, null], default: null },
|
||||
@@ -16,7 +14,6 @@ const props = defineProps({
|
||||
precision: { type: [Number, Object], default: 2 },
|
||||
allowNegative: { type: Boolean, default: false },
|
||||
useGrouping: { type: Boolean, default: true },
|
||||
class: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "change"]);
|
||||
@@ -84,7 +81,7 @@ onMounted(() => {
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:class="cn('flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-800 dark:border-gray-600"
|
||||
autocomplete="off"
|
||||
@change="$emit('change', numberValue)"
|
||||
/>
|
||||
|
||||
@@ -16,15 +16,15 @@ provide('selected', selected);
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<div class="text-sm font-medium text-center text-gray-500 border-b border-gray-200">
|
||||
<div class="text-sm font-medium text-center text-gray-500 border-b border-gray-200 dark:text-gray-400 dark:border-gray-700">
|
||||
<ul class="flex flex-wrap -mb-px">
|
||||
<li class="me-2" v-for="tab in tabs" :key="tab.name">
|
||||
<button
|
||||
@click="selected = tab.name"
|
||||
class="inline-block p-4"
|
||||
:class="{
|
||||
'border-b-2 border-transparent rounded-t-lg hover:text-gray-600 hover:border-gray-300': tab.name !== selected,
|
||||
[`text-${selectedColor} border-b-2 border-${selectedColor} rounded-t-lg active`]: tab.name === selected
|
||||
['border-b-2 border-transparent rounded-t-lg hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300']: tab.name !== selected,
|
||||
[`text-${ selectedColor } border-b-2 border-${ selectedColor } rounded-t-lg active dark:text-blue-500 dark:border-${ selectedColor }`]: tab.name === selected
|
||||
}"
|
||||
>
|
||||
{{ tab.title }}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<script setup>
|
||||
import { DropdownMenuItem } from '@/Components/ui/dropdown-menu';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: [String, Object, Array],
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
danger: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
function handleClick(event) {
|
||||
if (!props.disabled) {
|
||||
emit('click', event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem
|
||||
:disabled="disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 cursor-pointer',
|
||||
danger && 'text-red-700 focus:text-red-700 focus:bg-red-50',
|
||||
)
|
||||
"
|
||||
@select="handleClick"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
v-if="icon"
|
||||
:icon="icon"
|
||||
class="w-4 h-4 flex-shrink-0"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faFilter, faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
import DatePicker from '@/Components/DatePicker.vue';
|
||||
|
||||
const props = defineProps({
|
||||
column: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: [String, Number, Array, null],
|
||||
default: null,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text', // text, select, date, date-range, number
|
||||
validator: (v) => ['text', 'select', 'date', 'date-range', 'number'].includes(v),
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [], // For select type: [{ value, label }]
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:value', 'clear']);
|
||||
|
||||
const internalValue = ref(props.value);
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(newVal) => {
|
||||
internalValue.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
watch(internalValue, (newVal) => {
|
||||
emit('update:value', newVal);
|
||||
});
|
||||
|
||||
function clear() {
|
||||
internalValue.value = props.type === 'select' || props.type === 'date-range' ? null : '';
|
||||
emit('clear');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Filter Button -->
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'inline-flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium transition-colors',
|
||||
value && value !== '' && (Array.isArray(value) ? value.length > 0 : true)
|
||||
? 'bg-primary-100 text-primary-700 hover:bg-primary-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||
]"
|
||||
@click.stop
|
||||
>
|
||||
<FontAwesomeIcon :icon="faFilter" class="h-3 w-3 mr-1" />
|
||||
{{ column.label }}
|
||||
<button
|
||||
v-if="value && value !== '' && (Array.isArray(value) ? value.length > 0 : true)"
|
||||
@click.stop="clear"
|
||||
class="ml-1.5 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTimes" class="h-3 w-3" />
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<!-- Filter Dropdown -->
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
class="absolute z-50 mt-1 w-64 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5"
|
||||
@click.stop
|
||||
>
|
||||
<div class="p-3">
|
||||
<!-- Text Filter -->
|
||||
<input
|
||||
v-if="type === 'text'"
|
||||
v-model="internalValue"
|
||||
type="text"
|
||||
:placeholder="`Filtriraj ${column.label.toLowerCase()}...`"
|
||||
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
|
||||
<!-- Select Filter -->
|
||||
<select
|
||||
v-else-if="type === 'select'"
|
||||
v-model="internalValue"
|
||||
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null">Vse</option>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Number Filter -->
|
||||
<div v-else-if="type === 'number'" class="space-y-2">
|
||||
<input
|
||||
v-model.number="internalValue"
|
||||
type="number"
|
||||
:placeholder="`Filtriraj ${column.label.toLowerCase()}...`"
|
||||
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date Filter -->
|
||||
<DatePicker
|
||||
v-else-if="type === 'date'"
|
||||
v-model="internalValue"
|
||||
format="dd.MM.yyyy"
|
||||
placeholder="Izberi datum"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const showDropdown = ref(false);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
if (typeof window !== 'undefined') {
|
||||
document.addEventListener('click', () => {
|
||||
showDropdown.value = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,705 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, h, useSlots } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import {
|
||||
useVueTable,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
} from "@tanstack/vue-table";
|
||||
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 slots = useSlots();
|
||||
|
||||
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
|
||||
showPagination: { type: Boolean, default: true },
|
||||
showViewOptions: { 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 && !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,
|
||||
globalFilterFn: "includesString",
|
||||
onGlobalFilterChange: (updater) => {
|
||||
const newFilter =
|
||||
typeof updater === "function" ? updater(internalSearch.value) : updater;
|
||||
handleSearchChange(newFilter);
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
// Expose table instance and utilities for toolbar usage
|
||||
defineExpose({
|
||||
table,
|
||||
internalSearch,
|
||||
internalPageSize,
|
||||
rowSelection,
|
||||
handleSearchChange,
|
||||
handlePageSizeChange,
|
||||
handleExport,
|
||||
exportToCSV,
|
||||
exportToXLSX,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-4">
|
||||
<!-- Toolbar Slot - Users can build their own toolbar using the table instance -->
|
||||
<slot
|
||||
name="toolbar"
|
||||
:table="table"
|
||||
:search="internalSearch"
|
||||
:page-size="internalPageSize"
|
||||
:row-selection="rowSelection"
|
||||
/>
|
||||
|
||||
<!-- 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 class="p-4">
|
||||
<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="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 || slots.actions ? 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 || slots.actions ? 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="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>
|
||||
@@ -1,15 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue";
|
||||
import EmptyState from "@/Components/EmptyState.vue";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/Components/ui/table";
|
||||
FwbTable,
|
||||
FwbTableHead,
|
||||
FwbTableHeadCell,
|
||||
FwbTableBody,
|
||||
FwbTableRow,
|
||||
FwbTableCell,
|
||||
} from "flowbite-vue";
|
||||
|
||||
const props = defineProps({
|
||||
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class?, formatter? }]
|
||||
@@ -199,43 +197,42 @@ function setPageSize(ps) {
|
||||
<div
|
||||
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||
>
|
||||
<Table class="text-sm">
|
||||
<TableHeader
|
||||
<FwbTable hoverable striped class="text-sm">
|
||||
<FwbTableHead
|
||||
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
|
||||
>
|
||||
<TableRow class="border-b">
|
||||
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
|
||||
<button
|
||||
v-if="col.sortable"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 hover:text-indigo-600"
|
||||
@click="toggleSort(col)"
|
||||
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
|
||||
<FwbTableHeadCell v-for="col in columns" :key="col.key" :class="col.class">
|
||||
<button
|
||||
v-if="col.sortable"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 hover:text-indigo-600"
|
||||
@click="toggleSort(col)"
|
||||
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
|
||||
>
|
||||
<span class="uppercase">{{ col.label }}</span>
|
||||
<span v-if="sort?.key === col.key && sort.direction === 'asc'">▲</span>
|
||||
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
|
||||
>▼</span
|
||||
>
|
||||
<span class="uppercase">{{ col.label }}</span>
|
||||
<span v-if="sort?.key === col.key && sort.direction === 'asc'">▲</span>
|
||||
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
|
||||
>▼</span
|
||||
>
|
||||
</button>
|
||||
<span v-else>{{ col.label }}</span>
|
||||
</TableHead>
|
||||
<TableHead v-if="$slots.actions" class="w-px"> </TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</button>
|
||||
<span v-else>{{ col.label }}</span>
|
||||
</FwbTableHeadCell>
|
||||
<FwbTableHeadCell v-if="$slots.actions" class="w-px"> </FwbTableHeadCell>
|
||||
</FwbTableHead>
|
||||
|
||||
<TableBody>
|
||||
<FwbTableBody>
|
||||
<template v-if="!loading && pageRows.length">
|
||||
<TableRow
|
||||
<FwbTableRow
|
||||
v-for="(row, idx) in pageRows"
|
||||
:key="keyOf(row)"
|
||||
@click="$emit('row:click', row)"
|
||||
class="cursor-default hover:bg-gray-50/50"
|
||||
class="cursor-default"
|
||||
>
|
||||
<TableCell
|
||||
<FwbTableCell
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="col.class"
|
||||
:align="col.align || 'left'"
|
||||
>
|
||||
<template v-if="$slots['cell-' + col.key]">
|
||||
<slot
|
||||
@@ -258,37 +255,33 @@ function setPageSize(ps) {
|
||||
<template v-else>
|
||||
{{ col.formatter ? col.formatter(row) : row?.[col.key] ?? "" }}
|
||||
</template>
|
||||
</TableCell>
|
||||
<TableCell v-if="$slots.actions" class="w-px text-right">
|
||||
</FwbTableCell>
|
||||
<FwbTableCell v-if="$slots.actions" class="w-px text-right">
|
||||
<slot name="actions" :row="row" :index="idx" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</FwbTableCell>
|
||||
</FwbTableRow>
|
||||
</template>
|
||||
<template v-else-if="loading">
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||
<SkeletonTable :rows="5" :cols="columns.length" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<FwbTableRow>
|
||||
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||
<div class="p-6 text-center text-gray-500">Nalagam...</div>
|
||||
</FwbTableCell>
|
||||
</FwbTableRow>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||
<FwbTableRow>
|
||||
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||
<slot name="empty">
|
||||
<EmptyState
|
||||
:title="emptyText"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="p-6 text-center text-gray-500">{{ emptyText }}</div>
|
||||
</slot>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</FwbTableCell>
|
||||
</FwbTableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</FwbTableBody>
|
||||
</FwbTable>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
v-if="showPagination"
|
||||
class="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm text-gray-700"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<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>
|
||||
|
||||
@@ -1,708 +0,0 @@
|
||||
<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"
|
||||
:current-page="meta.current_page"
|
||||
:last-page="meta.last_page"
|
||||
:per-page="meta.per_page"
|
||||
:page-param="pageParamName"
|
||||
:per-page-param="'per_page'"<!-- legacy component may not have custom per-page name prop -->
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- TanStack Table Pagination for client-side -->
|
||||
<template v-else>
|
||||
<DataTablePagination :table="table" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
<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 || {});
|
||||
const onlyProps = Array.isArray(props.onlyProps) ? props.onlyProps : [];
|
||||
const inertiaOptions = {
|
||||
preserveScroll: props.preserveScroll,
|
||||
preserveState: props.preserveState,
|
||||
replace: true,
|
||||
};
|
||||
|
||||
if (onlyProps.length > 0) {
|
||||
inertiaOptions.only = onlyProps;
|
||||
}
|
||||
|
||||
router.get(url, q, inertiaOptions);
|
||||
}
|
||||
|
||||
// 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="p-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="[
|
||||
'p-3',
|
||||
header.column.columnDef.meta?.class,
|
||||
header.column.columnDef.meta?.align === 'right'
|
||||
? 'text-right'
|
||||
: header.column.columnDef.meta?.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left',
|
||||
'bg-muted/50',
|
||||
]"
|
||||
>
|
||||
<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',
|
||||
'p-3',
|
||||
]"
|
||||
>
|
||||
<!-- 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" class="border-t border-gray-200 p-4">
|
||||
<!-- Server-side pagination -->
|
||||
<template v-if="isServerSide && meta?.links">
|
||||
<Pagination
|
||||
:links="meta.links"
|
||||
:from="meta.from"
|
||||
:to="meta.to"
|
||||
:total="meta.total"
|
||||
:current-page="meta.current_page"
|
||||
:last-page="meta.last_page"
|
||||
:per-page="meta.per_page"
|
||||
:page-param="pageParamName"
|
||||
:per-page-param="perPageParamName"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Client-side pagination -->
|
||||
<template v-else>
|
||||
<DataTablePagination :table="table" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,884 +0,0 @@
|
||||
<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>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<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,16 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue";
|
||||
import EmptyState from "@/Components/EmptyState.vue";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/Components/ui/table";
|
||||
FwbTable,
|
||||
FwbTableHead,
|
||||
FwbTableHeadCell,
|
||||
FwbTableBody,
|
||||
FwbTableRow,
|
||||
FwbTableCell,
|
||||
} from "flowbite-vue";
|
||||
|
||||
const props = defineProps({
|
||||
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class? }]
|
||||
@@ -96,8 +94,7 @@ function setPageSize(ps) {
|
||||
function doRequest(overrides = {}) {
|
||||
const q = {
|
||||
...props.query,
|
||||
// Laravel expects snake_case per_page
|
||||
per_page: overrides.perPage ?? props.meta?.per_page ?? props.pageSize ?? 10,
|
||||
perPage: overrides.perPage ?? props.meta?.per_page ?? props.pageSize ?? 10,
|
||||
sort: overrides.sort ?? props.sort?.key ?? null,
|
||||
direction: overrides.direction ?? props.sort?.direction ?? null,
|
||||
search: overrides.search ?? props.search ?? "",
|
||||
@@ -201,12 +198,15 @@ function goToPageInput() {
|
||||
<div
|
||||
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||
>
|
||||
<Table class="text-sm">
|
||||
<TableHeader
|
||||
<FwbTable hoverable striped class="text-sm">
|
||||
<FwbTableHead
|
||||
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
|
||||
>
|
||||
<TableRow class="border-b">
|
||||
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
|
||||
<FwbTableHeadCell v-for="col in columns" :key="col.key" :class="col.class">
|
||||
<template v-if="$slots['header-' + col.key]">
|
||||
<slot :name="'header-' + col.key" :column="col" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="col.sortable"
|
||||
type="button"
|
||||
@@ -221,23 +221,24 @@ function goToPageInput() {
|
||||
>
|
||||
</button>
|
||||
<span v-else>{{ col.label }}</span>
|
||||
</TableHead>
|
||||
<TableHead v-if="$slots.actions" class="w-px"> </TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</template>
|
||||
</FwbTableHeadCell>
|
||||
<FwbTableHeadCell v-if="$slots.actions" class="w-px"> </FwbTableHeadCell>
|
||||
</FwbTableHead>
|
||||
|
||||
<TableBody>
|
||||
<FwbTableBody>
|
||||
<template v-if="!loading && rows.length">
|
||||
<TableRow
|
||||
<FwbTableRow
|
||||
v-for="(row, idx) in rows"
|
||||
:key="keyOf(row)"
|
||||
@click="$emit('row:click', row)"
|
||||
class="cursor-default hover:bg-gray-50/50"
|
||||
class="cursor-default"
|
||||
>
|
||||
<TableCell
|
||||
<FwbTableCell
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="col.class"
|
||||
:align="col.align || 'left'"
|
||||
>
|
||||
<template v-if="$slots['cell-' + col.key]">
|
||||
<slot
|
||||
@@ -260,30 +261,30 @@ function goToPageInput() {
|
||||
<template v-else>
|
||||
{{ row?.[col.key] ?? "" }}
|
||||
</template>
|
||||
</TableCell>
|
||||
<TableCell v-if="$slots.actions" class="w-px text-right">
|
||||
</FwbTableCell>
|
||||
<FwbTableCell v-if="$slots.actions" class="w-px text-right">
|
||||
<slot name="actions" :row="row" :index="idx" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</FwbTableCell>
|
||||
</FwbTableRow>
|
||||
</template>
|
||||
<template v-else-if="loading">
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||
<SkeletonTable :rows="5" :cols="columns.length" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<FwbTableRow>
|
||||
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||
<div class="p-6 text-center text-gray-500">Nalagam...</div>
|
||||
</FwbTableCell>
|
||||
</FwbTableRow>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||
<FwbTableRow>
|
||||
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||
<slot name="empty">
|
||||
<div class="p-6 text-center text-gray-500">{{ emptyText }}</div>
|
||||
</slot>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</FwbTableCell>
|
||||
</FwbTableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</FwbTableBody>
|
||||
</FwbTable>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<script setup>
|
||||
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 { 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({
|
||||
// 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:perPage"]);
|
||||
|
||||
// Popover state
|
||||
const settingsPopoverOpen = ref(false);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
// 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);
|
||||
},
|
||||
});
|
||||
|
||||
// Reset all filters
|
||||
function resetFilters() {
|
||||
props.table.resetColumnFilters();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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]"
|
||||
/>
|
||||
|
||||
<!-- Custom filter slots -->
|
||||
<slot name="filters" :table="table" />
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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="sm" class="gap-2">
|
||||
<Settings2 class="h-4 w-4" />
|
||||
Pogled
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,86 +0,0 @@
|
||||
<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>
|
||||
@@ -1,50 +0,0 @@
|
||||
<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>
|
||||
@@ -1,291 +0,0 @@
|
||||
# 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!
|
||||
@@ -1,390 +0,0 @@
|
||||
# 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,170 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default', // default, dot, pill
|
||||
validator: (v) => ['default', 'dot', 'pill'].includes(v),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md', // sm, md, lg
|
||||
validator: (v) => ['sm', 'md', 'lg'].includes(v),
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null, // If null, uses status-based colors
|
||||
validator: (v) =>
|
||||
!v ||
|
||||
[
|
||||
'gray',
|
||||
'red',
|
||||
'yellow',
|
||||
'green',
|
||||
'blue',
|
||||
'indigo',
|
||||
'purple',
|
||||
'pink',
|
||||
'amber',
|
||||
'emerald',
|
||||
].includes(v),
|
||||
},
|
||||
});
|
||||
|
||||
// Status-based color mapping
|
||||
const statusColors = {
|
||||
active: 'green',
|
||||
inactive: 'gray',
|
||||
archived: 'gray',
|
||||
pending: 'yellow',
|
||||
completed: 'green',
|
||||
failed: 'red',
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
warning: 'yellow',
|
||||
info: 'blue',
|
||||
draft: 'gray',
|
||||
published: 'green',
|
||||
};
|
||||
|
||||
const badgeColor = computed(() => {
|
||||
if (props.color) return props.color;
|
||||
const lowerStatus = props.status.toLowerCase();
|
||||
return statusColors[lowerStatus] || 'gray';
|
||||
});
|
||||
|
||||
const sizeClasses = {
|
||||
sm: {
|
||||
text: 'text-xs',
|
||||
padding: 'px-2 py-0.5',
|
||||
dot: 'w-1.5 h-1.5',
|
||||
},
|
||||
md: {
|
||||
text: 'text-sm',
|
||||
padding: 'px-2.5 py-1',
|
||||
dot: 'w-2 h-2',
|
||||
},
|
||||
lg: {
|
||||
text: 'text-base',
|
||||
padding: 'px-3 py-1.5',
|
||||
dot: 'w-2.5 h-2.5',
|
||||
},
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
gray: {
|
||||
bg: 'bg-gray-100',
|
||||
text: 'text-gray-800',
|
||||
dot: 'bg-gray-500',
|
||||
border: 'border-gray-300',
|
||||
},
|
||||
red: {
|
||||
bg: 'bg-red-100',
|
||||
text: 'text-red-800',
|
||||
dot: 'bg-red-500',
|
||||
border: 'border-red-300',
|
||||
},
|
||||
yellow: {
|
||||
bg: 'bg-yellow-100',
|
||||
text: 'text-yellow-800',
|
||||
dot: 'bg-yellow-500',
|
||||
border: 'border-yellow-300',
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-green-100',
|
||||
text: 'text-green-800',
|
||||
dot: 'bg-green-500',
|
||||
border: 'border-green-300',
|
||||
},
|
||||
blue: {
|
||||
bg: 'bg-blue-100',
|
||||
text: 'text-blue-800',
|
||||
dot: 'bg-blue-500',
|
||||
border: 'border-blue-300',
|
||||
},
|
||||
indigo: {
|
||||
bg: 'bg-indigo-100',
|
||||
text: 'text-indigo-800',
|
||||
dot: 'bg-indigo-500',
|
||||
border: 'border-indigo-300',
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-purple-100',
|
||||
text: 'text-purple-800',
|
||||
dot: 'bg-purple-500',
|
||||
border: 'border-purple-300',
|
||||
},
|
||||
pink: {
|
||||
bg: 'bg-pink-100',
|
||||
text: 'text-pink-800',
|
||||
dot: 'bg-pink-500',
|
||||
border: 'border-pink-300',
|
||||
},
|
||||
amber: {
|
||||
bg: 'bg-amber-100',
|
||||
text: 'text-amber-800',
|
||||
dot: 'bg-amber-500',
|
||||
border: 'border-amber-300',
|
||||
},
|
||||
emerald: {
|
||||
bg: 'bg-emerald-100',
|
||||
text: 'text-emerald-800',
|
||||
dot: 'bg-emerald-500',
|
||||
border: 'border-emerald-300',
|
||||
},
|
||||
};
|
||||
|
||||
const colors = computed(() => colorClasses[badgeColor.value] || colorClasses.gray);
|
||||
const sizes = computed(() => sizeClasses[props.size] || sizeClasses.md);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center font-medium',
|
||||
sizes.text,
|
||||
sizes.padding,
|
||||
colors.bg,
|
||||
colors.text,
|
||||
variant === 'pill' ? 'rounded-full' : 'rounded-md',
|
||||
variant === 'default' ? `border ${colors.border}` : '',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="variant === 'dot'"
|
||||
:class="[
|
||||
'rounded-full mr-1.5',
|
||||
sizes.dot,
|
||||
colors.dot,
|
||||
]"
|
||||
></span>
|
||||
<slot>{{ status }}</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup>
|
||||
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),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "md", // sm, md
|
||||
validator: (v) => ["sm", "md"].includes(v),
|
||||
},
|
||||
});
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "h-6 w-6",
|
||||
md: "h-8 w-8",
|
||||
};
|
||||
|
||||
const emit = defineEmits(["action"]);
|
||||
|
||||
function handleAction(action) {
|
||||
emit("action", action);
|
||||
if (action.onClick) {
|
||||
action.onClick();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" aria-label="Actions">
|
||||
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent :align="align === 'right' ? 'end' : 'start'" class="py-1">
|
||||
<slot :handle-action="handleAction" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -1,267 +0,0 @@
|
||||
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;
|
||||
@@ -1,152 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, useAttrs } from "vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Calendar } from "@/Components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/Components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarIcon } from "lucide-vue-next";
|
||||
import { format } from "date-fns";
|
||||
import { sl } from "date-fns/locale";
|
||||
import { CalendarDate, parseDate } from "@internationalized/date";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Date, String, null],
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "Izberi datum",
|
||||
},
|
||||
format: {
|
||||
type: String,
|
||||
default: "dd.MM.yyyy",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
error: {
|
||||
type: [String, Array],
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const attrs = useAttrs();
|
||||
const forwardedAttrs = computed(() => {
|
||||
const { class: _class, id: _id, ...rest } = attrs;
|
||||
return rest;
|
||||
});
|
||||
const controlId = computed(() => attrs.id ?? props.id);
|
||||
|
||||
// Convert string/Date to CalendarDate
|
||||
const toCalendarDate = (value) => {
|
||||
if (!value) return null;
|
||||
let dateObj;
|
||||
if (value instanceof Date) {
|
||||
dateObj = value;
|
||||
} else if (typeof value === "string") {
|
||||
// Handle YYYY-MM-DD format
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
try {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new CalendarDate(year, month, day);
|
||||
} catch {
|
||||
dateObj = new Date(value);
|
||||
}
|
||||
} else {
|
||||
dateObj = new Date(value);
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dateObj && !isNaN(dateObj.getTime())) {
|
||||
return new CalendarDate(
|
||||
dateObj.getFullYear(),
|
||||
dateObj.getMonth() + 1,
|
||||
dateObj.getDate()
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Convert CalendarDate to ISO string (YYYY-MM-DD)
|
||||
const fromCalendarDate = (calendarDate) => {
|
||||
if (!calendarDate) return null;
|
||||
return `${String(calendarDate.year).padStart(4, "0")}-${String(calendarDate.month).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const calendarDate = computed({
|
||||
get: () => toCalendarDate(props.modelValue),
|
||||
set: (value) => {
|
||||
const isoString = fromCalendarDate(value);
|
||||
emit("update:modelValue", isoString);
|
||||
},
|
||||
});
|
||||
|
||||
// Format for display
|
||||
const formattedDate = computed(() => {
|
||||
if (!calendarDate.value) return props.placeholder;
|
||||
try {
|
||||
const dateObj = new Date(
|
||||
calendarDate.value.year,
|
||||
calendarDate.value.month - 1,
|
||||
calendarDate.value.day
|
||||
);
|
||||
const formatMap = {
|
||||
"dd.MM.yyyy": "dd.MM.yyyy",
|
||||
"yyyy-MM-dd": "yyyy-MM-dd",
|
||||
};
|
||||
const dateFormat = formatMap[props.format] || "dd.MM.yyyy";
|
||||
return format(dateObj, dateFormat, { locale: sl });
|
||||
} catch {
|
||||
return props.placeholder;
|
||||
}
|
||||
});
|
||||
|
||||
const open = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
v-bind="forwardedAttrs"
|
||||
:id="controlId"
|
||||
variant="outline"
|
||||
:class="
|
||||
cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!calendarDate && 'text-muted-foreground',
|
||||
error && 'border-red-500 focus:border-red-500 focus:ring-red-500',
|
||||
attrs.class
|
||||
)
|
||||
"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{{ formattedDate }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" align="start">
|
||||
<Calendar v-model="calendarDate" :disabled="disabled" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p v-if="error" class="mt-1 text-sm text-red-600">
|
||||
{{ Array.isArray(error) ? error[0] : error }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { RangeCalendar } from "@/Components/ui/range-calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/Components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarIcon } from "lucide-vue-next";
|
||||
import { format } from "date-fns";
|
||||
import { sl } from "date-fns/locale";
|
||||
import { CalendarDate } from "@internationalized/date";
|
||||
import { DateFormatter, getLocalTimeZone } from "@internationalized/date";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Object, null],
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "Izberi datumski obseg",
|
||||
},
|
||||
format: {
|
||||
type: String,
|
||||
default: "dd.MM.yyyy",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
error: {
|
||||
type: [String, Array],
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
// Convert string dates to CalendarDate objects
|
||||
const toCalendarDate = (val) => {
|
||||
if (!val) return null;
|
||||
if (val instanceof Date) {
|
||||
return new CalendarDate(
|
||||
val.getFullYear(),
|
||||
val.getMonth() + 1,
|
||||
val.getDate()
|
||||
);
|
||||
}
|
||||
if (typeof val === "string") {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(val)) {
|
||||
const [year, month, day] = val.split("-").map(Number);
|
||||
return new CalendarDate(year, month, day);
|
||||
}
|
||||
const dateObj = new Date(val);
|
||||
if (!isNaN(dateObj.getTime())) {
|
||||
return new CalendarDate(
|
||||
dateObj.getFullYear(),
|
||||
dateObj.getMonth() + 1,
|
||||
dateObj.getDate()
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Convert CalendarDate to ISO string (YYYY-MM-DD)
|
||||
const fromCalendarDate = (calendarDate) => {
|
||||
if (!calendarDate) return null;
|
||||
return `${String(calendarDate.year).padStart(4, "0")}-${String(calendarDate.month).padStart(2, "0")}-${String(calendarDate.day).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// Convert ISO string range to DateRange (CalendarDate objects)
|
||||
const toDateRange = (value) => {
|
||||
if (!value) return { start: null, end: null };
|
||||
const start = toCalendarDate(value.start);
|
||||
const end = toCalendarDate(value.end);
|
||||
// Always return an object, even if both are null
|
||||
return { start: start || null, end: end || null };
|
||||
};
|
||||
|
||||
// Convert DateRange to ISO string range
|
||||
const fromDateRange = (dateRange) => {
|
||||
if (!dateRange || (!dateRange.start && !dateRange.end)) return null;
|
||||
const start = fromCalendarDate(dateRange.start);
|
||||
const end = fromCalendarDate(dateRange.end);
|
||||
// Return null if both dates are null/empty
|
||||
if (!start && !end) return null;
|
||||
return {
|
||||
start: start || null,
|
||||
end: end || null,
|
||||
};
|
||||
};
|
||||
|
||||
// Date formatter for display
|
||||
const df = new DateFormatter("sl-SI", {
|
||||
dateStyle: "short",
|
||||
});
|
||||
|
||||
const dateRange = computed({
|
||||
get: () => {
|
||||
const range = toDateRange(props.modelValue);
|
||||
// RangeCalendar expects an object with start and end, not null
|
||||
return range || { start: null, end: null };
|
||||
},
|
||||
set: (value) => {
|
||||
// Only emit if value has actual dates, otherwise emit null
|
||||
if (value && (value.start || value.end)) {
|
||||
const isoRange = fromDateRange(value);
|
||||
emit("update:modelValue", isoRange);
|
||||
} else {
|
||||
emit("update:modelValue", null);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Format for display using DateRange (CalendarDate objects)
|
||||
const formattedDateRange = computed(() => {
|
||||
const range = dateRange.value;
|
||||
if (!range || (!range.start && !range.end)) {
|
||||
return props.placeholder;
|
||||
}
|
||||
|
||||
try {
|
||||
if (range.start && range.end) {
|
||||
// Use DateFormatter if available, otherwise fall back to date-fns
|
||||
try {
|
||||
const startStr = df.format(range.start.toDate(getLocalTimeZone()));
|
||||
const endStr = df.format(range.end.toDate(getLocalTimeZone()));
|
||||
return `${startStr} - ${endStr}`;
|
||||
} catch {
|
||||
// Fallback to date-fns
|
||||
const formatDate = (calendarDate) => {
|
||||
if (!calendarDate) return "";
|
||||
const dateObj = new Date(
|
||||
calendarDate.year,
|
||||
calendarDate.month - 1,
|
||||
calendarDate.day
|
||||
);
|
||||
const formatMap = {
|
||||
"dd.MM.yyyy": "dd.MM.yyyy",
|
||||
"yyyy-MM-dd": "yyyy-MM-dd",
|
||||
};
|
||||
const dateFormat = formatMap[props.format] || "dd.MM.yyyy";
|
||||
return format(dateObj, dateFormat, { locale: sl });
|
||||
};
|
||||
return `${formatDate(range.start)} - ${formatDate(range.end)}`;
|
||||
}
|
||||
}
|
||||
if (range.start) {
|
||||
try {
|
||||
return df.format(range.start.toDate(getLocalTimeZone()));
|
||||
} catch {
|
||||
const dateObj = new Date(
|
||||
range.start.year,
|
||||
range.start.month - 1,
|
||||
range.start.day
|
||||
);
|
||||
return format(dateObj, props.format || "dd.MM.yyyy", { locale: sl });
|
||||
}
|
||||
}
|
||||
if (range.end) {
|
||||
try {
|
||||
return df.format(range.end.toDate(getLocalTimeZone()));
|
||||
} catch {
|
||||
const dateObj = new Date(
|
||||
range.end.year,
|
||||
range.end.month - 1,
|
||||
range.end.day
|
||||
);
|
||||
return format(dateObj, props.format || "dd.MM.yyyy", { locale: sl });
|
||||
}
|
||||
}
|
||||
|
||||
return props.placeholder;
|
||||
} catch {
|
||||
return props.placeholder;
|
||||
}
|
||||
});
|
||||
|
||||
const open = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
:id="id"
|
||||
variant="outline"
|
||||
:class="
|
||||
cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
(!props.modelValue?.start && !props.modelValue?.end) && 'text-muted-foreground',
|
||||
error && 'border-red-500 focus:border-red-500 focus:ring-red-500'
|
||||
)
|
||||
"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
<template v-if="dateRange?.start">
|
||||
<template v-if="dateRange.end">
|
||||
{{ formattedDateRange }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formattedDateRange }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ props.placeholder }}
|
||||
</template>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" align="start">
|
||||
<RangeCalendar
|
||||
v-model="dateRange"
|
||||
:disabled="disabled"
|
||||
:initial-focus="true"
|
||||
:number-of-months="2"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p v-if="error" class="mt-1 text-sm text-red-600">
|
||||
{{ Array.isArray(error) ? error[0] : error }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -1,76 +1,47 @@
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/Components/ui/dialog';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import Modal from './Modal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
default: '2xl',
|
||||
},
|
||||
closeable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
default: '2xl',
|
||||
},
|
||||
closeable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'close']);
|
||||
|
||||
const open = ref(props.show);
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
open.value = newVal;
|
||||
});
|
||||
|
||||
watch(open, (newVal) => {
|
||||
emit('update:show', newVal);
|
||||
if (!newVal) {
|
||||
const close = () => {
|
||||
emit('close');
|
||||
}
|
||||
});
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
const maxWidthMap = {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-md',
|
||||
lg: 'sm:max-w-lg',
|
||||
xl: 'sm:max-w-xl',
|
||||
'2xl': 'sm:max-w-2xl',
|
||||
wide: 'sm:max-w-[1200px]',
|
||||
};
|
||||
return maxWidthMap[props.maxWidth] || 'sm:max-w-2xl';
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open" :modal="true">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<slot name="title" />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<slot name="description" />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Modal
|
||||
:show="show"
|
||||
:max-width="maxWidth"
|
||||
:closeable="closeable"
|
||||
@close="close"
|
||||
>
|
||||
<div class="px-6 py-4">
|
||||
<div class="text-lg font-medium text-gray-900">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
|
||||
<div class="py-4">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-gray-600">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<slot name="footer" />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div class="flex flex-row justify-end px-6 py-4 bg-gray-100 text-end">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/Components/ui/dialog';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faCircleQuestion, faCheckCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: 'Potrditev' },
|
||||
message: { type: String, default: 'Ali ste prepričani?' },
|
||||
confirmText: { type: String, default: 'Potrdi' },
|
||||
cancelText: { type: String, default: 'Prekliči' },
|
||||
processing: { type: Boolean, default: false },
|
||||
maxWidth: { type: String, default: 'md' },
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default', // 'default' or 'success'
|
||||
validator: (v) => ['default', 'success'].includes(v)
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'close', 'confirm']);
|
||||
|
||||
const open = ref(props.show);
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
open.value = newVal;
|
||||
});
|
||||
|
||||
watch(open, (newVal) => {
|
||||
emit('update:show', newVal);
|
||||
if (!newVal) {
|
||||
emit('close');
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
|
||||
const icon = computed(() => props.variant === 'success' ? faCheckCircle : faCircleQuestion);
|
||||
const iconColor = computed(() => props.variant === 'success' ? 'text-green-600' : 'text-primary-600');
|
||||
const iconBg = computed(() => props.variant === 'success' ? 'bg-green-100' : 'bg-primary-100');
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
const maxWidthMap = {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-md',
|
||||
lg: 'sm:max-w-lg',
|
||||
xl: 'sm:max-w-xl',
|
||||
'2xl': 'sm:max-w-2xl',
|
||||
wide: 'sm:max-w-[1200px]',
|
||||
};
|
||||
return maxWidthMap[props.maxWidth] || 'sm:max-w-md';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="icon" :class="['h-5 w-5', iconColor]" />
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div class="flex items-start gap-4 pt-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div :class="['flex items-center justify-center h-12 w-12 rounded-full', iconBg]">
|
||||
<FontAwesomeIcon :icon="icon" :class="['h-6 w-6', iconColor]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-700">
|
||||
{{ message }}
|
||||
</p>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="onClose" :disabled="processing">
|
||||
{{ cancelText }}
|
||||
</Button>
|
||||
<Button
|
||||
@click="onConfirm"
|
||||
:disabled="processing"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faPlusCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed, ref, watch, nextTick } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: "Ustvari novo" },
|
||||
maxWidth: { type: String, default: "2xl" },
|
||||
confirmText: { type: String, default: "Ustvari" },
|
||||
cancelText: { type: String, default: "Prekliči" },
|
||||
processing: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:show", "close", "confirm"]);
|
||||
|
||||
const open = ref(props.show);
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
open.value = newVal;
|
||||
if (newVal) {
|
||||
// Emit custom event when dialog opens
|
||||
nextTick(() => {
|
||||
window.dispatchEvent(new CustomEvent("dialog:open"));
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(open, (newVal) => {
|
||||
emit("update:show", newVal);
|
||||
if (!newVal) {
|
||||
emit("close");
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
emit("confirm");
|
||||
};
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
const maxWidthMap = {
|
||||
sm: "sm:max-w-sm",
|
||||
md: "sm:max-w-md",
|
||||
lg: "sm:max-w-lg",
|
||||
xl: "sm:max-w-xl",
|
||||
"2xl": "sm:max-w-2xl",
|
||||
wide: "sm:max-w-[1200px]",
|
||||
};
|
||||
return maxWidthMap[props.maxWidth] || "sm:max-w-2xl";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faPlusCircle" class="h-5 w-5 text-primary" />
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<slot name="description" />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="py-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="onClose" :disabled="processing">
|
||||
{{ cancelText }}
|
||||
</Button>
|
||||
<Button @click="onConfirm" :disabled="processing || disabled">
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,96 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/Components/ui/dialog';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faTrashCan, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: 'Izbriši' },
|
||||
message: { type: String, default: 'Ali ste prepričani, da želite izbrisati ta element?' },
|
||||
confirmText: { type: String, default: 'Izbriši' },
|
||||
cancelText: { type: String, default: 'Prekliči' },
|
||||
processing: { type: Boolean, default: false },
|
||||
itemName: { type: String, default: null }, // Optional name to show in confirmation
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'close', 'confirm']);
|
||||
|
||||
const open = ref(props.show);
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
open.value = newVal;
|
||||
});
|
||||
|
||||
watch(open, (newVal) => {
|
||||
emit('update:show', newVal);
|
||||
if (!newVal) {
|
||||
emit('close');
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faTrashCan" class="h-5 w-5 text-red-600" />
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div class="flex items-start gap-4 pt-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<p class="text-sm text-gray-700">
|
||||
{{ message }}
|
||||
</p>
|
||||
<p v-if="itemName" class="text-sm font-medium text-gray-900">
|
||||
{{ itemName }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Ta dejanje ni mogoče razveljaviti.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="onClose" :disabled="processing">
|
||||
{{ cancelText }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@click="onConfirm"
|
||||
:disabled="processing"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed, ref, watch, nextTick } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: "Uredi" },
|
||||
maxWidth: { type: String, default: "2xl" },
|
||||
confirmText: { type: String, default: "Shrani" },
|
||||
cancelText: { type: String, default: "Prekliči" },
|
||||
processing: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:show", "close", "confirm"]);
|
||||
|
||||
const open = ref(props.show);
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
open.value = newVal;
|
||||
if (newVal) {
|
||||
// Emit custom event when dialog opens
|
||||
nextTick(() => {
|
||||
window.dispatchEvent(new CustomEvent("dialog:open"));
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(open, (newVal) => {
|
||||
emit("update:show", newVal);
|
||||
if (!newVal) {
|
||||
emit("close");
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
emit("confirm");
|
||||
};
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
const maxWidthMap = {
|
||||
sm: "sm:max-w-sm",
|
||||
md: "sm:max-w-md",
|
||||
lg: "sm:max-w-lg",
|
||||
xl: "sm:max-w-xl",
|
||||
"2xl": "sm:max-w-2xl",
|
||||
wide: "sm:max-w-[1200px]",
|
||||
};
|
||||
return maxWidthMap[props.maxWidth] || "sm:max-w-2xl";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faPenToSquare" class="h-5 w-5 text-primary" />
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<slot name="description" />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="py-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="onClose" :disabled="processing">
|
||||
{{ cancelText }}
|
||||
</Button>
|
||||
<Button @click="onConfirm" :disabled="processing || disabled">
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,104 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/Components/ui/dialog';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: 'Opozorilo' },
|
||||
message: { type: String, default: 'Prosimo, bodite pozorni.' },
|
||||
confirmText: { type: String, default: 'Razumem' },
|
||||
cancelText: { type: String, default: 'Prekliči' },
|
||||
processing: { type: Boolean, default: false },
|
||||
maxWidth: { type: String, default: 'md' },
|
||||
showCancel: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'close', 'confirm']);
|
||||
|
||||
const open = ref(props.show);
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
open.value = newVal;
|
||||
});
|
||||
|
||||
watch(open, (newVal) => {
|
||||
emit('update:show', newVal);
|
||||
if (!newVal) {
|
||||
emit('close');
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
const maxWidthMap = {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-md',
|
||||
lg: 'sm:max-w-lg',
|
||||
xl: 'sm:max-w-xl',
|
||||
'2xl': 'sm:max-w-2xl',
|
||||
wide: 'sm:max-w-[1200px]',
|
||||
};
|
||||
return maxWidthMap[props.maxWidth] || 'sm:max-w-md';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="h-5 w-5 text-amber-600" />
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div class="flex items-start gap-4 pt-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-amber-100">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="h-6 w-6 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-700">
|
||||
{{ message }}
|
||||
</p>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button v-if="showCancel" variant="outline" @click="onClose" :disabled="processing">
|
||||
{{ cancelText }}
|
||||
</Button>
|
||||
<Button
|
||||
@click="onConfirm"
|
||||
:disabled="processing"
|
||||
class="bg-amber-600 hover:bg-amber-700 focus:ring-amber-500"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import DialogModal from '@/Components/DialogModal.vue'
|
||||
import InputLabel from '@/Components/InputLabel.vue'
|
||||
import TextInput from '@/Components/TextInput.vue'
|
||||
import { useForm } from '@inertiajs/vue3'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
client_case_uuid: { type: String, required: true },
|
||||
document: { type: Object, default: null },
|
||||
contracts: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
description: '',
|
||||
is_public: false,
|
||||
contract_uuid: null,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.document,
|
||||
(d) => {
|
||||
if (!d) return
|
||||
form.name = d.name || d.original_name || ''
|
||||
form.description = d.description || ''
|
||||
form.is_public = !!d.is_public
|
||||
// Pre-fill contract selection if this doc belongs to a contract
|
||||
const isContract = (d?.documentable_type || '').toLowerCase().includes('contract')
|
||||
form.contract_uuid = isContract ? d.contract_uuid || null : null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const submit = () => {
|
||||
if (!props.document) return
|
||||
form.patch(
|
||||
route('clientCase.document.update', {
|
||||
client_case: props.client_case_uuid,
|
||||
document: props.document.uuid,
|
||||
}),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
emit('saved')
|
||||
emit('close')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const contractOptions = computed(() => {
|
||||
return props.contracts || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" @close="$emit('close')">
|
||||
<template #title>Uredi dokument</template>
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<InputLabel for="docName" value="Ime" />
|
||||
<TextInput id="docName" v-model="form.name" class="mt-1 block w-full" />
|
||||
<div v-if="form.errors.name" class="text-sm text-red-600 mt-1">{{ form.errors.name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="docDesc" value="Opis" />
|
||||
<TextInput id="docDesc" v-model="form.description" class="mt-1 block w-full" />
|
||||
<div v-if="form.errors.description" class="text-sm text-red-600 mt-1">{{ form.errors.description }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="docPublic" type="checkbox" v-model="form.is_public" />
|
||||
<InputLabel for="docPublic" value="Javno" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="docContract" value="Pogodba" />
|
||||
<select id="docContract" v-model="form.contract_uuid" class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option :value="null">— Brez — (dok. pri primeru)</option>
|
||||
<option v-for="c in contractOptions" :key="c.uuid || c.id" :value="c.uuid">{{ c.reference || c.uuid }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.contract_uuid" class="text-sm text-red-600 mt-1">{{ form.errors.contract_uuid }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200" @click="$emit('close')">Prekliči</button>
|
||||
<button type="button" class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" :disabled="form.processing" @click="submit">Shrani</button>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import DialogModal from '@/Components/DialogModal.vue'
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue'
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue'
|
||||
import ActionMessage from '@/Components/ActionMessage.vue'
|
||||
import InputLabel from '@/Components/InputLabel.vue'
|
||||
import TextInput from '@/Components/TextInput.vue'
|
||||
import { useForm } from '@inertiajs/vue3'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
postUrl: { type: String, required: true },
|
||||
// Optional list of contracts to allow attaching the document directly to a contract
|
||||
// Each item should have at least: { uuid, reference }
|
||||
contracts: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['close', 'uploaded'])
|
||||
|
||||
const MAX_SIZE = 25 * 1024 * 1024 // 25MB
|
||||
const ALLOWED_EXTS = ['doc','docx','pdf','txt','csv','xls','xlsx','jpeg','jpg','png']
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
description: '',
|
||||
file: null,
|
||||
is_public: true,
|
||||
contract_uuid: null,
|
||||
})
|
||||
const localError = ref('')
|
||||
|
||||
watch(() => props.show, (v) => {
|
||||
if (!v) return
|
||||
localError.value = ''
|
||||
})
|
||||
|
||||
const onFileChange = (e) => {
|
||||
localError.value = ''
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) { form.file = null; return }
|
||||
const ext = (f.name.split('.').pop() || '').toLowerCase()
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
localError.value = 'Unsupported file type. Allowed: ' + ALLOWED_EXTS.join(', ')
|
||||
e.target.value = ''
|
||||
form.file = null
|
||||
return
|
||||
}
|
||||
if (f.size > MAX_SIZE) {
|
||||
localError.value = 'File is too large. Maximum size is 25MB.'
|
||||
e.target.value = ''
|
||||
form.file = null
|
||||
return
|
||||
}
|
||||
form.file = f
|
||||
if (!form.name) {
|
||||
form.name = f.name.replace(/\.[^.]+$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
localError.value = ''
|
||||
if (!form.file) {
|
||||
localError.value = 'Please choose a file.'
|
||||
return
|
||||
}
|
||||
const ext = (form.file.name.split('.').pop() || '').toLowerCase()
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
localError.value = 'Unsupported file type. Allowed: ' + ALLOWED_EXTS.join(', ')
|
||||
return
|
||||
}
|
||||
if (form.file.size > MAX_SIZE) {
|
||||
localError.value = 'File is too large. Maximum size is 25MB.'
|
||||
return
|
||||
}
|
||||
form.post(props.postUrl, {
|
||||
forceFormData: true,
|
||||
onSuccess: () => {
|
||||
emit('uploaded')
|
||||
close()
|
||||
form.reset()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const close = () => emit('close')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="props.show" @close="close" maxWidth="lg">
|
||||
<template #title>Dodaj dokument</template>
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<div v-if="props.contracts && props.contracts.length" class="grid grid-cols-1 gap-2">
|
||||
<InputLabel for="doc_attach" value="Pripiši k" />
|
||||
<select id="doc_attach" v-model="form.contract_uuid" class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option :value="null">Primer</option>
|
||||
<option v-for="c in props.contracts" :key="c.uuid" :value="c.uuid">Pogodba: {{ c.reference }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="doc_name" value="Name" />
|
||||
<TextInput id="doc_name" class="mt-1 block w-full" v-model="form.name" />
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="doc_desc" value="Description" />
|
||||
<textarea id="doc_desc" class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" rows="3" v-model="form.description"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="doc_file" value="File (max 25MB)" />
|
||||
<input id="doc_file" type="file" class="mt-1 block w-full" @change="onFileChange" accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png" />
|
||||
<div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" v-model="form.is_public" class="rounded" />
|
||||
Public
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex items-center gap-3">
|
||||
<ActionMessage :on="form.recentlySuccessful">Uploaded.</ActionMessage>
|
||||
<SecondaryButton type="button" @click="close">Cancel</SecondaryButton>
|
||||
<PrimaryButton :disabled="form.processing" @click="submit">Upload</PrimaryButton>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import DialogModal from '@/Components/DialogModal.vue'
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
src: { type: String, default: '' },
|
||||
title: { type: String, default: 'Document' }
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="props.show" @close="$emit('close')" maxWidth="4xl">
|
||||
<template #title>{{ props.title }}</template>
|
||||
<template #content>
|
||||
<div class="h-[70vh]">
|
||||
<iframe v-if="props.src" :src="props.src" class="w-full h-full rounded border" />
|
||||
<div v-else class="text-sm text-gray-500">No document to display.</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton type="button" @click="$emit('close')">Close</SecondaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
@@ -0,0 +1,411 @@
|
||||
<script setup>
|
||||
import {
|
||||
FwbTable,
|
||||
FwbTableHead,
|
||||
FwbTableHeadCell,
|
||||
FwbTableBody,
|
||||
FwbTableRow,
|
||||
FwbTableCell,
|
||||
FwbBadge,
|
||||
} from "flowbite-vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import {
|
||||
faFilePdf,
|
||||
faFileWord,
|
||||
faFileExcel,
|
||||
faFileLines,
|
||||
faFileImage,
|
||||
faFile,
|
||||
faCircleInfo,
|
||||
faEllipsisVertical,
|
||||
faDownload,
|
||||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { ref } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
|
||||
import SecondaryButton from "./SecondaryButton.vue";
|
||||
import DangerButton from "./DangerButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
documents: { type: Array, 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 },
|
||||
});
|
||||
// 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
|
||||
if (doc.documentable_type?.toLowerCase?.().includes("contract")) {
|
||||
return doc.contract_reference ? `Pogodba ${doc.contract_reference}` : "Pogodba";
|
||||
}
|
||||
return "Primer";
|
||||
};
|
||||
|
||||
const emit = defineEmits(["view", "download", "delete", "edit"]);
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes == null) return "-";
|
||||
const thresh = 1024;
|
||||
if (Math.abs(bytes) < thresh) return bytes + " B";
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let u = -1;
|
||||
do {
|
||||
bytes /= thresh;
|
||||
++u;
|
||||
} while (Math.abs(bytes) >= thresh && u < units.length - 1);
|
||||
return bytes.toFixed(1) + " " + units[u];
|
||||
};
|
||||
|
||||
const extFrom = (doc) => {
|
||||
let ext = (doc?.extension || "").toLowerCase();
|
||||
if (!ext && doc?.original_name) {
|
||||
const parts = String(doc.original_name).toLowerCase().split(".");
|
||||
if (parts.length > 1) ext = parts.pop();
|
||||
}
|
||||
// derive from mime
|
||||
if (!ext && doc?.mime_type) {
|
||||
const mime = String(doc.mime_type).toLowerCase();
|
||||
if (mime.includes("pdf")) ext = "pdf";
|
||||
else if (mime.includes("word") || mime.includes("msword") || mime.includes("doc"))
|
||||
ext = "docx";
|
||||
else if (mime.includes("excel") || mime.includes("sheet")) ext = "xlsx";
|
||||
else if (mime.includes("csv")) ext = "csv";
|
||||
else if (mime.startsWith("image/")) ext = "img";
|
||||
else if (mime.includes("text")) ext = "txt";
|
||||
}
|
||||
return ext;
|
||||
};
|
||||
|
||||
const fileTypeInfo = (doc) => {
|
||||
const ext = extFrom(doc);
|
||||
const mime = (doc?.mime_type || "").toLowerCase();
|
||||
switch (ext) {
|
||||
case "pdf":
|
||||
return { icon: faFilePdf, color: "text-red-600", label: "PDF" };
|
||||
case "doc":
|
||||
case "docx":
|
||||
return {
|
||||
icon: faFileWord,
|
||||
color: "text-blue-600",
|
||||
label: (ext || "DOCX").toUpperCase(),
|
||||
};
|
||||
case "xls":
|
||||
case "xlsx":
|
||||
return {
|
||||
icon: faFileExcel,
|
||||
color: "text-green-600",
|
||||
label: (ext || "XLSX").toUpperCase(),
|
||||
};
|
||||
case "csv":
|
||||
// treat CSV as spreadsheet-like
|
||||
return { icon: faFileExcel, color: "text-emerald-600", label: "CSV" };
|
||||
case "txt":
|
||||
return { icon: faFileLines, color: "text-slate-600", label: "TXT" };
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "png":
|
||||
case "img":
|
||||
return {
|
||||
icon: faFileImage,
|
||||
color: "text-fuchsia-600",
|
||||
label: ext === "img" ? "IMG" : (ext || "IMG").toUpperCase(),
|
||||
};
|
||||
default:
|
||||
if (mime.startsWith("image/"))
|
||||
return { icon: faFileImage, color: "text-fuchsia-600", label: "IMG" };
|
||||
return {
|
||||
icon: faFile,
|
||||
color: "text-gray-600",
|
||||
label: (ext || "FILE").toUpperCase(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const hasDesc = (doc) => {
|
||||
const d = doc?.description;
|
||||
return typeof d === "string" && d.trim().length > 0;
|
||||
};
|
||||
|
||||
const expandedDescKey = ref(null);
|
||||
const rowKey = (doc, i) => doc?.uuid ?? i;
|
||||
const toggleDesc = (doc, i) => {
|
||||
const key = rowKey(doc, i);
|
||||
expandedDescKey.value = expandedDescKey.value === key ? null : key;
|
||||
};
|
||||
|
||||
const resolveDownloadUrl = (doc) => {
|
||||
if (typeof props.downloadUrlBuilder === "function")
|
||||
return props.downloadUrlBuilder(doc);
|
||||
// If no builder provided, parent can handle via emitted event
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleDownload = (doc) => {
|
||||
const url = resolveDownloadUrl(doc);
|
||||
if (url) {
|
||||
// Trigger a navigation to a direct-download endpoint; server should set Content-Disposition: attachment
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.target = "_self";
|
||||
a.rel = "noopener";
|
||||
// In many browsers, simply setting href is enough
|
||||
a.click();
|
||||
} else {
|
||||
emit("download", doc);
|
||||
}
|
||||
closeActions();
|
||||
};
|
||||
|
||||
// ---------------- Delete logic ----------------
|
||||
const confirmDelete = ref(false);
|
||||
const deleting = ref(false);
|
||||
const docToDelete = ref(null);
|
||||
|
||||
const resolveDeleteUrl = (doc) => {
|
||||
// 1. Explicit builder via prop takes precedence
|
||||
if (typeof props.deleteUrlBuilder === "function") {
|
||||
return props.deleteUrlBuilder(doc);
|
||||
}
|
||||
// 2. Attempt automatic route resolution (requires Ziggy's global `route` helper)
|
||||
try {
|
||||
const type = (doc?.documentable_type || "").toLowerCase();
|
||||
// Contract document
|
||||
if (type.includes("contract") && doc?.contract_uuid && doc?.uuid) {
|
||||
if (typeof route === "function") {
|
||||
return route("contract.document.delete", {
|
||||
contract: doc.contract_uuid,
|
||||
document: doc.uuid,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Case document
|
||||
if (doc?.client_case_uuid && doc?.uuid) {
|
||||
if (typeof route === "function") {
|
||||
return route("clientCase.document.delete", {
|
||||
client_case: doc.client_case_uuid,
|
||||
document: doc.uuid,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// swallow – fallback to emit path
|
||||
}
|
||||
// 3. Fallback: no URL, caller must handle emitted event
|
||||
return null;
|
||||
};
|
||||
|
||||
const requestDelete = async () => {
|
||||
if (!docToDelete.value) {
|
||||
return;
|
||||
}
|
||||
const url = resolveDeleteUrl(docToDelete.value);
|
||||
deleting.value = true;
|
||||
try {
|
||||
if (url) {
|
||||
await router.delete(url, { preserveScroll: true });
|
||||
} else {
|
||||
emit("delete", docToDelete.value);
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
confirmDelete.value = false;
|
||||
docToDelete.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const askDelete = (doc) => {
|
||||
docToDelete.value = doc;
|
||||
confirmDelete.value = true;
|
||||
};
|
||||
|
||||
function closeActions() {
|
||||
/* noop placeholder for symmetry; Dropdown auto-closes */
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||
>
|
||||
<FwbTable hoverable striped class="text-sm">
|
||||
<FwbTableHead
|
||||
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
|
||||
>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||
>Naziv</FwbTableHeadCell
|
||||
>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||
>Vrsta</FwbTableHeadCell
|
||||
>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||
>Velikost</FwbTableHeadCell
|
||||
>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||
>Dodano</FwbTableHeadCell
|
||||
>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||
>Vir</FwbTableHeadCell
|
||||
>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-"
|
||||
>Opis</FwbTableHeadCell
|
||||
>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-"
|
||||
></FwbTableHeadCell>
|
||||
</FwbTableHead>
|
||||
<FwbTableBody>
|
||||
<template v-for="(doc, i) in documents" :key="doc.uuid || i">
|
||||
<FwbTableRow>
|
||||
<FwbTableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-indigo-600 hover:underline"
|
||||
@click="$emit('view', doc)"
|
||||
>
|
||||
{{ doc.name }}
|
||||
</button>
|
||||
<FwbBadge v-if="doc.is_public" type="green">Public</FwbBadge>
|
||||
</div>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
:icon="fileTypeInfo(doc).icon"
|
||||
:class="['h-5 w-5', fileTypeInfo(doc).color]"
|
||||
/>
|
||||
<span class="text-gray-700">{{ fileTypeInfo(doc).label }}</span>
|
||||
</div>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell>{{ formatSize(doc.size) }}</FwbTableCell>
|
||||
<FwbTableCell>{{ new Date(doc.created_at).toLocaleString() }}</FwbTableCell>
|
||||
<FwbTableCell>
|
||||
<FwbBadge type="purple">{{ sourceLabel(doc) }}</FwbBadge>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell class="text-center">
|
||||
<button
|
||||
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
:disabled="!hasDesc(doc)"
|
||||
:title="hasDesc(doc) ? 'Pokaži opis' : 'Ni opisa'"
|
||||
type="button"
|
||||
@click="toggleDesc(doc, i)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-700" />
|
||||
</button>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell class="text-right whitespace-nowrap">
|
||||
<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"
|
||||
:title="'Actions'"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faEllipsisVertical"
|
||||
class="h-4 w-4 text-gray-700"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
@click="emit('edit', doc)"
|
||||
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-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
@click="handleDownload(doc)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" />
|
||||
<span>Prenos</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
||||
@click="askDelete(doc)"
|
||||
v-if="edit"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4" />
|
||||
<span>Izbriši</span>
|
||||
</button>
|
||||
<!-- future actions can be slotted here -->
|
||||
</template>
|
||||
</Dropdown>
|
||||
</FwbTableCell>
|
||||
</FwbTableRow>
|
||||
<!-- Expanded description row directly below the item -->
|
||||
<FwbTableRow
|
||||
:key="'desc-' + (doc.uuid || i)"
|
||||
v-if="expandedDescKey === rowKey(doc, i)"
|
||||
>
|
||||
<FwbTableCell :colspan="6" class="bg-gray-50">
|
||||
<div
|
||||
class="px-4 py-3 text-sm text-gray-700 whitespace-pre-wrap border-l-2 border-indigo-400"
|
||||
>
|
||||
{{ doc.description }}
|
||||
</div>
|
||||
</FwbTableCell>
|
||||
</FwbTableRow>
|
||||
</template>
|
||||
</FwbTableBody>
|
||||
</FwbTable>
|
||||
<div
|
||||
v-if="!documents || documents.length === 0"
|
||||
class="p-6 text-center text-sm text-gray-500"
|
||||
>
|
||||
No documents.
|
||||
</div>
|
||||
<!-- Delete confirmation modal using shared component -->
|
||||
<ConfirmationModal
|
||||
:show="confirmDelete"
|
||||
:closeable="!deleting"
|
||||
max-width="md"
|
||||
@close="
|
||||
confirmDelete = false;
|
||||
docToDelete = null;
|
||||
"
|
||||
>
|
||||
<template #title>Potrditev</template>
|
||||
<template #content>
|
||||
Ali res želite izbrisati dokument
|
||||
<span class="font-medium">{{ docToDelete?.name }}</span
|
||||
>? Tega dejanja ni mogoče razveljaviti.
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
@click="
|
||||
confirmDelete = false;
|
||||
docToDelete = null;
|
||||
"
|
||||
:disabled="deleting"
|
||||
>Prekliči</SecondaryButton
|
||||
>
|
||||
<DangerButton
|
||||
:disabled="deleting"
|
||||
type="button"
|
||||
class="ml-2"
|
||||
@click="requestDelete"
|
||||
>{{ deleting ? "Brisanje…" : "Izbriši" }}</DangerButton
|
||||
>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,182 +0,0 @@
|
||||
<script setup>
|
||||
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/Components/ui/form'
|
||||
import { Input } from '@/Components/ui/input'
|
||||
import { Textarea } from '@/Components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select'
|
||||
import { Checkbox } from '@/Components/ui/checkbox'
|
||||
import { Switch } from '@/Components/ui/switch'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
client_case_uuid: { type: String, required: true },
|
||||
document: { type: Object, default: null },
|
||||
contracts: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
name: z.string().min(1, 'Ime je obvezno'),
|
||||
description: z.string().optional(),
|
||||
is_public: z.boolean().default(false),
|
||||
contract_uuid: z.string().nullable().optional(),
|
||||
}))
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
})
|
||||
|
||||
|
||||
const processing = ref(false)
|
||||
|
||||
const update = async () => {
|
||||
if (!props.document) return
|
||||
|
||||
processing.value = true
|
||||
const { values } = form
|
||||
|
||||
router.patch(
|
||||
route('clientCase.document.update', {
|
||||
client_case: props.client_case_uuid,
|
||||
document: props.document.uuid,
|
||||
}),
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
emit('saved')
|
||||
close()
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Map Inertia errors to VeeValidate field errors
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]]
|
||||
form.setFieldError(field, errorMessages[0])
|
||||
})
|
||||
processing.value = false
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
processing.value = false
|
||||
}
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
update()
|
||||
})
|
||||
|
||||
const onConfirm = () => {
|
||||
onSubmit()
|
||||
}
|
||||
|
||||
const contractOptions = computed(() => {
|
||||
return props.contracts || []
|
||||
})
|
||||
|
||||
// Watch for dialog opening and document changes
|
||||
watch(
|
||||
() => [props.show, props.document],
|
||||
() => {
|
||||
if (!props.show) {
|
||||
return
|
||||
}
|
||||
// When dialog opens, reset form with document values
|
||||
console.log((props.document?.documentable_type || '').toLowerCase().includes('contract') ? (props.document.contract_uuid || null) : null, props.document)
|
||||
if (props.document) {
|
||||
form.resetForm({
|
||||
values: {
|
||||
name: props.document.name || props.document.original_name || '',
|
||||
description: props.document.description || '',
|
||||
is_public: !!props.document.is_public,
|
||||
contract_uuid: (props.document?.documentable_type || '').toLowerCase().includes('contract') ? (props.document.contract_uuid || null) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UpdateDialog
|
||||
:show="show"
|
||||
title="Uredi dokument"
|
||||
:processing="processing"
|
||||
@close="close"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<form @submit.prevent="onSubmit" class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Ime</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="docName" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea id="docDesc" v-bind="componentField" rows="3" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="is_public">
|
||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<div class="space-y-1 leading-none">
|
||||
<FormLabel>Javno</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="contract_uuid">
|
||||
<FormItem>
|
||||
<FormLabel>Pogodba</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="— Brez — (dok. pri primeru)" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— Brez — (dok. pri primeru)</SelectItem>
|
||||
<SelectItem
|
||||
v-for="c in contractOptions"
|
||||
:key="c.uuid || c.id"
|
||||
:value="c.uuid"
|
||||
>
|
||||
{{ c.reference || c.uuid }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</form>
|
||||
</UpdateDialog>
|
||||
</template>
|
||||
@@ -1,218 +0,0 @@
|
||||
<script setup>
|
||||
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import { ref, watch } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/Components/ui/form'
|
||||
import { Input } from '@/Components/ui/input'
|
||||
import { Textarea } from '@/Components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select'
|
||||
import { Switch } from '@/Components/ui/switch'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
postUrl: { type: String, required: true },
|
||||
// Optional list of contracts to allow attaching the document directly to a contract
|
||||
// Each item should have at least: { uuid, reference }
|
||||
contracts: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['close', 'uploaded'])
|
||||
|
||||
const MAX_SIZE = 25 * 1024 * 1024 // 25MB
|
||||
const ALLOWED_EXTS = ['doc','docx','pdf','txt','csv','xls','xlsx','jpeg','jpg','png']
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
name: z.string().min(1, 'Ime je obvezno'),
|
||||
description: z.string().optional(),
|
||||
file: z.instanceof(File).refine((file) => file.size > 0, 'Izberite datoteko'),
|
||||
is_public: z.boolean().default(true),
|
||||
contract_uuid: z.string().nullable().optional(),
|
||||
}))
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
file: null,
|
||||
is_public: true,
|
||||
contract_uuid: null,
|
||||
},
|
||||
})
|
||||
|
||||
const localError = ref('')
|
||||
|
||||
watch(() => props.show, (v) => {
|
||||
if (!v) return
|
||||
localError.value = ''
|
||||
form.resetForm()
|
||||
})
|
||||
|
||||
const onFileChange = (e) => {
|
||||
localError.value = ''
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) {
|
||||
form.setFieldValue('file', null)
|
||||
return
|
||||
}
|
||||
const ext = (f.name.split('.').pop() || '').toLowerCase()
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
|
||||
e.target.value = ''
|
||||
form.setFieldValue('file', null)
|
||||
return
|
||||
}
|
||||
if (f.size > MAX_SIZE) {
|
||||
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
|
||||
e.target.value = ''
|
||||
form.setFieldValue('file', null)
|
||||
return
|
||||
}
|
||||
form.setFieldValue('file', f)
|
||||
if (!form.values.name) {
|
||||
form.setFieldValue('name', f.name.replace(/\.[^.]+$/, ''))
|
||||
}
|
||||
}
|
||||
|
||||
const submit = form.handleSubmit(async (values) => {
|
||||
localError.value = ''
|
||||
if (!values.file) {
|
||||
localError.value = 'Prosimo izberite datoteko.'
|
||||
return
|
||||
}
|
||||
const ext = (values.file.name.split('.').pop() || '').toLowerCase()
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
localError.value = 'Nepodprta vrsta datoteke. Dovoljeno: ' + ALLOWED_EXTS.join(', ')
|
||||
return
|
||||
}
|
||||
if (values.file.size > MAX_SIZE) {
|
||||
localError.value = 'Datoteka je prevelika. Največja velikost je 25MB.'
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('name', values.name)
|
||||
formData.append('description', values.description || '')
|
||||
formData.append('file', values.file)
|
||||
formData.append('is_public', values.is_public ? '1' : '0')
|
||||
if (values.contract_uuid) {
|
||||
formData.append('contract_uuid', values.contract_uuid)
|
||||
}
|
||||
|
||||
router.post(props.postUrl, formData, {
|
||||
forceFormData: true,
|
||||
onSuccess: () => {
|
||||
emit('uploaded')
|
||||
emit('close')
|
||||
form.resetForm()
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Set form errors if any
|
||||
if (errors.name) form.setFieldError('name', errors.name)
|
||||
if (errors.description) form.setFieldError('description', errors.description)
|
||||
if (errors.file) form.setFieldError('file', errors.file)
|
||||
if (errors.contract_uuid) form.setFieldError('contract_uuid', errors.contract_uuid)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const close = () => emit('close')
|
||||
|
||||
const onConfirm = () => {
|
||||
submit()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreateDialog
|
||||
:show="props.show"
|
||||
title="Dodaj dokument"
|
||||
max-width="lg"
|
||||
confirm-text="Naloži"
|
||||
:processing="!!form.isSubmitting.value"
|
||||
:disabled="!form.values.file"
|
||||
@close="close"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<FormField v-if="props.contracts && props.contracts.length" v-slot="{ value, handleChange }" name="contract_uuid">
|
||||
<FormItem>
|
||||
<FormLabel>Pripiši k</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Primer" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Primer</SelectItem>
|
||||
<SelectItem
|
||||
v-for="c in props.contracts"
|
||||
:key="c.uuid"
|
||||
:value="c.uuid"
|
||||
>
|
||||
Pogodba: {{ c.reference }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Ime</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="doc_name" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea id="doc_desc" v-bind="componentField" rows="3" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="file">
|
||||
<FormItem>
|
||||
<FormLabel>Datoteka (max 25MB)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="doc_file"
|
||||
type="file"
|
||||
@change="onFileChange"
|
||||
accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div>
|
||||
<div v-if="value" class="text-sm text-gray-600 mt-1">
|
||||
Izbrana datoteka: {{ value.name }} ({{ (value.size / 1024).toFixed(2) }} KB)
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="is_public">
|
||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<div class="space-y-1 leading-none">
|
||||
<FormLabel>Javno</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</form>
|
||||
</CreateDialog>
|
||||
</template>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/Components/ui/dialog'
|
||||
import { Button } from '@/Components/ui/button'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
src: { type: String, default: '' },
|
||||
title: { type: String, default: 'Dokument' }
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="(open) => !open && $emit('close')">
|
||||
<DialogContent class="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ props.title }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="h-[70vh]">
|
||||
<iframe v-if="props.src" :src="props.src" class="w-full h-full rounded border" />
|
||||
<div v-else class="text-sm text-gray-500">Ni dokumenta za prikaz.</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<Button type="button" variant="outline" @click="$emit('close')">Zapri</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,421 +0,0 @@
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import {
|
||||
faFilePdf,
|
||||
faFileWord,
|
||||
faFileExcel,
|
||||
faFileLines,
|
||||
faFileImage,
|
||||
faFile,
|
||||
faCircleInfo,
|
||||
faEllipsisVertical,
|
||||
faDownload,
|
||||
faTrash,
|
||||
faFileAlt,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { ref, computed } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
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, 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: "key", label: "#", sortable: false, 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
|
||||
if (doc.documentable_type?.toLowerCase?.().includes("contract")) {
|
||||
return doc.contract_reference ? `Pogodba ${doc.contract_reference}` : "Pogodba";
|
||||
}
|
||||
return "Primer";
|
||||
};
|
||||
|
||||
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;
|
||||
if (Math.abs(bytes) < thresh) return bytes + " B";
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let u = -1;
|
||||
do {
|
||||
bytes /= thresh;
|
||||
++u;
|
||||
} while (Math.abs(bytes) >= thresh && u < units.length - 1);
|
||||
return bytes.toFixed(1) + " " + units[u];
|
||||
};
|
||||
|
||||
const extFrom = (doc) => {
|
||||
let ext = (doc?.extension || "").toLowerCase();
|
||||
if (!ext && doc?.original_name) {
|
||||
const parts = String(doc.original_name).toLowerCase().split(".");
|
||||
if (parts.length > 1) ext = parts.pop();
|
||||
}
|
||||
// derive from mime
|
||||
if (!ext && doc?.mime_type) {
|
||||
const mime = String(doc.mime_type).toLowerCase();
|
||||
if (mime.includes("pdf")) ext = "pdf";
|
||||
else if (mime.includes("word") || mime.includes("msword") || mime.includes("doc"))
|
||||
ext = "docx";
|
||||
else if (mime.includes("excel") || mime.includes("sheet")) ext = "xlsx";
|
||||
else if (mime.includes("csv")) ext = "csv";
|
||||
else if (mime.startsWith("image/")) ext = "img";
|
||||
else if (mime.includes("text")) ext = "txt";
|
||||
}
|
||||
return ext;
|
||||
};
|
||||
|
||||
const fileTypeInfo = (doc) => {
|
||||
const ext = extFrom(doc);
|
||||
const mime = (doc?.mime_type || "").toLowerCase();
|
||||
switch (ext) {
|
||||
case "pdf":
|
||||
return { icon: faFilePdf, color: "text-red-600", label: "PDF" };
|
||||
case "doc":
|
||||
case "docx":
|
||||
return {
|
||||
icon: faFileWord,
|
||||
color: "text-blue-600",
|
||||
label: (ext || "DOCX").toUpperCase(),
|
||||
};
|
||||
case "xls":
|
||||
case "xlsx":
|
||||
return {
|
||||
icon: faFileExcel,
|
||||
color: "text-green-600",
|
||||
label: (ext || "XLSX").toUpperCase(),
|
||||
};
|
||||
case "csv":
|
||||
// treat CSV as spreadsheet-like
|
||||
return { icon: faFileExcel, color: "text-emerald-600", label: "CSV" };
|
||||
case "txt":
|
||||
return { icon: faFileLines, color: "text-slate-600", label: "TXT" };
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "png":
|
||||
case "img":
|
||||
return {
|
||||
icon: faFileImage,
|
||||
color: "text-fuchsia-600",
|
||||
label: ext === "img" ? "IMG" : (ext || "IMG").toUpperCase(),
|
||||
};
|
||||
default:
|
||||
if (mime.startsWith("image/"))
|
||||
return { icon: faFileImage, color: "text-fuchsia-600", label: "IMG" };
|
||||
return {
|
||||
icon: faFile,
|
||||
color: "text-gray-600",
|
||||
label: (ext || "FILE").toUpperCase(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const hasDesc = (doc) => {
|
||||
const d = doc?.description;
|
||||
return typeof d === "string" && d.trim().length > 0;
|
||||
};
|
||||
|
||||
const expandedDescKey = ref(null);
|
||||
const rowKey = (doc) => doc?.uuid ?? doc?.id ?? null;
|
||||
const toggleDesc = (doc) => {
|
||||
const key = rowKey(doc);
|
||||
if (!key) return;
|
||||
expandedDescKey.value = expandedDescKey.value === key ? null : key;
|
||||
};
|
||||
|
||||
// Track which documents have expanded descriptions
|
||||
const isExpanded = (doc) => {
|
||||
const key = rowKey(doc);
|
||||
return key ? expandedDescKey.value === key : false;
|
||||
};
|
||||
|
||||
const resolveDownloadUrl = (doc) => {
|
||||
if (typeof props.downloadUrlBuilder === "function")
|
||||
return props.downloadUrlBuilder(doc);
|
||||
// If no builder provided, parent can handle via emitted event
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleDownload = (doc) => {
|
||||
const url = resolveDownloadUrl(doc);
|
||||
if (url) {
|
||||
// Trigger a navigation to a direct-download endpoint; server should set Content-Disposition: attachment
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.target = "_self";
|
||||
a.rel = "noopener";
|
||||
// In many browsers, simply setting href is enough
|
||||
a.click();
|
||||
} else {
|
||||
emit("download", doc);
|
||||
}
|
||||
closeActions();
|
||||
};
|
||||
|
||||
// ---------------- Delete logic ----------------
|
||||
const confirmDelete = ref(false);
|
||||
const deleting = ref(false);
|
||||
const docToDelete = ref(null);
|
||||
|
||||
const resolveDeleteUrl = (doc) => {
|
||||
// 1. Explicit builder via prop takes precedence
|
||||
if (typeof props.deleteUrlBuilder === "function") {
|
||||
return props.deleteUrlBuilder(doc);
|
||||
}
|
||||
// 2. Attempt automatic route resolution (requires Ziggy's global `route` helper)
|
||||
try {
|
||||
const type = (doc?.documentable_type || "").toLowerCase();
|
||||
// Contract document
|
||||
if (type.includes("contract") && doc?.contract_uuid && doc?.uuid) {
|
||||
if (typeof route === "function") {
|
||||
return route("contract.document.delete", {
|
||||
contract: doc.contract_uuid,
|
||||
document: doc.uuid,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Case document
|
||||
if (doc?.client_case_uuid && doc?.uuid) {
|
||||
if (typeof route === "function") {
|
||||
return route("clientCase.document.delete", {
|
||||
client_case: doc.client_case_uuid,
|
||||
document: doc.uuid,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// swallow – fallback to emit path
|
||||
}
|
||||
// 3. Fallback: no URL, caller must handle emitted event
|
||||
return null;
|
||||
};
|
||||
|
||||
const requestDelete = async () => {
|
||||
if (!docToDelete.value) {
|
||||
return;
|
||||
}
|
||||
const url = resolveDeleteUrl(docToDelete.value);
|
||||
deleting.value = true;
|
||||
try {
|
||||
if (url) {
|
||||
await router.delete(url, { preserveScroll: true });
|
||||
} else {
|
||||
emit("delete", docToDelete.value);
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
confirmDelete.value = false;
|
||||
docToDelete.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const askDelete = (doc) => {
|
||||
docToDelete.value = doc;
|
||||
confirmDelete.value = true;
|
||||
};
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
confirmDelete.value = false;
|
||||
docToDelete.value = null;
|
||||
};
|
||||
|
||||
function closeActions() {
|
||||
/* noop placeholder for symmetry; Dropdown auto-closes */
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
: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"
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
row-key="uuid"
|
||||
empty-text="Ni dokumentov."
|
||||
>
|
||||
<template #toolbar-actions>
|
||||
<slot name="add" />
|
||||
</template>
|
||||
<!-- Key column -->
|
||||
<template #cell-key="{ row }">
|
||||
<Badge
|
||||
v-if="row.is_public"
|
||||
variant="secondary"
|
||||
class="bg-green-100 text-green-700 hover:bg-green-200 shrink-0"
|
||||
>Public</Badge
|
||||
>
|
||||
</template>
|
||||
<!-- Name column -->
|
||||
<template #cell-name="{ row }">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-indigo-600 hover:underline max-w-xs truncate"
|
||||
:title="row.name"
|
||||
@click.stop="$emit('view', row)"
|
||||
>
|
||||
{{ row.name.length > 15 ? row.name.substring(0, 15) + "..." : row.name }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Expanded description -->
|
||||
<div
|
||||
v-if="isExpanded(row)"
|
||||
class="mt-2 bg-gray-50 px-4 py-3 text-sm text-gray-700 whitespace-pre-wrap border-l-2 border-indigo-400 rounded"
|
||||
>
|
||||
{{ row.description }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Type column -->
|
||||
<template #cell-type="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
:icon="fileTypeInfo(row).icon"
|
||||
:class="['h-5 w-5', fileTypeInfo(row).color]"
|
||||
/>
|
||||
<span class="text-gray-700">{{ fileTypeInfo(row).label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Size column -->
|
||||
<template #cell-size="{ row }">
|
||||
{{ formatSize(row.size) }}
|
||||
</template>
|
||||
|
||||
<!-- Created at column -->
|
||||
<template #cell-created_at="{ row }">
|
||||
<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
|
||||
>
|
||||
</template>
|
||||
|
||||
<!-- Description column -->
|
||||
<template #cell-description="{ row }">
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
:disabled="!hasDesc(row)"
|
||||
:title="hasDesc(row) ? 'Pokaži opis' : 'Ni opisa'"
|
||||
type="button"
|
||||
@click.stop="toggleDesc(row)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Actions column -->
|
||||
<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>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<DeleteDialog
|
||||
:show="confirmDelete"
|
||||
title="Potrditev brisanja"
|
||||
:message="`Ali res želite izbrisati dokument '${docToDelete?.name}'?`"
|
||||
:item-name="docToDelete?.name"
|
||||
confirm-text="Izbriši"
|
||||
:processing="deleting"
|
||||
@close="closeDeleteDialog"
|
||||
@confirm="requestDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
// ListGroup components removed - using custom implementation
|
||||
import { FwbListGroup, FwbListGroupItem } from 'flowbite-vue';
|
||||
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import draggable from 'vuedraggable';
|
||||
|
||||
@@ -68,13 +68,13 @@ watch(
|
||||
group="actions"
|
||||
>
|
||||
<template #item="{element, index}">
|
||||
<li class="flex justify-between items-center p-2 bg-white border border-gray-200 rounded-md mb-1 hover:bg-gray-50 transition-colors">
|
||||
<fwb-list-group-item class="flex justify-between">
|
||||
<span class="text">{{ element.name }} </span>
|
||||
|
||||
<button type="button" class="cursor-pointer p-1 hover:bg-gray-100 rounded" @click="removeAt(index, containerOne)"><svg class="w-6 h-6 text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<i class=" cursor-pointer" @click="removeAt(index, containerOne)"><svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
|
||||
</svg></button>
|
||||
</li>
|
||||
</svg></i>
|
||||
</fwb-list-group-item>
|
||||
</template>
|
||||
</draggable>
|
||||
<draggable
|
||||
@@ -92,13 +92,13 @@ watch(
|
||||
group="actions"
|
||||
>
|
||||
<template #item="{element, index}">
|
||||
<li class="flex justify-between items-center p-2 bg-white border border-gray-200 rounded-md mb-1 hover:bg-gray-50 transition-colors">
|
||||
<fwb-list-group-item class="flex justify-between">
|
||||
<span class="text">{{ element.name }} </span>
|
||||
|
||||
<button type="button" class="cursor-pointer p-1 hover:bg-gray-100 rounded" @click="removeAt(index, containerOne)"><svg class="w-6 h-6 text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<i class="cursor-pointer" @click="removeAt(index, containerOne)"><svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
|
||||
</svg></button>
|
||||
</li>
|
||||
</svg></i>
|
||||
</fwb-list-group-item>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/Components/ui/dropdown-menu';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
align: {
|
||||
@@ -25,64 +20,64 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const open = ref(false);
|
||||
let open = ref(false);
|
||||
const triggerEl = ref(null);
|
||||
const panelEl = ref(null);
|
||||
const panelStyle = ref({ top: '0px', left: '0px' });
|
||||
|
||||
// Expose close method for parent components
|
||||
const close = () => {
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
close,
|
||||
open,
|
||||
});
|
||||
|
||||
// Close dropdown when dialog opens
|
||||
const handleDialogOpen = () => {
|
||||
if (open.value) {
|
||||
open.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for dialog opens using MutationObserver
|
||||
let observer = null;
|
||||
|
||||
onMounted(() => {
|
||||
// Listen for custom dialog open events
|
||||
window.addEventListener('dialog:open', handleDialogOpen);
|
||||
|
||||
// Watch for dialog state changes in the DOM
|
||||
observer = new MutationObserver((mutations) => {
|
||||
// Check if any dialog has data-state="open"
|
||||
const openDialogs = document.querySelectorAll('[data-state="open"]');
|
||||
const hasOpenDialog = Array.from(openDialogs).some((dialog) => {
|
||||
// Check if it's a dialog element (has role="dialog" or is DialogContent)
|
||||
const role = dialog.getAttribute('role');
|
||||
const isDialogContent = dialog.classList?.contains('DialogContent') ||
|
||||
dialog.querySelector('[role="dialog"]') ||
|
||||
dialog.closest('[role="dialog"]');
|
||||
|
||||
return role === 'dialog' || isDialogContent;
|
||||
});
|
||||
|
||||
if (hasOpenDialog && open.value) {
|
||||
handleDialogOpen();
|
||||
const closeOnEscape = (e) => {
|
||||
if (open.value && e.key === 'Escape') {
|
||||
open.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-state'],
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
const updatePosition = () => {
|
||||
const t = triggerEl.value;
|
||||
const p = panelEl.value;
|
||||
if (!t || !p) return;
|
||||
const rect = t.getBoundingClientRect();
|
||||
// Ensure we have updated width
|
||||
const pw = p.offsetWidth || 0;
|
||||
const ph = p.offsetHeight || 0;
|
||||
const margin = 8; // small spacing from trigger
|
||||
let left = rect.left;
|
||||
if (props.align === 'right') {
|
||||
left = rect.right - pw;
|
||||
} else if (props.align === 'left') {
|
||||
left = rect.left;
|
||||
}
|
||||
// Clamp within viewport
|
||||
const maxLeft = Math.max(0, window.innerWidth - pw - margin);
|
||||
left = Math.min(Math.max(margin, left), maxLeft);
|
||||
let top = rect.bottom + margin;
|
||||
// If not enough space below, place above the trigger
|
||||
if (top + ph > window.innerHeight) {
|
||||
top = Math.max(margin, rect.top - ph - margin);
|
||||
}
|
||||
panelStyle.value = { top: `${top}px`, left: `${left}px` };
|
||||
};
|
||||
|
||||
const onWindowChange = () => {
|
||||
updatePosition();
|
||||
};
|
||||
|
||||
watch(open, async (val) => {
|
||||
if (val) {
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
window.addEventListener('resize', onWindowChange);
|
||||
window.addEventListener('scroll', onWindowChange, true);
|
||||
} else {
|
||||
window.removeEventListener('resize', onWindowChange);
|
||||
window.removeEventListener('scroll', onWindowChange, true);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('dialog:open', handleDialogOpen);
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
window.removeEventListener('resize', onWindowChange);
|
||||
window.removeEventListener('scroll', onWindowChange, true);
|
||||
});
|
||||
|
||||
const widthClass = computed(() => {
|
||||
@@ -95,38 +90,57 @@ const widthClass = computed(() => {
|
||||
'wide': 'w-[34rem] max-w-[90vw]',
|
||||
'auto': '',
|
||||
};
|
||||
return map[props.width.toString()] || '';
|
||||
return map[props.width.toString()] ?? '';
|
||||
});
|
||||
|
||||
// Map align prop to shadcn-vue's align prop
|
||||
// 'left' -> 'start', 'right' -> 'end'
|
||||
const alignProp = computed(() => {
|
||||
if (props.align === 'left') return 'start';
|
||||
if (props.align === 'right') return 'end';
|
||||
return 'start';
|
||||
});
|
||||
|
||||
const combinedContentClasses = computed(() => {
|
||||
// Merge width class with custom content classes
|
||||
// Note: shadcn-vue already provides base styling, so we append custom classes
|
||||
const classes = [widthClass.value];
|
||||
if (props.contentClasses && props.contentClasses.length) {
|
||||
classes.push(...props.contentClasses);
|
||||
const alignmentClasses = computed(() => {
|
||||
if (props.align === 'left') {
|
||||
return 'ltr:origin-top-left rtl:origin-top-right start-0';
|
||||
}
|
||||
return classes.filter(Boolean).join(' ');
|
||||
|
||||
if (props.align === 'right') {
|
||||
return 'ltr:origin-top-right rtl:origin-top-left end-0';
|
||||
}
|
||||
|
||||
return 'origin-top';
|
||||
});
|
||||
const onContentClick = () => {
|
||||
if (props.closeOnContentClick) {
|
||||
open.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu v-model:open="open">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<div class="relative" ref="triggerEl">
|
||||
<div @click="open = ! open">
|
||||
<slot name="trigger" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
:align="alignProp"
|
||||
:class="combinedContentClasses"
|
||||
>
|
||||
<slot name="content" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<teleport to="body">
|
||||
<!-- Full Screen Dropdown Overlay at body level -->
|
||||
<div v-show="open" class="fixed inset-0 z-[2147483646]" @click="open = false" />
|
||||
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="open"
|
||||
ref="panelEl"
|
||||
class="fixed z-[2147483647] rounded-md shadow-lg"
|
||||
:class="[widthClass]"
|
||||
:style="[panelStyle]"
|
||||
>
|
||||
<div class="rounded-md ring-1 ring-black ring-opacity-5" :class="contentClasses" @click="onContentClick">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup>
|
||||
import { computed, watch } from "vue";
|
||||
import DialogModal from "./DialogModal.vue";
|
||||
import InputLabel from "./InputLabel.vue";
|
||||
import SectionTitle from "./SectionTitle.vue";
|
||||
import TextInput from "./TextInput.vue";
|
||||
import InputError from "./InputError.vue";
|
||||
import PrimaryButton from "./PrimaryButton.vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
|
||||
/*
|
||||
EmailCreateForm / Email editor
|
||||
- Props mirror Phone/Address forms for consistency
|
||||
- Routes assumed: person.email.create, person.email.update
|
||||
- Adjust route names/fields to match your backend if different
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
person: { type: Object, required: true },
|
||||
// kept for parity with other *CreateForm components; not used directly here
|
||||
types: { type: Array, default: () => [] },
|
||||
edit: { type: Boolean, default: false },
|
||||
id: { type: Number, default: 0 },
|
||||
// When true, force-show the auto mail opt-in even if person.client wasn't eager loaded
|
||||
isClientContext: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
// Inertia useForm handles processing and errors for us
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const close = () => {
|
||||
emit("close");
|
||||
// Clear validation errors and reset minimal fields after closing so the form reopens cleanly
|
||||
setTimeout(() => {
|
||||
form.clearErrors();
|
||||
form.reset("value", "label", "receive_auto_mails");
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
value: "",
|
||||
label: "",
|
||||
receive_auto_mails: false,
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
form.reset("value", "label", "receive_auto_mails");
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
form.post(route("person.email.create", props.person), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
form.put(route("person.email.update", { person: props.person, email_id: props.id }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (!newVal) {
|
||||
return;
|
||||
}
|
||||
if (props.edit && props.id) {
|
||||
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
|
||||
const email = list.find((e) => e.id === props.id);
|
||||
if (email) {
|
||||
form.value = email.value ?? email.email ?? email.address ?? "";
|
||||
form.label = email.label ?? "";
|
||||
form.receive_auto_mails = !!email.receive_auto_mails;
|
||||
} else {
|
||||
form.reset("value", "label", "receive_auto_mails");
|
||||
}
|
||||
} else {
|
||||
form.reset("value", "label", "receive_auto_mails");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const submit = () => (props.edit ? update() : create());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
<template #title>
|
||||
<span v-if="edit">Spremeni email</span>
|
||||
<span v-else>Dodaj email</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<form @submit.prevent="submit">
|
||||
<SectionTitle class="border-b mb-4">
|
||||
<template #title>Email</template>
|
||||
</SectionTitle>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="em_value" value="E-pošta" />
|
||||
<TextInput
|
||||
id="em_value"
|
||||
v-model="form.value"
|
||||
type="email"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="email"
|
||||
/>
|
||||
<InputError
|
||||
v-if="form.errors.value"
|
||||
v-for="err in [].concat(form.errors.value || [])"
|
||||
:key="err"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="em_label" value="Oznaka (neobvezno)" />
|
||||
<TextInput
|
||||
id="em_label"
|
||||
v-model="form.label"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<InputError
|
||||
v-if="form.errors.label"
|
||||
v-for="err in [].concat(form.errors.label || [])"
|
||||
:key="err"
|
||||
:message="err"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.person?.client || isClientContext"
|
||||
class="mt-3 flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
id="em_receive_auto_mails"
|
||||
type="checkbox"
|
||||
v-model="form.receive_auto_mails"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
|
||||
/>
|
||||
<label for="em_receive_auto_mails" class="text-sm"
|
||||
>Prejemaj samodejna e-sporočila</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<PrimaryButton
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
>Shrani</PrimaryButton
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
@@ -1,124 +0,0 @@
|
||||
<script setup>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: [String, Object, Array],
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Ni podatkov',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
action: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md', // sm, md, lg
|
||||
validator: (value) => ['sm', 'md', 'lg'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
const sizes = {
|
||||
sm: {
|
||||
icon: 'text-4xl',
|
||||
title: 'text-base',
|
||||
description: 'text-sm',
|
||||
container: 'py-8',
|
||||
},
|
||||
md: {
|
||||
icon: 'text-5xl',
|
||||
title: 'text-lg',
|
||||
description: 'text-sm',
|
||||
container: 'py-12',
|
||||
},
|
||||
lg: {
|
||||
icon: 'text-6xl',
|
||||
title: 'text-xl',
|
||||
description: 'text-base',
|
||||
container: 'py-16',
|
||||
},
|
||||
};
|
||||
return sizes[props.size];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center text-center"
|
||||
:class="sizeClasses.container"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div
|
||||
v-if="icon"
|
||||
class="mb-4 text-gray-400"
|
||||
:class="sizeClasses.icon"
|
||||
>
|
||||
<FontAwesomeIcon :icon="icon" />
|
||||
</div>
|
||||
<!-- Default icon if none provided -->
|
||||
<div
|
||||
v-else
|
||||
class="mb-4 text-gray-400"
|
||||
:class="sizeClasses.icon"
|
||||
>
|
||||
<svg
|
||||
class="mx-auto"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h3
|
||||
class="font-medium text-gray-900 mb-2"
|
||||
:class="sizeClasses.title"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
v-if="description"
|
||||
class="text-gray-500 max-w-sm mb-6"
|
||||
:class="sizeClasses.description"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- Action button -->
|
||||
<div v-if="action">
|
||||
<component
|
||||
:is="action.to ? 'Link' : 'button'"
|
||||
:href="action.to"
|
||||
@click="action.onClick"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
v-if="action.icon"
|
||||
:icon="action.icon"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{{ action.label }}
|
||||
</component>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { LucideIcon } from "lucide-vue-next";
|
||||
import { ChevronRight } from "lucide-vue-next";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/Components/ui/collapsible";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
defineProps<{
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<Collapsible
|
||||
v-for="item in items"
|
||||
:key="item.title"
|
||||
as-child
|
||||
:default-open="item.isActive"
|
||||
class="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="item.title">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
<ChevronRight
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
|
||||
<SidebarMenuSubButton as-child>
|
||||
<a :href="subItem.url">
|
||||
<span>{{ subItem.title }}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</template>
|
||||
@@ -22,27 +22,19 @@ const showSlot = ref(props.show);
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
() => {
|
||||
if (props.show) {
|
||||
document.body.style.overflow = "hidden";
|
||||
showSlot.value = true;
|
||||
// Use nextTick to ensure dialog ref is available
|
||||
setTimeout(() => {
|
||||
if (dialog.value) {
|
||||
dialog.value.showModal();
|
||||
}
|
||||
}, 0);
|
||||
dialog.value?.showModal();
|
||||
} else {
|
||||
document.body.style.overflow = null;
|
||||
setTimeout(() => {
|
||||
if (dialog.value) {
|
||||
dialog.value.close();
|
||||
}
|
||||
dialog.value?.close();
|
||||
showSlot.value = false;
|
||||
}, 200);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
}
|
||||
);
|
||||
|
||||
const close = () => {
|
||||
|
||||
@@ -1,319 +1,200 @@
|
||||
<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 {
|
||||
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";
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
import { computed } from "vue";
|
||||
|
||||
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;
|
||||
|
||||
const prevLink = computed(() => {
|
||||
if (num > 0 && props.links && Array.isArray(props.links) && props.links[0]) {
|
||||
return props.links[0];
|
||||
}
|
||||
return null;
|
||||
const prevLink = computed(() => (num > 0 ? props.links[0] : null));
|
||||
const nextLink = computed(() => (num > 1 ? props.links[num - 1] : null));
|
||||
const numericLinks = computed(() => {
|
||||
if (num < 3) return [];
|
||||
return props.links
|
||||
.slice(1, num - 1)
|
||||
.map((l) => ({
|
||||
...l,
|
||||
page: Number.parseInt(String(l.label).replace(/[^0-9]/g, ""), 10),
|
||||
}))
|
||||
.filter((l) => !Number.isNaN(l.page));
|
||||
});
|
||||
const nextLink = computed(() => {
|
||||
if (num > 1 && props.links && Array.isArray(props.links) && props.links[num - 1]) {
|
||||
return props.links[num - 1];
|
||||
}
|
||||
return null;
|
||||
const currentPage = computed(() => numericLinks.value.find((l) => l.active)?.page || 1);
|
||||
const lastPage = computed(() =>
|
||||
numericLinks.value.length ? Math.max(...numericLinks.value.map((l) => l.page)) : 1
|
||||
);
|
||||
const linkByPage = computed(() => {
|
||||
const m = new Map();
|
||||
for (const l of numericLinks.value) m.set(l.page, l);
|
||||
return m;
|
||||
});
|
||||
const firstLink = computed(() => {
|
||||
if (num < 3 || !props.links || !Array.isArray(props.links)) return null;
|
||||
// Find the first numeric link (page 1)
|
||||
for (let i = 1; i < num - 1; i++) {
|
||||
const link = props.links[i];
|
||||
if (!link) continue;
|
||||
const page = Number.parseInt(String(link?.label || "").replace(/[^0-9]/g, ""), 10);
|
||||
if (page === 1) return link;
|
||||
const windowItems = computed(() => {
|
||||
const items = [];
|
||||
const cur = currentPage.value;
|
||||
const last = lastPage.value;
|
||||
const show = new Set([1, last, cur - 1, cur, cur + 1]);
|
||||
if (cur <= 3) {
|
||||
show.add(2);
|
||||
show.add(3);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const lastLink = computed(() => {
|
||||
if (num < 3 || !props.links || !Array.isArray(props.links)) return null;
|
||||
// Find the last numeric link
|
||||
let maxPage = 0;
|
||||
let maxLink = null;
|
||||
for (let i = 1; i < num - 1; i++) {
|
||||
const link = props.links[i];
|
||||
if (!link) continue;
|
||||
const page = Number.parseInt(String(link?.label || "").replace(/[^0-9]/g, ""), 10);
|
||||
if (!Number.isNaN(page) && page > maxPage) {
|
||||
maxPage = page;
|
||||
maxLink = link;
|
||||
if (cur >= last - 2) {
|
||||
show.add(last - 1);
|
||||
show.add(last - 2);
|
||||
}
|
||||
|
||||
// Prev
|
||||
items.push({ kind: "prev", link: prevLink.value });
|
||||
|
||||
// Pages with ellipses
|
||||
let inGap = false;
|
||||
for (let p = 1; p <= last; p++) {
|
||||
if (show.has(p)) {
|
||||
items.push({
|
||||
kind: "page",
|
||||
link: linkByPage.value.get(p) || {
|
||||
url: null,
|
||||
label: String(p),
|
||||
active: p === cur,
|
||||
},
|
||||
});
|
||||
inGap = false;
|
||||
} else if (!inGap) {
|
||||
items.push({ kind: "ellipsis" });
|
||||
inGap = true;
|
||||
}
|
||||
}
|
||||
return maxLink;
|
||||
|
||||
// Next
|
||||
items.push({ kind: "next", link: nextLink.value });
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// Generate visible page numbers with ellipsis (similar to DataTableClient)
|
||||
const visiblePages = computed(() => {
|
||||
const pages = [];
|
||||
const total = props.lastPage;
|
||||
const current = props.currentPage;
|
||||
const maxVisible = 5;
|
||||
|
||||
if (total <= maxVisible) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
// Calculate window around current page
|
||||
const half = Math.floor(maxVisible / 2);
|
||||
let start = Math.max(1, current - half);
|
||||
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("");
|
||||
|
||||
// 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 > props.lastPage) {
|
||||
gotoInput.value = "";
|
||||
return;
|
||||
}
|
||||
navigateToPage(n);
|
||||
gotoInput.value = "";
|
||||
}
|
||||
|
||||
function handleKeyPress(event) {
|
||||
if (event.key === "Enter") {
|
||||
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 px-2 text-sm text-gray-700 sm:px-1"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<!-- Mobile: Simple prev/next -->
|
||||
<div class="flex items-center justify-between bg-white px-4 py-3 sm:px-6">
|
||||
<!-- Mobile: Prev / Next -->
|
||||
<div class="flex flex-1 justify-between sm:hidden">
|
||||
<button
|
||||
v-if="prevLink?.url"
|
||||
@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"
|
||||
<component
|
||||
:is="links?.[0]?.url ? Link : 'span'"
|
||||
:href="links?.[0]?.url"
|
||||
:aria-disabled="!links?.[0]?.url"
|
||||
:tabindex="!links?.[0]?.url ? -1 : 0"
|
||||
class="relative inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium"
|
||||
:class="
|
||||
links?.[0]?.url
|
||||
? 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
||||
: 'border-gray-200 bg-gray-100 text-gray-400'
|
||||
"
|
||||
>
|
||||
Prejšnja
|
||||
</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>
|
||||
<button
|
||||
v-if="nextLink?.url"
|
||||
@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"
|
||||
</component>
|
||||
<component
|
||||
:is="links?.[num - 1]?.url ? Link : 'span'"
|
||||
:href="links?.[num - 1]?.url"
|
||||
:aria-disabled="!links?.[num - 1]?.url"
|
||||
:tabindex="!links?.[num - 1]?.url ? -1 : 0"
|
||||
class="relative ml-3 inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium"
|
||||
:class="
|
||||
links?.[num - 1]?.url
|
||||
? 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
||||
: 'border-gray-200 bg-gray-100 text-gray-400'
|
||||
"
|
||||
>
|
||||
Naslednja
|
||||
</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"
|
||||
>
|
||||
Naslednja
|
||||
</span>
|
||||
</component>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Full pagination -->
|
||||
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<!-- 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>
|
||||
<p class="text-sm text-gray-700">
|
||||
Showing
|
||||
<span class="font-medium">{{ from }}</span>
|
||||
to
|
||||
<span class="font-medium">{{ to }}</span>
|
||||
of
|
||||
<span class="font-medium">{{ total }}</span>
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
<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 -->
|
||||
<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>
|
||||
|
||||
<!-- Previous -->
|
||||
<PaginationPrevious
|
||||
:disabled="currentPage <= 1"
|
||||
@click="navigateToPage(currentPage - 1)"
|
||||
>
|
||||
<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)"
|
||||
<div>
|
||||
<nav
|
||||
class="isolate inline-flex -space-x-px rounded-md shadow-sm"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<template v-for="(item, idx) in windowItems" :key="idx">
|
||||
<!-- Prev / Next -->
|
||||
<component
|
||||
v-if="item.kind === 'prev' || item.kind === 'next'"
|
||||
:is="item.link?.url ? Link : 'span'"
|
||||
:href="item.link?.url"
|
||||
class="relative inline-flex items-center px-2 py-2 ring-1 ring-inset ring-gray-300 focus:z-20 focus:outline-offset-0"
|
||||
:class="{
|
||||
'rounded-l-md': item.kind === 'prev',
|
||||
'rounded-r-md': item.kind === 'next',
|
||||
'text-gray-900 hover:bg-gray-50': item.link?.url,
|
||||
'text-gray-400 bg-gray-100': !item.link?.url,
|
||||
}"
|
||||
>
|
||||
{{ item }}
|
||||
</PaginationItem>
|
||||
<span class="sr-only">{{
|
||||
item.kind === "prev" ? "Prejšnja" : "Naslednja"
|
||||
}}</span>
|
||||
<svg
|
||||
v-if="item.kind === 'prev'"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</component>
|
||||
|
||||
<!-- Ellipsis -->
|
||||
<span
|
||||
v-else-if="item.kind === 'ellipsis'"
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-500 ring-1 ring-inset ring-gray-200 select-none"
|
||||
>…</span
|
||||
>
|
||||
|
||||
<!-- Page number -->
|
||||
<component
|
||||
v-else-if="item.kind === 'page'"
|
||||
:is="item.link?.url ? Link : 'span'"
|
||||
:href="item.link?.url"
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:outline-offset-0"
|
||||
:class="{
|
||||
'text-gray-700 ring-1 ring-inset ring-gray-300': !item.link?.url,
|
||||
'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20':
|
||||
item.link?.url && !item.link?.active,
|
||||
'z-10 bg-blue-600 text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600':
|
||||
item.link?.active,
|
||||
}"
|
||||
>
|
||||
{{ item.link?.label || "" }}
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<!-- Next -->
|
||||
<PaginationNext
|
||||
:disabled="currentPage >= lastPage"
|
||||
@click="navigateToPage(currentPage + 1)"
|
||||
>
|
||||
<ChevronRight />
|
||||
</PaginationNext>
|
||||
|
||||
<!-- Last -->
|
||||
<PaginationLast
|
||||
:disabled="currentPage >= lastPage"
|
||||
@click="navigateToPage(lastPage)"
|
||||
>
|
||||
<ChevronsRight />
|
||||
</PaginationLast>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<input
|
||||
v-model="gotoInput"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="lastPage"
|
||||
inputmode="numeric"
|
||||
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"
|
||||
/>
|
||||
<Separator orientation="vertical" class="h-full" />
|
||||
<span class="text-sm text-muted-foreground">{{ lastPage }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { useForm, Field as FormField } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
person: Object,
|
||||
types: Array,
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
id: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
address: z.string().min(1, "Naslov je obvezen."),
|
||||
country: z.string().optional(),
|
||||
post_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
type_id: z.number().nullable(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
address: "",
|
||||
country: "",
|
||||
post_code: "",
|
||||
city: "",
|
||||
type_id: props.types?.[0]?.id ?? null,
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
const processing = ref(false);
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const close = () => {
|
||||
emit("close");
|
||||
setTimeout(() => {
|
||||
form.resetForm();
|
||||
processing.value = false;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.resetForm({
|
||||
values: {
|
||||
address: "",
|
||||
country: "",
|
||||
post_code: "",
|
||||
city: "",
|
||||
type_id: props.types?.[0]?.id ?? null,
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.id,
|
||||
(id) => {
|
||||
if (props.edit && id !== 0) {
|
||||
const a = props.person.addresses?.find((x) => x.id === id);
|
||||
if (a) {
|
||||
form.setValues({
|
||||
address: a.address || "",
|
||||
country: a.country || "",
|
||||
post_code: a.post_code || a.postal_code || "",
|
||||
city: a.city || "",
|
||||
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
|
||||
description: a.description || "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
resetForm();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val && props.edit && props.id) {
|
||||
const a = props.person.addresses?.find((x) => x.id === props.id);
|
||||
if (a) {
|
||||
form.setValues({
|
||||
address: a.address || "",
|
||||
country: a.country || "",
|
||||
post_code: a.post_code || a.postal_code || "",
|
||||
city: a.city || "",
|
||||
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
|
||||
description: a.description || "",
|
||||
});
|
||||
}
|
||||
} else if (val && !props.edit) {
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
const create = async () => {
|
||||
processing.value = true;
|
||||
const { values } = form;
|
||||
|
||||
router.post(
|
||||
route("person.address.create", props.person),
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processing.value = false;
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true;
|
||||
const { values } = form;
|
||||
|
||||
router.put(
|
||||
route("person.address.update", { person: props.person, address_id: props.id }),
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processing.value = false;
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const callSubmit = () => {
|
||||
if (props.edit) {
|
||||
update();
|
||||
} else {
|
||||
create();
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(() => {
|
||||
callSubmit();
|
||||
});
|
||||
|
||||
const onConfirm = () => {
|
||||
onSubmit();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="edit ? UpdateDialog : CreateDialog"
|
||||
:show="show"
|
||||
:title="edit ? 'Spremeni naslov' : 'Dodaj novi naslov'"
|
||||
confirm-text="Shrani"
|
||||
:processing="processing"
|
||||
@close="close"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<SectionTitle class="border-b mb-4">
|
||||
<template #title> Naslov </template>
|
||||
</SectionTitle>
|
||||
|
||||
<div class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="address">
|
||||
<FormItem>
|
||||
<FormLabel>Naslov</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="country">
|
||||
<FormItem>
|
||||
<FormLabel>Država</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="post_code">
|
||||
<FormItem>
|
||||
<FormLabel>Poštna številka</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="city">
|
||||
<FormItem>
|
||||
<FormLabel>Mesto</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="type_id">
|
||||
<FormItem>
|
||||
<FormLabel>Tip</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi tip" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="type in types" :key="type.id" :value="type.id">
|
||||
{{ type.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</form>
|
||||
</component>
|
||||
</template>
|
||||
@@ -1,217 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { useForm, Field as FormField } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
person: Object,
|
||||
types: Array,
|
||||
id: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const processing = ref(false);
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
address: z.string().min(1, "Naslov je obvezen."),
|
||||
country: z.string().optional(),
|
||||
post_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
type_id: z.number().nullable(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
address: "",
|
||||
country: "",
|
||||
post_code: "",
|
||||
city: "",
|
||||
type_id: props.types?.[0]?.id ?? null,
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
emit("close");
|
||||
setTimeout(() => {
|
||||
form.resetForm();
|
||||
processing.value = false;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.resetForm({
|
||||
values: {
|
||||
address: "",
|
||||
country: "",
|
||||
post_code: "",
|
||||
city: "",
|
||||
type_id: props.types?.[0]?.id ?? null,
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const hydrate = () => {
|
||||
const id = props.id;
|
||||
if (id) {
|
||||
const a = (props.person.addresses || []).find((x) => x.id === id);
|
||||
if (a) {
|
||||
form.setValues({
|
||||
address: a.address || "",
|
||||
country: a.country || "",
|
||||
post_code: a.post_code || a.postal_code || "",
|
||||
city: a.city || "",
|
||||
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
|
||||
description: a.description || "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
resetForm();
|
||||
};
|
||||
|
||||
watch(() => props.id, () => hydrate(), { immediate: true });
|
||||
watch(() => props.show, (v) => {
|
||||
if (v) hydrate();
|
||||
});
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true;
|
||||
const { values } = form;
|
||||
|
||||
router.put(
|
||||
route("person.address.update", { person: props.person, address_id: props.id }),
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
processing.value = false;
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(() => {
|
||||
update();
|
||||
});
|
||||
|
||||
const onConfirm = () => {
|
||||
onSubmit();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UpdateDialog
|
||||
:show="show"
|
||||
title="Spremeni naslov"
|
||||
confirm-text="Shrani"
|
||||
:processing="processing"
|
||||
@close="close"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<SectionTitle class="border-b mb-4">
|
||||
<template #title>Naslov</template>
|
||||
</SectionTitle>
|
||||
|
||||
<div class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="address">
|
||||
<FormItem>
|
||||
<FormLabel>Naslov</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="country">
|
||||
<FormItem>
|
||||
<FormLabel>Država</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="post_code">
|
||||
<FormItem>
|
||||
<FormLabel>Poštna številka</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="city">
|
||||
<FormItem>
|
||||
<FormLabel>Mesto</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="type_id">
|
||||
<FormItem>
|
||||
<FormLabel>Tip</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi tip" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="type in types" :key="type.id" :value="type.id">
|
||||
{{ type.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</form>
|
||||
</UpdateDialog>
|
||||
</template>
|
||||
@@ -1,234 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useForm, Field as FormField } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import CreateDialog from "../Dialogs/CreateDialog.vue";
|
||||
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
|
||||
import SectionTitle from "../SectionTitle.vue";
|
||||
import {
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/Components/ui/form";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Switch } from "@/Components/ui/switch";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
person: { type: Object, required: true },
|
||||
types: { type: Array, default: () => [] },
|
||||
edit: { type: Boolean, default: false },
|
||||
id: { type: Number, default: 0 },
|
||||
isClientContext: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
// Zod schema for form validation
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
|
||||
label: z.string().optional(),
|
||||
receive_auto_mails: z.boolean().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
// VeeValidate form
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
value: "",
|
||||
label: "",
|
||||
receive_auto_mails: false,
|
||||
},
|
||||
});
|
||||
|
||||
const processing = ref(false);
|
||||
|
||||
const close = () => {
|
||||
emit("close");
|
||||
setTimeout(() => {
|
||||
form.resetForm();
|
||||
processing.value = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.resetForm({
|
||||
values: {
|
||||
value: "",
|
||||
label: "",
|
||||
receive_auto_mails: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
processing.value = true;
|
||||
const { values } = form;
|
||||
|
||||
router.post(
|
||||
route("person.email.create", props.person),
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Map Inertia errors to VeeValidate field errors
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
processing.value = true;
|
||||
const { values } = form;
|
||||
|
||||
router.put(
|
||||
route("person.email.update", { person: props.person, email_id: props.id }),
|
||||
values,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
close();
|
||||
resetForm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Map Inertia errors to VeeValidate field errors
|
||||
Object.keys(errors).forEach((field) => {
|
||||
const errorMessages = Array.isArray(errors[field])
|
||||
? errors[field]
|
||||
: [errors[field]];
|
||||
form.setFieldError(field, errorMessages[0]);
|
||||
});
|
||||
processing.value = false;
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (!newVal) {
|
||||
return;
|
||||
}
|
||||
if (props.edit && props.id) {
|
||||
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
|
||||
const email = list.find((e) => e.id === props.id);
|
||||
if (email) {
|
||||
form.setValues({
|
||||
value: email.value ?? email.email ?? email.address ?? "",
|
||||
label: email.label ?? "",
|
||||
receive_auto_mails: !!email.receive_auto_mails,
|
||||
});
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
if (props.edit) {
|
||||
update();
|
||||
} else {
|
||||
create();
|
||||
}
|
||||
});
|
||||
|
||||
const onConfirm = () => {
|
||||
onSubmit();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="edit ? UpdateDialog : CreateDialog"
|
||||
:show="show"
|
||||
:title="edit ? 'Spremeni email' : 'Dodaj email'"
|
||||
confirm-text="Shrani"
|
||||
:processing="processing"
|
||||
@close="close"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<SectionTitle class="border-b mb-4">
|
||||
<template #title>Email</template>
|
||||
</SectionTitle>
|
||||
|
||||
<div class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="value">
|
||||
<FormItem>
|
||||
<FormLabel>E-pošta</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="example@example.com"
|
||||
autocomplete="email"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="label">
|
||||
<FormItem>
|
||||
<FormLabel>Oznaka (neobvezno)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Oznaka"
|
||||
autocomplete="off"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-if="props.person?.client || isClientContext"
|
||||
v-slot="{ value, handleChange }"
|
||||
name="receive_auto_mails"
|
||||
>
|
||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<div class="space-y-1 leading-none">
|
||||
<FormLabel class="cursor-pointer">
|
||||
Prejemaj samodejna e-sporočila
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</form>
|
||||
</component>
|
||||
</template>
|
||||
@@ -1,81 +0,0 @@
|
||||
<script setup>
|
||||
import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
|
||||
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 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">
|
||||
<Card class="p-2 gap-1" 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"
|
||||
>
|
||||
{{ 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"
|
||||
>
|
||||
{{ address.type.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="edit">
|
||||
<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"
|
||||
>
|
||||
<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 p-1">
|
||||
{{
|
||||
address.post_code && address.city
|
||||
? `${address.address}, ${address.post_code} ${address.city}`
|
||||
: address.address
|
||||
}}
|
||||
</p>
|
||||
</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 h-full"
|
||||
title="Dodaj naslov"
|
||||
>
|
||||
<PlusIcon class="h-8 w-8 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,99 +0,0 @@
|
||||
<script setup>
|
||||
import { DottedMenu, EditIcon, TrashBinIcon, PlusIcon } from "@/Utilities/Icons";
|
||||
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 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);
|
||||
</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">
|
||||
<Card class="p-2 gap-1" 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"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||
>
|
||||
{{ email.label }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800"
|
||||
>
|
||||
Email
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="edit">
|
||||
<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"
|
||||
>
|
||||
<TrashBinIcon size="sm" class="text-red-600" />
|
||||
<span>Izbriši</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<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"
|
||||
>
|
||||
{{ 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 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"
|
||||
>
|
||||
Ni e-poštnih naslovov.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user