Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 229c100cc4 | |||
| 9a4897bf0c | |||
| b2a9350d0f | |||
| 27bdb942ab | |||
| 7eaab16e30 | |||
| 6a2dd860fa | |||
| ca8754cd94 | |||
| 8fdc0d6359 | |||
| 7fc4520dbf | |||
| dc41862afc | |||
| fb6474ab88 | |||
| 2ad24216ae | |||
| 8031501d25 |
@@ -22,7 +22,7 @@ ## Foundational Context
|
|||||||
- pestphp/pest (PEST) - v3
|
- pestphp/pest (PEST) - v3
|
||||||
- phpunit/phpunit (PHPUNIT) - v11
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
- @inertiajs/vue3 (INERTIA) - v2
|
- @inertiajs/vue3 (INERTIA) - v2
|
||||||
- tailwindcss (TAILWINDCSS) - v4
|
- tailwindcss (TAILWINDCSS) - v3
|
||||||
- vue (VUE) - 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:`.
|
- 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.
|
- Always use Tailwind CSS v3 - verify you're using only classes supported by this version.
|
||||||
- `corePlugins` is not supported in Tailwind v4.
|
|
||||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
|
||||||
|
|
||||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
|
||||||
- @tailwind base;
|
|
||||||
- @tailwind components;
|
|
||||||
- @tailwind utilities;
|
|
||||||
+ @import "tailwindcss";
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
|
|
||||||
### Replaced Utilities
|
|
||||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
|
||||||
- Opacity values are still numeric.
|
|
||||||
|
|
||||||
| Deprecated | Replacement |
|
|
||||||
|------------+--------------|
|
|
||||||
| bg-opacity-* | bg-black/* |
|
|
||||||
| text-opacity-* | text-black/* |
|
|
||||||
| border-opacity-* | border-black/* |
|
|
||||||
| divide-opacity-* | divide-black/* |
|
|
||||||
| ring-opacity-* | ring-black/* |
|
|
||||||
| placeholder-opacity-* | placeholder-black/* |
|
|
||||||
| flex-shrink-* | shrink-* |
|
|
||||||
| flex-grow-* | grow-* |
|
|
||||||
| overflow-ellipsis | text-ellipsis |
|
|
||||||
| decoration-slice | box-decoration-slice |
|
|
||||||
| decoration-clone | box-decoration-clone |
|
|
||||||
|
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|||||||
@@ -19,4 +19,3 @@ yarn-error.log
|
|||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.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,
|
'--days' => $days,
|
||||||
])->dailyAt('02:00');
|
])->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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Models\Contract;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||||
|
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithCustomValueBinder;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||||
|
|
||||||
|
class ClientContractsExport extends DefaultValueBinder implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithCustomValueBinder, WithHeadings, WithMapping
|
||||||
|
{
|
||||||
|
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
|
||||||
|
|
||||||
|
public const TEXT_EXCEL_FORMAT = NumberFormat::FORMAT_TEXT;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private array $columnLetterMap = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{label: string}>
|
||||||
|
*/
|
||||||
|
public const COLUMN_METADATA = [
|
||||||
|
'reference' => ['label' => 'Referenca'],
|
||||||
|
'customer' => ['label' => 'Stranka'],
|
||||||
|
'address' => ['label' => 'Naslov'],
|
||||||
|
'start' => ['label' => 'Začetek'],
|
||||||
|
'segment' => ['label' => 'Segment'],
|
||||||
|
'balance' => ['label' => 'Stanje'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $columns
|
||||||
|
*/
|
||||||
|
public function __construct(private Builder $query, private array $columns) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function allowedColumns(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::COLUMN_METADATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function columnLabel(string $column): string
|
||||||
|
{
|
||||||
|
return self::COLUMN_METADATA[$column]['label'] ?? $column;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(): Builder
|
||||||
|
{
|
||||||
|
return $this->query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, mixed>
|
||||||
|
*/
|
||||||
|
public function map($row): array
|
||||||
|
{
|
||||||
|
return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return array_map(fn (string $column) => self::columnLabel($column), $this->columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function columnFormats(): array
|
||||||
|
{
|
||||||
|
$formats = [];
|
||||||
|
|
||||||
|
foreach ($this->getColumnLetterMap() as $letter => $column) {
|
||||||
|
if ($column === 'reference') {
|
||||||
|
$formats[$letter] = self::TEXT_EXCEL_FORMAT;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($column === 'start') {
|
||||||
|
$formats[$letter] = self::DATE_EXCEL_FORMAT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveValue(Contract $contract, string $column): mixed
|
||||||
|
{
|
||||||
|
return match ($column) {
|
||||||
|
'reference' => $contract->reference,
|
||||||
|
'customer' => optional($contract->clientCase?->person)->full_name,
|
||||||
|
'address' => optional($contract->clientCase?->person?->address)->address,
|
||||||
|
'start' => $this->formatDate($contract->start_date),
|
||||||
|
'segment' => $contract->segments?->first()?->name,
|
||||||
|
'balance' => optional($contract->account)->balance_amount,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatDate(?string $date): mixed
|
||||||
|
{
|
||||||
|
if (empty($date)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$carbon = Carbon::parse($date);
|
||||||
|
|
||||||
|
return ExcelDate::dateTimeToExcel($carbon);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function getColumnLetterMap(): array
|
||||||
|
{
|
||||||
|
if ($this->columnLetterMap !== []) {
|
||||||
|
return $this->columnLetterMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
$letter = 'A';
|
||||||
|
foreach ($this->columns as $column) {
|
||||||
|
$this->columnLetterMap[$letter] = $column;
|
||||||
|
$letter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->columnLetterMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bindValue(Cell $cell, $value): bool
|
||||||
|
{
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
$cell->setValueExplicit($value, DataType::TYPE_NUMERIC);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::bindValue($cell, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ class SegmentContractsExport extends DefaultValueBinder implements FromQuery, Sh
|
|||||||
public const COLUMN_METADATA = [
|
public const COLUMN_METADATA = [
|
||||||
'reference' => ['label' => 'Pogodba'],
|
'reference' => ['label' => 'Pogodba'],
|
||||||
'client_case' => ['label' => 'Primer'],
|
'client_case' => ['label' => 'Primer'],
|
||||||
|
'address' => ['label' => 'Naslov'],
|
||||||
'client' => ['label' => 'Stranka'],
|
'client' => ['label' => 'Stranka'],
|
||||||
'type' => ['label' => 'Vrsta'],
|
'type' => ['label' => 'Vrsta'],
|
||||||
'start_date' => ['label' => 'Začetek'],
|
'start_date' => ['label' => 'Začetek'],
|
||||||
@@ -107,6 +108,7 @@ private function resolveValue(Contract $contract, string $column): mixed
|
|||||||
return match ($column) {
|
return match ($column) {
|
||||||
'reference' => $contract->reference,
|
'reference' => $contract->reference,
|
||||||
'client_case' => optional($contract->clientCase?->person)->full_name,
|
'client_case' => optional($contract->clientCase?->person)->full_name,
|
||||||
|
'address' => optional($contract->clientCase?->person?->address)->address,
|
||||||
'client' => optional($contract->clientCase?->client?->person)->full_name,
|
'client' => optional($contract->clientCase?->client?->person)->full_name,
|
||||||
'type' => optional($contract->type)->name,
|
'type' => optional($contract->type)->name,
|
||||||
'start_date' => $this->formatDate($contract->start_date),
|
'start_date' => $this->formatDate($contract->start_date),
|
||||||
|
|||||||
@@ -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');
|
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.
|
* 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
@@ -2,52 +2,62 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Exports\ClientContractsExport;
|
||||||
|
use App\Http\Requests\ExportClientContractsRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Services\ReferenceDataCache;
|
|
||||||
use DB;
|
use DB;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
|
||||||
class ClientController extends Controller
|
class ClientController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
|
||||||
|
|
||||||
public function index(Client $client, Request $request)
|
public function index(Client $client, Request $request)
|
||||||
{
|
{
|
||||||
$search = $request->input('search');
|
|
||||||
|
|
||||||
$query = $client::query()
|
$query = $client::query()
|
||||||
->select('clients.*')
|
->with('person')
|
||||||
->when($search, function ($que) use ($search) {
|
->when($request->input('search'), function ($que, $search) {
|
||||||
$que->join('person', 'person.id', '=', 'clients.person_id')
|
$que->whereHas('person', function ($q) use ($search) {
|
||||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
$q->where('full_name', 'ilike', '%'.$search.'%');
|
||||||
->groupBy('clients.id');
|
});
|
||||||
})
|
})
|
||||||
->where('clients.active', 1)
|
->where('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')
|
|
||||||
->addSelect([
|
->addSelect([
|
||||||
// Number of client cases for this client that have at least one active contract
|
// 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'),
|
'cases_with_active_contracts_count' => DB::query()
|
||||||
// Sum of account balances for active contracts
|
->from('client_cases')
|
||||||
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
->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('created_at');
|
||||||
->orderByDesc('clients.created_at');
|
|
||||||
|
|
||||||
return Inertia::render('Client/Index', [
|
return Inertia::render('Client/Index', [
|
||||||
'clients' => $query
|
'clients' => $query
|
||||||
->paginate($request->integer('per_page', 15))
|
->paginate($request->integer('perPage', 15))
|
||||||
->withQueryString(),
|
->withQueryString(),
|
||||||
'filters' => $request->only(['search']),
|
'filters' => $request->only(['search']),
|
||||||
]);
|
]);
|
||||||
@@ -61,37 +71,44 @@ public function show(Client $client, Request $request)
|
|||||||
->findOrFail($client->id);
|
->findOrFail($client->id);
|
||||||
|
|
||||||
$types = [
|
$types = [
|
||||||
'address_types' => $this->referenceCache->getAddressTypes(),
|
'address_types' => \App\Models\Person\AddressType::all(),
|
||||||
'phone_types' => $this->referenceCache->getPhoneTypes(),
|
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return Inertia::render('Client/Show', [
|
return Inertia::render('Client/Show', [
|
||||||
'client' => $data,
|
'client' => $data,
|
||||||
'client_cases' => $data->clientCases()
|
'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'])
|
->with(['person', 'client.person'])
|
||||||
->where('client_cases.active', 1)
|
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
|
||||||
->orderByDesc('client_cases.created_at')
|
'person',
|
||||||
->paginate($request->integer('per_page', 15))
|
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(),
|
->withQueryString(),
|
||||||
'types' => $types,
|
'types' => $types,
|
||||||
'filters' => $request->only(['search']),
|
'filters' => $request->only(['search']),
|
||||||
@@ -109,23 +126,34 @@ public function contracts(Client $client, Request $request)
|
|||||||
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
|
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
|
||||||
|
|
||||||
$contractsQuery = \App\Models\Contract::query()
|
$contractsQuery = \App\Models\Contract::query()
|
||||||
->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.start_date', 'contracts.client_case_id'])
|
->whereHas('clientCase', function ($q) use ($client) {
|
||||||
->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
|
$q->where('client_id', $client->id);
|
||||||
->where('client_cases.client_id', $client->id)
|
})
|
||||||
->whereNull('contracts.deleted_at')
|
->with([
|
||||||
|
'clientCase:id,uuid,person_id',
|
||||||
|
'clientCase.person:id,full_name',
|
||||||
|
'clientCase.person.address',
|
||||||
|
'segments' => function ($q) {
|
||||||
|
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||||
|
},
|
||||||
|
'account:id,accounts.contract_id,balance_amount',
|
||||||
|
])
|
||||||
|
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
|
||||||
|
->whereNull('deleted_at')
|
||||||
->when($from || $to, function ($q) use ($from, $to) {
|
->when($from || $to, function ($q) use ($from, $to) {
|
||||||
if (! empty($from)) {
|
if (! empty($from)) {
|
||||||
$q->whereDate('contracts.start_date', '>=', $from);
|
$q->whereDate('start_date', '>=', $from);
|
||||||
}
|
}
|
||||||
if (! empty($to)) {
|
if (! empty($to)) {
|
||||||
$q->whereDate('contracts.start_date', '<=', $to);
|
$q->whereDate('start_date', '<=', $to);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
->when($search, function ($q) use ($search) {
|
->when($search, function ($q) use ($search) {
|
||||||
$q->leftJoin('person', 'person.id', '=', 'client_cases.person_id')
|
$q->where(function ($inner) use ($search) {
|
||||||
->where(function ($inner) use ($search) {
|
$inner->where('reference', 'ilike', '%'.$search.'%')
|
||||||
$inner->where('contracts.reference', 'ilike', '%'.$search.'%')
|
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
||||||
->orWhere('person.full_name', 'ilike', '%'.$search.'%');
|
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
->when($segmentIds, function ($q) use ($segmentIds) {
|
->when($segmentIds, function ($q) use ($segmentIds) {
|
||||||
@@ -134,36 +162,100 @@ public function contracts(Client $client, Request $request)
|
|||||||
->where('contract_segment.active', true);
|
->where('contract_segment.active', true);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
->orderByDesc('start_date');
|
||||||
|
|
||||||
|
$segments = \App\Models\Segment::orderBy('name')->get(['id', 'name']);
|
||||||
|
|
||||||
|
$types = [
|
||||||
|
'address_types' => \App\Models\Person\AddressType::all(),
|
||||||
|
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('Client/Contracts', [
|
||||||
|
'client' => $data,
|
||||||
|
'contracts' => $contractsQuery->paginate($request->integer('perPage', 20))->withQueryString(),
|
||||||
|
'filters' => $request->only(['from', 'to', 'search', 'segments']),
|
||||||
|
'segments' => $segments,
|
||||||
|
'types' => $types,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportContracts(ExportClientContractsRequest $request, Client $client)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$columns = array_values(array_unique($data['columns']));
|
||||||
|
|
||||||
|
$from = $data['from'] ?? null;
|
||||||
|
$to = $data['to'] ?? null;
|
||||||
|
$search = $data['search'] ?? null;
|
||||||
|
$segmentsParam = $data['segments'] ?? null;
|
||||||
|
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
|
||||||
|
|
||||||
|
$query = \App\Models\Contract::query()
|
||||||
|
->whereHas('clientCase', function ($q) use ($client) {
|
||||||
|
$q->where('client_id', $client->id);
|
||||||
|
})
|
||||||
->with([
|
->with([
|
||||||
'clientCase:id,uuid,person_id',
|
'clientCase:id,uuid,person_id',
|
||||||
'clientCase.person:id,full_name',
|
'clientCase.person:id,full_name',
|
||||||
|
'clientCase.person.address',
|
||||||
'segments' => function ($q) {
|
'segments' => function ($q) {
|
||||||
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||||
},
|
},
|
||||||
'account:id,accounts.contract_id,balance_amount',
|
'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']);
|
if (($data['scope'] ?? ExportClientContractsRequest::SCOPE_ALL) === ExportClientContractsRequest::SCOPE_CURRENT) {
|
||||||
|
$page = max(1, (int) ($data['page'] ?? 1));
|
||||||
|
$perPage = max(1, min(200, (int) ($data['per_page'] ?? 15)));
|
||||||
|
$query->forPage($page, $perPage);
|
||||||
|
}
|
||||||
|
|
||||||
$types = [
|
$filename = $this->buildExportFilename($client);
|
||||||
'address_types' => $this->referenceCache->getAddressTypes(),
|
|
||||||
'phone_types' => $this->referenceCache->getPhoneTypes(),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Support custom pagination parameter names used by DataTableNew2
|
return Excel::download(new ClientContractsExport($query, $columns), $filename);
|
||||||
$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', [
|
private function buildExportFilename(Client $client): string
|
||||||
'client' => $data,
|
{
|
||||||
'contracts' => $contractsQuery
|
$datePrefix = now()->format('dmy');
|
||||||
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
$clientName = $this->slugify($client->person?->full_name ?? 'stranka');
|
||||||
->withQueryString(),
|
|
||||||
'filters' => $request->only(['from', 'to', 'search', 'segment']),
|
return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName);
|
||||||
'segments' => $segments,
|
}
|
||||||
'types' => $types,
|
|
||||||
]);
|
private function slugify(?string $value): string
|
||||||
|
{
|
||||||
|
if (empty($value)) {
|
||||||
|
return 'data';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::slug($value, '-') ?: 'data';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
@@ -203,14 +295,14 @@ public function store(Request $request)
|
|||||||
|
|
||||||
// \App\Models\Person\PersonAddress::create($address);
|
// \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)
|
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)
|
public function update(Contract $contract, Request $request)
|
||||||
|
|||||||
@@ -2,17 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Account;
|
|
||||||
use App\Models\Activity;
|
use App\Models\Activity;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Contract;
|
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\FieldJob; // if this model exists
|
||||||
use App\Models\Import;
|
use App\Models\Import;
|
||||||
use App\Models\SmsLog;
|
use App\Models\SmsLog;
|
||||||
use App\Models\SmsProfile;
|
use App\Models\SmsProfile;
|
||||||
use App\Services\Sms\SmsService;
|
use App\Services\Sms\SmsService;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@@ -23,38 +21,25 @@ class DashboardController extends Controller
|
|||||||
public function __invoke(SmsService $sms): Response
|
public function __invoke(SmsService $sms): Response
|
||||||
{
|
{
|
||||||
$today = now()->startOfDay();
|
$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
|
$clientsTotal = Client::count();
|
||||||
$activeClientsCount = Cache::remember('dashboard:active_clients:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
$clientsNew7d = Client::where('created_at', '>=', now()->subDays(7))->count();
|
||||||
return Client::where('active', true)->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
|
// Basic activities deferred list (limit 10)
|
||||||
$activeContractsCount = Cache::remember('dashboard:active_contracts:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
$activities = Activity::query()
|
||||||
return Contract::whereNull('deleted_at')->count();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
})
|
|
||||||
->whereNotNull('promise_date')
|
|
||||||
->whereDate('promise_date', '>=', $today)
|
|
||||||
->count();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Activities (limit 10) - cached
|
|
||||||
$activities = Cache::remember('dashboard:activities', $cacheMinutes * 60, function () {
|
|
||||||
return Activity::query()
|
|
||||||
->with(['clientCase:id,uuid'])
|
->with(['clientCase:id,uuid'])
|
||||||
->latest()
|
->latest()
|
||||||
->limit(10)
|
->limit(10)
|
||||||
@@ -69,20 +54,30 @@ public function __invoke(SmsService $sms): Response
|
|||||||
'action_id' => $a->action_id,
|
'action_id' => $a->action_id,
|
||||||
'decision_id' => $a->decision_id,
|
'decision_id' => $a->decision_id,
|
||||||
]);
|
]);
|
||||||
});
|
|
||||||
|
|
||||||
// 7-day trends for field jobs - cached
|
// 7-day trends (including today)
|
||||||
$trends = Cache::remember('dashboard:field_jobs_trends:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
|
||||||
$start = now()->subDays(6)->startOfDay();
|
$start = now()->subDays(6)->startOfDay();
|
||||||
$end = now()->endOfDay();
|
$end = now()->endOfDay();
|
||||||
|
|
||||||
$dateKeys = collect(range(0, 6))
|
$dateKeys = collect(range(0, 6))
|
||||||
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
->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])
|
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
||||||
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
||||||
->groupBy('d')
|
->groupBy('d')
|
||||||
->pluck('c', '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
|
// Completed field jobs last 7 days
|
||||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||||
@@ -91,16 +86,50 @@ public function __invoke(SmsService $sms): Response
|
|||||||
->groupBy('d')
|
->groupBy('d')
|
||||||
->pluck('c', 'd');
|
->pluck('c', 'd');
|
||||||
|
|
||||||
return [
|
$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(),
|
'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(),
|
'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
|
||||||
'labels' => $dateKeys,
|
'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');
|
||||||
|
})
|
||||||
|
->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);
|
||||||
|
|
||||||
|
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,
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Field jobs assigned today - cached
|
// Field jobs assigned today
|
||||||
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
$fieldJobsAssignedToday = FieldJob::query()
|
||||||
return FieldJob::query()
|
|
||||||
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
||||||
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
||||||
->with(['contract' => function ($q) {
|
->with(['contract' => function ($q) {
|
||||||
@@ -114,6 +143,7 @@ public function __invoke(SmsService $sms): Response
|
|||||||
$contract = $fj->contract;
|
$contract = $fj->contract;
|
||||||
$segmentId = null;
|
$segmentId = null;
|
||||||
if ($contract && method_exists($contract, 'segments')) {
|
if ($contract && method_exists($contract, 'segments')) {
|
||||||
|
// Determine active segment via pivot active flag if present
|
||||||
$activeSeg = $contract->segments->first();
|
$activeSeg = $contract->segments->first();
|
||||||
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
|
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
|
||||||
$segmentId = $activeSeg->id;
|
$segmentId = $activeSeg->id;
|
||||||
@@ -123,6 +153,7 @@ public function __invoke(SmsService $sms): Response
|
|||||||
return [
|
return [
|
||||||
'id' => $fj->id,
|
'id' => $fj->id,
|
||||||
'priority' => $fj->priority,
|
'priority' => $fj->priority,
|
||||||
|
// Normalize to ISO8601 strings so FE retains timezone & time component
|
||||||
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
||||||
'created_at' => $fj->created_at?->toIso8601String(),
|
'created_at' => $fj->created_at?->toIso8601String(),
|
||||||
'contract' => $contract ? [
|
'contract' => $contract ? [
|
||||||
@@ -134,35 +165,69 @@ public function __invoke(SmsService $sms): Response
|
|||||||
] : null,
|
] : 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');
|
$recentActivity = Activity::query()->latest('created_at')->value('created_at');
|
||||||
$lastActivityMinutes = null;
|
$lastActivityMinutes = null;
|
||||||
if ($recentActivity) {
|
if ($recentActivity) {
|
||||||
|
// diffInMinutes is absolute (non-negative) but guard anyway & cast to int
|
||||||
$lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity));
|
$lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity));
|
||||||
}
|
}
|
||||||
$systemHealth = [
|
$systemHealth = [
|
||||||
|
'queue_backlog' => $queueBacklog,
|
||||||
|
'failed_jobs' => $failedJobs,
|
||||||
'last_activity_minutes' => $lastActivityMinutes,
|
'last_activity_minutes' => $lastActivityMinutes,
|
||||||
'last_activity_iso' => $recentActivity?->toIso8601String(),
|
'last_activity_iso' => $recentActivity?->toIso8601String(),
|
||||||
'generated_at' => now()->toIso8601String(),
|
'generated_at' => now()->toIso8601String(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return Inertia::render('Dashboard/Index', [
|
return Inertia::render('Dashboard', [
|
||||||
'kpis' => [
|
'kpis' => [
|
||||||
'active_clients' => $activeClientsCount,
|
'clients_total' => $clientsTotal,
|
||||||
'active_contracts' => $activeContractsCount,
|
'clients_new_7d' => $clientsNew7d,
|
||||||
'total_balance' => $totalBalance,
|
'field_jobs_today' => $fieldJobsToday,
|
||||||
'active_promises' => $activePromisesCount,
|
'documents_today' => $documentsToday,
|
||||||
|
'active_imports' => $activeImports,
|
||||||
|
'active_contracts' => $activeContracts,
|
||||||
],
|
],
|
||||||
'trends' => $trends,
|
'trends' => $trends,
|
||||||
])->with([
|
])->with([ // deferred props (Inertia v2 style)
|
||||||
'activities' => fn () => $activities,
|
'activities' => fn () => $activities,
|
||||||
'systemHealth' => fn () => $systemHealth,
|
'systemHealth' => fn () => $systemHealth,
|
||||||
|
'staleCases' => fn () => $staleCases,
|
||||||
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
|
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
|
||||||
'smsStats' => function () use ($sms, $today, $cacheMinutes) {
|
'importsInProgress' => fn () => $importsInProgress,
|
||||||
// SMS stats - cached
|
'activeTemplates' => fn () => $activeTemplates,
|
||||||
return Cache::remember('dashboard:sms_stats:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($sms, $today) {
|
'smsStats' => function () use ($sms, $today) {
|
||||||
|
// Aggregate counts per profile for today
|
||||||
$counts = SmsLog::query()
|
$counts = SmsLog::query()
|
||||||
->whereDate('created_at', $today)
|
->whereDate('created_at', $today)
|
||||||
->selectRaw('profile_id, status, COUNT(*) as c')
|
->selectRaw('profile_id, status, COUNT(*) as c')
|
||||||
@@ -184,11 +249,13 @@ public function __invoke(SmsService $sms): Response
|
|||||||
return $map;
|
return $map;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Important: include credential fields so provider calls have proper credentials
|
||||||
$profiles = SmsProfile::query()
|
$profiles = SmsProfile::query()
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
|
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
|
||||||
|
|
||||||
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
|
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
|
||||||
|
// Provider balance may fail; guard and present a placeholder.
|
||||||
try {
|
try {
|
||||||
$balance = $sms->getCreditBalance($p);
|
$balance = $sms->getCreditBalance($p);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -204,7 +271,6 @@ public function __invoke(SmsService $sms): Response
|
|||||||
'today' => $c,
|
'today' => $c,
|
||||||
];
|
];
|
||||||
})->values();
|
})->values();
|
||||||
});
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,18 @@ public function update(Person $person, Request $request)
|
|||||||
|
|
||||||
$person->update($attributes);
|
$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)
|
public function createAddress(Person $person, Request $request)
|
||||||
@@ -52,8 +60,13 @@ public function createAddress(Person $person, Request $request)
|
|||||||
], $attributes);
|
], $attributes);
|
||||||
|
|
||||||
// Support Inertia form submissions (redirect back) and JSON (for API/axios)
|
// 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)
|
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);
|
$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)
|
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 = $person->addresses()->findOrFail($address_id);
|
||||||
$address->delete(); // soft delete
|
$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)
|
public function createPhone(Person $person, Request $request)
|
||||||
@@ -101,7 +122,7 @@ public function createPhone(Person $person, Request $request)
|
|||||||
'country_code' => $attributes['country_code'] ?? null,
|
'country_code' => $attributes['country_code'] ?? null,
|
||||||
], $attributes);
|
], $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)
|
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);
|
$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)
|
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 = $person->phones()->findOrFail($phone_id);
|
||||||
$phone->delete(); // soft delete
|
$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)
|
public function createEmail(Person $person, Request $request)
|
||||||
@@ -149,7 +170,7 @@ public function createEmail(Person $person, Request $request)
|
|||||||
'value' => $attributes['value'],
|
'value' => $attributes['value'],
|
||||||
], $attributes);
|
], $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)
|
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);
|
$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)
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
return back()->with('success', 'Email deleted')->with('flash_method', 'DELETE');
|
return response()->json(['status' => 'ok']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRR (bank account) CRUD
|
// 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
|
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||||
$trr = $person->bankAccounts()->create($attributes);
|
$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)
|
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 = $person->bankAccounts()->findOrFail($trr_id);
|
||||||
$trr->update($attributes);
|
$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)
|
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 = $person->bankAccounts()->findOrFail($trr_id);
|
||||||
$trr->delete();
|
$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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\FieldJob;
|
use App\Models\FieldJob;
|
||||||
use App\Services\ReferenceDataCache;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
class PhoneViewController extends Controller
|
class PhoneViewController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,6 +65,12 @@ public function show(Segment $segment)
|
|||||||
|
|
||||||
$contracts = $this->hydrateClientShortcut($contracts);
|
$contracts = $this->hydrateClientShortcut($contracts);
|
||||||
|
|
||||||
|
// Hide addresses array since we're using the singular address relationship
|
||||||
|
$contracts->getCollection()->each(function ($contract) {
|
||||||
|
$contract->clientCase?->person?->makeHidden('addresses');
|
||||||
|
$contract->clientCase?->client?->person?->makeHidden('addresses');
|
||||||
|
});
|
||||||
|
|
||||||
$clients = Client::query()
|
$clients = Client::query()
|
||||||
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
|
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
|
||||||
$q->where('segments.id', $segment->id)
|
$q->where('segments.id', $segment->id)
|
||||||
@@ -191,8 +197,7 @@ private function buildContractsQuery(Segment $segment, ?string $search, ?string
|
|||||||
->where('contract_segment.active', '=', 1);
|
->where('contract_segment.active', '=', 1);
|
||||||
})
|
})
|
||||||
->with([
|
->with([
|
||||||
'clientCase.person',
|
'clientCase.person.address',
|
||||||
'clientCase.client.person',
|
|
||||||
'type',
|
'type',
|
||||||
'account',
|
'account',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ public function share(Request $request): array
|
|||||||
'error' => fn () => $request->session()->get('error'),
|
'error' => fn () => $request->session()->get('error'),
|
||||||
'warning' => fn () => $request->session()->get('warning'),
|
'warning' => fn () => $request->session()->get('warning'),
|
||||||
'info' => fn () => $request->session()->get('info'),
|
'info' => fn () => $request->session()->get('info'),
|
||||||
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
|
|
||||||
],
|
],
|
||||||
'notifications' => function () use ($request) {
|
'notifications' => function () use ($request) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Exports\ClientContractsExport;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ExportClientContractsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public const SCOPE_CURRENT = 'current';
|
||||||
|
|
||||||
|
public const SCOPE_ALL = 'all';
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$columnRule = Rule::in(ClientContractsExport::allowedColumns());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'scope' => ['required', Rule::in([self::SCOPE_CURRENT, self::SCOPE_ALL])],
|
||||||
|
'columns' => ['required', 'array', 'min:1'],
|
||||||
|
'columns.*' => ['string', $columnRule],
|
||||||
|
'search' => ['nullable', 'string', 'max:255'],
|
||||||
|
'from' => ['nullable', 'date'],
|
||||||
|
'to' => ['nullable', 'date'],
|
||||||
|
'segments' => ['nullable', 'string'],
|
||||||
|
'page' => ['nullable', 'integer', 'min:1'],
|
||||||
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'per_page' => $this->input('per_page') ?? $this->input('perPage'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,12 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Account extends Model
|
class Account extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||||
|
use SoftDeletes;
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
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
|
public function action(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\Action::class);
|
return $this->belongsTo(\App\Models\Action::class);
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Traits\Uuid;
|
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\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
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
|
public function type(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\ContractType::class, 'type_id');
|
return $this->belongsTo(\App\Models\ContractType::class, 'type_id');
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class Person extends Model
|
|||||||
'group_id',
|
'group_id',
|
||||||
'type_id',
|
'type_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
|
'employer'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -112,6 +113,14 @@ public function addresses(): HasMany
|
|||||||
->orderBy('id');
|
->orderBy('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function address(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(\App\Models\Person\PersonAddress::class)
|
||||||
|
->with(['type'])
|
||||||
|
->where('active', '=', 1)
|
||||||
|
->oldestOfMany('id');
|
||||||
|
}
|
||||||
|
|
||||||
public function emails(): HasMany
|
public function emails(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Models\Email::class, 'person_id')
|
return $this->hasMany(\App\Models\Email::class, 'person_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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
use App\Models\Person\PersonPhone;
|
use App\Models\Person\PersonPhone;
|
||||||
use App\Models\Person\PersonType;
|
use App\Models\Person\PersonType;
|
||||||
use App\Models\Person\PhoneType;
|
use App\Models\Person\PhoneType;
|
||||||
|
use Exception;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -1632,7 +1633,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
|||||||
|
|
||||||
$existing = Account::query()
|
$existing = Account::query()
|
||||||
->where('contract_id', $contractId)
|
->where('contract_id', $contractId)
|
||||||
->where('reference', $reference)
|
//->where('reference', $reference)
|
||||||
->where('active', 1)
|
->where('active', 1)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -1655,6 +1656,14 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
|||||||
$value = $acc[$field] ?? null;
|
$value = $acc[$field] ?? null;
|
||||||
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
|
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
|
||||||
$value = $this->normalizeDecimal($value);
|
$value = $this->normalizeDecimal($value);
|
||||||
|
// Ensure the normalized value is numeric, otherwise default to 0
|
||||||
|
if ($value === '' || $value === '-' || ! is_numeric($value)) {
|
||||||
|
$value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Convert empty string to 0 for amount fields
|
||||||
|
if (in_array($field, ['balance_amount', 'initial_amount'], true) && ($value === '' || $value === null)) {
|
||||||
|
$value = 0;
|
||||||
}
|
}
|
||||||
$mode = $map->apply_mode ?? 'both';
|
$mode = $map->apply_mode ?? 'both';
|
||||||
if ($mode === 'keyref') {
|
if ($mode === 'keyref') {
|
||||||
@@ -1684,8 +1693,12 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
|||||||
if ($existing) {
|
if ($existing) {
|
||||||
// Build non-null changes for account fields
|
// Build non-null changes for account fields
|
||||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||||
// Track balance change
|
// Track balance change - normalize in case DB has malformed data
|
||||||
$oldBalance = (float) ($existing->balance_amount ?? 0);
|
$rawBalance = $existing->balance_amount ?? 0;
|
||||||
|
if (is_string($rawBalance) && $rawBalance !== '') {
|
||||||
|
$rawBalance = $this->normalizeDecimal($rawBalance);
|
||||||
|
}
|
||||||
|
$oldBalance = is_numeric($rawBalance) ? (float) $rawBalance : 0;
|
||||||
// Note: meta merging for contracts is handled in upsertContractChain, not here
|
// Note: meta merging for contracts is handled in upsertContractChain, not here
|
||||||
if (! empty($changes)) {
|
if (! empty($changes)) {
|
||||||
$existing->fill($changes);
|
$existing->fill($changes);
|
||||||
@@ -1694,7 +1707,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings, bool $h
|
|||||||
|
|
||||||
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
|
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
|
||||||
if (array_key_exists('balance_amount', $changes)) {
|
if (array_key_exists('balance_amount', $changes)) {
|
||||||
$newBalance = (float) ($existing->balance_amount ?? 0);
|
$rawNewBalance = $existing->balance_amount ?? 0;
|
||||||
|
if (is_string($rawNewBalance) && $rawNewBalance !== '') {
|
||||||
|
$rawNewBalance = $this->normalizeDecimal($rawNewBalance);
|
||||||
|
}
|
||||||
|
$newBalance = is_numeric($rawNewBalance) ? (float) $rawNewBalance : 0;
|
||||||
if ($newBalance !== $oldBalance) {
|
if ($newBalance !== $oldBalance) {
|
||||||
try {
|
try {
|
||||||
$contractId = $existing->contract_id;
|
$contractId = $existing->contract_id;
|
||||||
@@ -2301,10 +2318,31 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
|||||||
|
|
||||||
return ['action' => 'skipped_history', 'contract' => $existing, 'message' => 'Existing contract left unchanged (history import)'];
|
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)
|
// 1) Prepare contract field changes (non-null)
|
||||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
$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;
|
$metaUpdated = false;
|
||||||
$metaAppliedKeys = [];
|
$metaAppliedKeys = [];
|
||||||
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
|
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
|
||||||
@@ -2347,7 +2385,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($changes) && ! $metaUpdated) {
|
if (empty($changes) && ! $metaUpdated && ! $isTrashed) {
|
||||||
// Nothing to change
|
// Nothing to change
|
||||||
return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing];
|
return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing];
|
||||||
}
|
}
|
||||||
@@ -2355,6 +2393,12 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b
|
|||||||
if (! empty($changes)) {
|
if (! empty($changes)) {
|
||||||
$existing->fill($changes);
|
$existing->fill($changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore soft-deleted contract if it was trashed
|
||||||
|
if ($isTrashed) {
|
||||||
|
$existing->restore();
|
||||||
|
}
|
||||||
|
|
||||||
$existing->save();
|
$existing->save();
|
||||||
|
|
||||||
// Build applied fields info, include meta keys if any
|
// Build applied fields info, include meta keys if any
|
||||||
@@ -2365,7 +2409,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 {
|
} else {
|
||||||
if (empty($applyInsert)) {
|
if (empty($applyInsert)) {
|
||||||
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
|
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
|
||||||
@@ -2941,7 +2987,7 @@ private function findOrCreatePersonId(array $p): ?int
|
|||||||
// Create person if any fields present; ensure required foreign keys
|
// Create person if any fields present; ensure required foreign keys
|
||||||
if (! empty($p)) {
|
if (! empty($p)) {
|
||||||
$data = [];
|
$data = [];
|
||||||
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id'] as $k) {
|
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id', 'employer'] as $k) {
|
||||||
if (array_key_exists($k, $p)) {
|
if (array_key_exists($k, $p)) {
|
||||||
$data[$k] = $p[$k];
|
$data[$k] = $p[$k];
|
||||||
}
|
}
|
||||||
@@ -2954,6 +3000,16 @@ private function findOrCreatePersonId(array $p): ?int
|
|||||||
$data['full_name'] = trim($fn.' '.$ln);
|
$data['full_name'] = trim($fn.' '.$ln);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalise birthday date
|
||||||
|
if (!empty($data['birthday'])) {
|
||||||
|
try {
|
||||||
|
$data['birthday'] = date('Y-m-d', strtotime($data['birthday']));
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::warning('ImportProcessor::findOrCreatePersonId ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
// ensure required group/type ids
|
// ensure required group/type ids
|
||||||
$data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId();
|
$data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId();
|
||||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId();
|
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId();
|
||||||
@@ -3130,10 +3186,38 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
|||||||
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
|
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
|
||||||
$addrData['country'] = 'SLO';
|
$addrData['country'] = 'SLO';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($addrData['city']) && empty($addrData['post_code'])) {
|
||||||
|
if (preg_match('/^\d{3,}\s+/',trim($addrData['city']))) {
|
||||||
|
$cleanStrCity = str($addrData['city'])->squish()->value();
|
||||||
|
$splitCity = preg_split('/\s/', $cleanStrCity, 2);
|
||||||
|
if (count($splitCity) >= 2) {
|
||||||
|
$addrData['post_code'] = $splitCity[0];
|
||||||
|
$addrData['city'] = $splitCity[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Compare addresses with all spaces removed to handle whitespace variations
|
// Compare addresses with all spaces removed to handle whitespace variations
|
||||||
$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
|
/*$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
|
||||||
|
|
||||||
|
|
||||||
$existing = PersonAddress::where('person_id', $personId)
|
$existing = PersonAddress::where('person_id', $personId)
|
||||||
->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces])
|
->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces])
|
||||||
|
->first();*/
|
||||||
|
|
||||||
|
// Build search query combining address, post_code and city
|
||||||
|
$searchParts = [$addrData['address']];
|
||||||
|
if (!empty($addrData['post_code'])) {
|
||||||
|
$searchParts[] = $addrData['post_code'];
|
||||||
|
}
|
||||||
|
if (!empty($addrData['city'])) {
|
||||||
|
$searchParts[] = $addrData['city'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$searchQuery = implode(' ', $searchParts);
|
||||||
|
// Use fulltext search (GIN index optimized)
|
||||||
|
$existing = PersonAddress::query()->where('person_id', $personId)
|
||||||
|
->whereRaw("search_vector @@ plainto_tsquery('simple', ?)", [$searchQuery])
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$applyInsert = [];
|
$applyInsert = [];
|
||||||
@@ -3178,6 +3262,11 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
|||||||
$data['person_id'] = $personId;
|
$data['person_id'] = $personId;
|
||||||
$data['country'] = $data['country'] ?? 'SLO';
|
$data['country'] = $data['country'] ?? 'SLO';
|
||||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
|
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
|
||||||
|
|
||||||
|
if (!empty($addrData['post_code']) && $addrData['post_code'] !== '0' && !isset($applyUpdate['post_code'])) {
|
||||||
|
$data['post_code'] = $addrData['post_code'];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$created = PersonAddress::create($data);
|
$created = PersonAddress::create($data);
|
||||||
|
|
||||||
|
|||||||
@@ -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\AuthServiceProvider::class,
|
||||||
App\Providers\FortifyServiceProvider::class,
|
App\Providers\FortifyServiceProvider::class,
|
||||||
App\Providers\JetstreamServiceProvider::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": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"arielmejiadev/larapex-charts": "^2.1",
|
"arielmejiadev/larapex-charts": "^2.1",
|
||||||
"barryvdh/laravel-dompdf": "^3.1",
|
|
||||||
"diglactic/laravel-breadcrumbs": "^10.0",
|
"diglactic/laravel-breadcrumbs": "^10.0",
|
||||||
"http-interop/http-factory-guzzle": "^1.2",
|
"http-interop/http-factory-guzzle": "^1.2",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "d28e6760b713feea1c4ad6058f96287a",
|
"content-hash": "d29c47a4d6813ee8e80a7c8112c2f17e",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "arielmejiadev/larapex-charts",
|
"name": "arielmejiadev/larapex-charts",
|
||||||
@@ -113,83 +113,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-10-01T13:55:55+00:00"
|
"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",
|
"name": "brick/math",
|
||||||
"version": "0.12.3",
|
"version": "0.12.3",
|
||||||
@@ -838,161 +761,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-05T11:56:58+00:00"
|
"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",
|
"name": "dragonmantank/cron-expression",
|
||||||
"version": "v3.4.0",
|
"version": "v3.4.0",
|
||||||
@@ -3227,16 +2995,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "maennchen/zipstream-php",
|
"name": "maennchen/zipstream-php",
|
||||||
"version": "3.2.0",
|
"version": "3.2.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||||
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416"
|
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
|
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||||
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
|
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -3247,7 +3015,7 @@
|
|||||||
"require-dev": {
|
"require-dev": {
|
||||||
"brianium/paratest": "^7.7",
|
"brianium/paratest": "^7.7",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"friendsofphp/php-cs-fixer": "^3.16",
|
"friendsofphp/php-cs-fixer": "^3.86",
|
||||||
"guzzlehttp/guzzle": "^7.5",
|
"guzzlehttp/guzzle": "^7.5",
|
||||||
"mikey179/vfsstream": "^1.6",
|
"mikey179/vfsstream": "^1.6",
|
||||||
"php-coveralls/php-coveralls": "^2.5",
|
"php-coveralls/php-coveralls": "^2.5",
|
||||||
@@ -3293,7 +3061,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
"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": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -3301,7 +3069,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-07-17T11:15:13+00:00"
|
"time": "2025-12-10T09:58:31+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "markbaker/complex",
|
"name": "markbaker/complex",
|
||||||
@@ -3410,73 +3178,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2022-12-02T22:17:43+00:00"
|
"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",
|
"name": "meilisearch/meilisearch-php",
|
||||||
"version": "v1.13.0",
|
"version": "v1.13.0",
|
||||||
@@ -5330,72 +5031,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-02-28T15:16:05+00:00"
|
"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",
|
"name": "spatie/laravel-package-tools",
|
||||||
"version": "1.92.0",
|
"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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
Schema::table('person', function (Blueprint $table){
|
||||||
|
$table->string('employer', 125)->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('person', function (Blueprint $table){
|
||||||
|
$table->dropColumn('employer');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Add a generated tsvector column for fulltext search
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE person_addresses
|
||||||
|
ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
to_tsvector('simple',
|
||||||
|
coalesce(address, '') || ' ' ||
|
||||||
|
coalesce(post_code, '') || ' ' ||
|
||||||
|
coalesce(city, '')
|
||||||
|
)
|
||||||
|
) STORED
|
||||||
|
");
|
||||||
|
|
||||||
|
// Create GIN index on the tsvector column for fast fulltext search
|
||||||
|
DB::statement('CREATE INDEX person_addresses_search_vector_idx ON person_addresses USING GIN(search_vector)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('person_addresses', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('person_addresses_search_vector_idx');
|
||||||
|
$table->dropColumn('search_vector');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -14,7 +14,7 @@ public function run(): void
|
|||||||
'key' => 'person',
|
'key' => 'person',
|
||||||
'canonical_root' => 'person',
|
'canonical_root' => 'person',
|
||||||
'label' => 'Person',
|
'label' => 'Person',
|
||||||
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'],
|
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description', 'employer'],
|
||||||
'field_aliases' => [
|
'field_aliases' => [
|
||||||
'dob' => 'birthday',
|
'dob' => 'birthday',
|
||||||
'date_of_birth' => 'birthday',
|
'date_of_birth' => 'birthday',
|
||||||
@@ -30,6 +30,7 @@ public function run(): void
|
|||||||
['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'],
|
['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'],
|
||||||
['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'],
|
['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'],
|
||||||
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
|
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
|
||||||
|
['pattern' => '/^(delodajalec|služba)\b/i', 'field' => 'employer']
|
||||||
],
|
],
|
||||||
'ui' => ['order' => 1],
|
'ui' => ['order' => 1],
|
||||||
],
|
],
|
||||||
|
|||||||
Generated
+1646
-3190
File diff suppressed because it is too large
Load Diff
+27
-44
@@ -3,63 +3,46 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build"
|
||||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@inertiajs/vue3": "2.0",
|
"@inertiajs/vue3": "2.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@types/node": "^24.10.3",
|
"autoprefixer": "^10.4.16",
|
||||||
"@vitejs/plugin-vue": "^6.0.2",
|
"axios": "^1.7.4",
|
||||||
"autoprefixer": "^10.4.22",
|
|
||||||
"axios": "^1.13.2",
|
|
||||||
"laravel-vite-plugin": "^2.0.1",
|
"laravel-vite-plugin": "^2.0.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.4.32",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.9.3",
|
"vite": "^7.1.7",
|
||||||
"vite": "^7.2.7",
|
"vue": "^3.3.13"
|
||||||
"vue": "^3.3.13",
|
|
||||||
"vue-tsc": "^3.1.8"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/vue-fontawesome": "^3.1.2",
|
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||||
|
"quill": "^1.3.7",
|
||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@heroicons/vue": "^2.2.0",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@internationalized/date": "^3.10.0",
|
"@internationalized/date": "^3.9.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@vuepic/vue-datepicker": "^11.0.2",
|
||||||
"@unovis/ts": "^1.6.2",
|
"apexcharts": "^4.0.0",
|
||||||
"@unovis/vue": "^1.6.2",
|
"flowbite": "^2.5.2",
|
||||||
"@vee-validate/zod": "^4.15.1",
|
"flowbite-vue": "^0.1.6",
|
||||||
"@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",
|
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-vue-next": "^0.552.0",
|
|
||||||
"material-design-icons-iconfont": "^6.7.0",
|
"material-design-icons-iconfont": "^6.7.0",
|
||||||
"preline": "^2.7.0",
|
"preline": "^2.7.0",
|
||||||
"quill": "^1.3.7",
|
"reka-ui": "^2.5.1",
|
||||||
"reka-ui": "^2.6.1",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"tailwindcss-inner-border": "^0.2.0",
|
"tailwindcss-inner-border": "^0.2.0",
|
||||||
"v-calendar": "^3.1.2",
|
"v-calendar": "^3.1.2",
|
||||||
"vee-validate": "^4.15.1",
|
"vue-multiselect": "^3.1.0",
|
||||||
"vue-currency-input": "^3.2.1",
|
"vue-search-input": "^1.1.16",
|
||||||
"vue-multiselect": "^3.4.0",
|
"vue3-apexcharts": "^1.7.0",
|
||||||
"vue-search-input": "^1.1.19",
|
|
||||||
"vue-sonner": "^2.0.9",
|
|
||||||
"vue3-apexcharts": "^1.10.0",
|
|
||||||
"vuedraggable": "^4.1.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 {
|
export default {
|
||||||
plugins: [
|
plugins: {
|
||||||
tailwindcss(),
|
tailwindcss: {},
|
||||||
autoprefixer(),
|
autoprefixer: {},
|
||||||
],
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-263
@@ -1,143 +1,10 @@
|
|||||||
@import "tailwindcss";
|
@import '/node_modules/floating-vue/dist/style.css';
|
||||||
@import "./themes.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] {
|
[x-cloak] {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -145,127 +12,3 @@ [x-cloak] {
|
|||||||
|
|
||||||
/* Ensure dropdowns/menus render above dialog overlays when appended to body */
|
/* Ensure dropdowns/menus render above dialog overlays when appended to body */
|
||||||
.multiselect__content-wrapper { z-index: 2147483647 !important; }
|
.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>
|
<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>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<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 DialogModal from './DialogModal.vue';
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
@@ -9,7 +9,6 @@ import ActionMessage from './ActionMessage.vue';
|
|||||||
import PrimaryButton from './PrimaryButton.vue';
|
import PrimaryButton from './PrimaryButton.vue';
|
||||||
import Modal from './Modal.vue';
|
import Modal from './Modal.vue';
|
||||||
import SecondaryButton from './SecondaryButton.vue';
|
import SecondaryButton from './SecondaryButton.vue';
|
||||||
import { Button } from '@/Components/ui/button';
|
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -122,32 +121,30 @@ const remove = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="['relative rounded-lg border border-gray-200 bg-white shadow-sm overflow-x-auto overflow-y-auto', bodyMaxHeight, stickyHeader ? 'table-sticky' : '']">
|
<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">
|
<FwbTable hoverable striped 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">
|
<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">
|
||||||
<TableRow class="border-b">
|
<FwbTableHeadCell
|
||||||
<TableHead
|
|
||||||
v-for="(h, hIndex) in header"
|
v-for="(h, hIndex) in header"
|
||||||
:key="hIndex"
|
: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"
|
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>
|
>{{ h.data }}</FwbTableHeadCell>
|
||||||
<TableHead v-if="editor" class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90"></TableHead>
|
<FwbTableHeadCell v-if="editor" class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90"></FwbTableHeadCell>
|
||||||
<TableHead v-else class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90" />
|
<FwbTableHeadCell v-else class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90" />
|
||||||
</TableRow>
|
</FwbTableHead>
|
||||||
</TableHeader>
|
<FwbTableBody>
|
||||||
<TableBody>
|
<FwbTableRow v-for="(row, key, parent_index) in body" :key="key" :class="row.options.class">
|
||||||
<TableRow v-for="(row, key, parent_index) in body" :key="key" :class="[row.options.class, 'hover:bg-gray-50/50']">
|
<FwbTableCell v-for="(col, colIndex) in row.cols" :key="colIndex" class="align-middle">
|
||||||
<TableCell 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>
|
<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>
|
<span v-else>{{ col.data }}</span>
|
||||||
</TableCell>
|
</FwbTableCell>
|
||||||
<TableCell v-if="editor" class="text-right whitespace-nowrap">
|
<FwbTableCell 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>
|
<fwb-button class="mr-2" size="sm" color="default" @click="openEditor(row.options.ref, row.options.editable)" outline>Edit</fwb-button>
|
||||||
<Button size="sm" variant="destructive" @click="showModal(row.options.ref.val, row.options.title)">Remove</Button>
|
<fwb-button size="sm" color="red" @click="showModal(row.options.ref.val, row.options.title)" outline>Remove</fwb-button>
|
||||||
</TableCell>
|
</FwbTableCell>
|
||||||
<TableCell v-else />
|
<FwbTableCell v-else />
|
||||||
</TableRow>
|
</FwbTableRow>
|
||||||
</TableBody>
|
</FwbTableBody>
|
||||||
</Table>
|
</FwbTable>
|
||||||
<div v-if="!body || body.length === 0" class="p-6 text-center text-sm text-gray-500">No records found.</div>
|
<div v-if="!body || body.length === 0" class="p-6 text-center text-sm text-gray-500">No records found.</div>
|
||||||
</div>
|
</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">
|
<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"/>
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
|
||||||
</svg>
|
</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>
|
<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">{{ page.title }}</span>
|
<span v-else class="ms-1 text-sm font-medium text-gray-500 md:ms-2 dark:text-gray-400">{{ page.title }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import DeleteDialog from './Dialogs/DeleteDialog.vue';
|
import DialogModal from './DialogModal.vue';
|
||||||
|
import PrimaryButton from './PrimaryButton.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: { type: Boolean, default: false },
|
show: { type: Boolean, default: false },
|
||||||
@@ -8,8 +9,6 @@ const props = defineProps({
|
|||||||
confirmText: { type: String, default: 'Potrdi' },
|
confirmText: { type: String, default: 'Potrdi' },
|
||||||
cancelText: { type: String, default: 'Prekliči' },
|
cancelText: { type: String, default: 'Prekliči' },
|
||||||
danger: { type: Boolean, default: false },
|
danger: { type: Boolean, default: false },
|
||||||
itemName: { type: String, default: null },
|
|
||||||
processing: { type: Boolean, default: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'confirm']);
|
const emit = defineEmits(['close', 'confirm']);
|
||||||
@@ -19,15 +18,21 @@ const onConfirm = () => emit('confirm');
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DeleteDialog
|
<DialogModal :show="show" @close="onClose">
|
||||||
:show="show"
|
<template #title>
|
||||||
:title="title"
|
{{ title }}
|
||||||
:message="message"
|
</template>
|
||||||
:item-name="itemName"
|
<template #content>
|
||||||
:confirm-text="confirmText"
|
<p class="text-sm text-gray-700">{{ message }}</p>
|
||||||
:cancel-text="cancelText"
|
<div class="mt-6 flex items-center justify-end gap-3">
|
||||||
:processing="processing"
|
<button type="button" class="px-4 py-2 rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50" @click="onClose">
|
||||||
@close="onClose"
|
{{ cancelText }}
|
||||||
@confirm="onConfirm"
|
</button>
|
||||||
/>
|
<PrimaryButton :class="danger ? 'bg-red-600 hover:bg-red-700 focus:ring-red-500' : ''" @click="onConfirm">
|
||||||
|
{{ confirmText }}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { watch, onMounted } from "vue";
|
import { watch, onMounted } from "vue";
|
||||||
import { useCurrencyInput } from "vue-currency-input";
|
import { useCurrencyInput } from "vue-currency-input";
|
||||||
import { Input } from "@/Components/ui/input";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: [Number, String, null], default: null },
|
modelValue: { type: [Number, String, null], default: null },
|
||||||
@@ -16,7 +14,6 @@ const props = defineProps({
|
|||||||
precision: { type: [Number, Object], default: 2 },
|
precision: { type: [Number, Object], default: 2 },
|
||||||
allowNegative: { type: Boolean, default: false },
|
allowNegative: { type: Boolean, default: false },
|
||||||
useGrouping: { type: Boolean, default: true },
|
useGrouping: { type: Boolean, default: true },
|
||||||
class: { type: String, default: "" },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue", "change"]);
|
const emit = defineEmits(["update:modelValue", "change"]);
|
||||||
@@ -84,7 +81,7 @@ onMounted(() => {
|
|||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:required="required"
|
: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"
|
autocomplete="off"
|
||||||
@change="$emit('change', numberValue)"
|
@change="$emit('change', numberValue)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ provide('selected', selected);
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<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">
|
<ul class="flex flex-wrap -mb-px">
|
||||||
<li class="me-2" v-for="tab in tabs" :key="tab.name">
|
<li class="me-2" v-for="tab in tabs" :key="tab.name">
|
||||||
<button
|
<button
|
||||||
@click="selected = tab.name"
|
@click="selected = tab.name"
|
||||||
class="inline-block p-4"
|
class="inline-block p-4"
|
||||||
:class="{
|
:class="{
|
||||||
'border-b-2 border-transparent rounded-t-lg hover:text-gray-600 hover:border-gray-300': 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`]: 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 }}
|
{{ 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>
|
<script setup>
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue";
|
|
||||||
import EmptyState from "@/Components/EmptyState.vue";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
FwbTable,
|
||||||
TableHeader,
|
FwbTableHead,
|
||||||
TableHead,
|
FwbTableHeadCell,
|
||||||
TableBody,
|
FwbTableBody,
|
||||||
TableRow,
|
FwbTableRow,
|
||||||
TableCell,
|
FwbTableCell,
|
||||||
} from "@/Components/ui/table";
|
} from "flowbite-vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class?, formatter? }]
|
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class?, formatter? }]
|
||||||
@@ -199,12 +197,11 @@ function setPageSize(ps) {
|
|||||||
<div
|
<div
|
||||||
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
|
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||||
>
|
>
|
||||||
<Table class="text-sm">
|
<FwbTable hoverable striped class="text-sm">
|
||||||
<TableHeader
|
<FwbTableHead
|
||||||
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
|
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
|
||||||
>
|
>
|
||||||
<TableRow class="border-b">
|
<FwbTableHeadCell v-for="col in columns" :key="col.key" :class="col.class">
|
||||||
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
|
|
||||||
<button
|
<button
|
||||||
v-if="col.sortable"
|
v-if="col.sortable"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -219,23 +216,23 @@ function setPageSize(ps) {
|
|||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<span v-else>{{ col.label }}</span>
|
<span v-else>{{ col.label }}</span>
|
||||||
</TableHead>
|
</FwbTableHeadCell>
|
||||||
<TableHead v-if="$slots.actions" class="w-px"> </TableHead>
|
<FwbTableHeadCell v-if="$slots.actions" class="w-px"> </FwbTableHeadCell>
|
||||||
</TableRow>
|
</FwbTableHead>
|
||||||
</TableHeader>
|
|
||||||
|
|
||||||
<TableBody>
|
<FwbTableBody>
|
||||||
<template v-if="!loading && pageRows.length">
|
<template v-if="!loading && pageRows.length">
|
||||||
<TableRow
|
<FwbTableRow
|
||||||
v-for="(row, idx) in pageRows"
|
v-for="(row, idx) in pageRows"
|
||||||
:key="keyOf(row)"
|
:key="keyOf(row)"
|
||||||
@click="$emit('row:click', row)"
|
@click="$emit('row:click', row)"
|
||||||
class="cursor-default hover:bg-gray-50/50"
|
class="cursor-default"
|
||||||
>
|
>
|
||||||
<TableCell
|
<FwbTableCell
|
||||||
v-for="col in columns"
|
v-for="col in columns"
|
||||||
:key="col.key"
|
:key="col.key"
|
||||||
:class="col.class"
|
:class="col.class"
|
||||||
|
:align="col.align || 'left'"
|
||||||
>
|
>
|
||||||
<template v-if="$slots['cell-' + col.key]">
|
<template v-if="$slots['cell-' + col.key]">
|
||||||
<slot
|
<slot
|
||||||
@@ -258,37 +255,33 @@ function setPageSize(ps) {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
{{ col.formatter ? col.formatter(row) : row?.[col.key] ?? "" }}
|
{{ col.formatter ? col.formatter(row) : row?.[col.key] ?? "" }}
|
||||||
</template>
|
</template>
|
||||||
</TableCell>
|
</FwbTableCell>
|
||||||
<TableCell v-if="$slots.actions" class="w-px text-right">
|
<FwbTableCell v-if="$slots.actions" class="w-px text-right">
|
||||||
<slot name="actions" :row="row" :index="idx" />
|
<slot name="actions" :row="row" :index="idx" />
|
||||||
</TableCell>
|
</FwbTableCell>
|
||||||
</TableRow>
|
</FwbTableRow>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="loading">
|
<template v-else-if="loading">
|
||||||
<TableRow>
|
<FwbTableRow>
|
||||||
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||||
<SkeletonTable :rows="5" :cols="columns.length" />
|
<div class="p-6 text-center text-gray-500">Nalagam...</div>
|
||||||
</TableCell>
|
</FwbTableCell>
|
||||||
</TableRow>
|
</FwbTableRow>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<TableRow>
|
<FwbTableRow>
|
||||||
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||||
<slot name="empty">
|
<slot name="empty">
|
||||||
<EmptyState
|
<div class="p-6 text-center text-gray-500">{{ emptyText }}</div>
|
||||||
:title="emptyText"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</slot>
|
</slot>
|
||||||
</TableCell>
|
</FwbTableCell>
|
||||||
</TableRow>
|
</FwbTableRow>
|
||||||
</template>
|
</template>
|
||||||
</TableBody>
|
</FwbTableBody>
|
||||||
</Table>
|
</FwbTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
v-if="showPagination"
|
|
||||||
class="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm text-gray-700"
|
class="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm text-gray-700"
|
||||||
aria-label="Pagination"
|
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>
|
<script setup>
|
||||||
import { ref, watch, computed } from "vue";
|
import { ref, watch, computed } from "vue";
|
||||||
import { router } from "@inertiajs/vue3";
|
import { router } from "@inertiajs/vue3";
|
||||||
import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue";
|
|
||||||
import EmptyState from "@/Components/EmptyState.vue";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
FwbTable,
|
||||||
TableHeader,
|
FwbTableHead,
|
||||||
TableHead,
|
FwbTableHeadCell,
|
||||||
TableBody,
|
FwbTableBody,
|
||||||
TableRow,
|
FwbTableRow,
|
||||||
TableCell,
|
FwbTableCell,
|
||||||
} from "@/Components/ui/table";
|
} from "flowbite-vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class? }]
|
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class? }]
|
||||||
@@ -96,8 +94,7 @@ function setPageSize(ps) {
|
|||||||
function doRequest(overrides = {}) {
|
function doRequest(overrides = {}) {
|
||||||
const q = {
|
const q = {
|
||||||
...props.query,
|
...props.query,
|
||||||
// Laravel expects snake_case per_page
|
perPage: overrides.perPage ?? props.meta?.per_page ?? props.pageSize ?? 10,
|
||||||
per_page: overrides.perPage ?? props.meta?.per_page ?? props.pageSize ?? 10,
|
|
||||||
sort: overrides.sort ?? props.sort?.key ?? null,
|
sort: overrides.sort ?? props.sort?.key ?? null,
|
||||||
direction: overrides.direction ?? props.sort?.direction ?? null,
|
direction: overrides.direction ?? props.sort?.direction ?? null,
|
||||||
search: overrides.search ?? props.search ?? "",
|
search: overrides.search ?? props.search ?? "",
|
||||||
@@ -201,12 +198,15 @@ function goToPageInput() {
|
|||||||
<div
|
<div
|
||||||
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
|
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||||
>
|
>
|
||||||
<Table class="text-sm">
|
<FwbTable hoverable striped class="text-sm">
|
||||||
<TableHeader
|
<FwbTableHead
|
||||||
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
|
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
|
||||||
>
|
>
|
||||||
<TableRow class="border-b">
|
<FwbTableHeadCell v-for="col in columns" :key="col.key" :class="col.class">
|
||||||
<TableHead 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
|
<button
|
||||||
v-if="col.sortable"
|
v-if="col.sortable"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -221,23 +221,24 @@ function goToPageInput() {
|
|||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<span v-else>{{ col.label }}</span>
|
<span v-else>{{ col.label }}</span>
|
||||||
</TableHead>
|
</template>
|
||||||
<TableHead v-if="$slots.actions" class="w-px"> </TableHead>
|
</FwbTableHeadCell>
|
||||||
</TableRow>
|
<FwbTableHeadCell v-if="$slots.actions" class="w-px"> </FwbTableHeadCell>
|
||||||
</TableHeader>
|
</FwbTableHead>
|
||||||
|
|
||||||
<TableBody>
|
<FwbTableBody>
|
||||||
<template v-if="!loading && rows.length">
|
<template v-if="!loading && rows.length">
|
||||||
<TableRow
|
<FwbTableRow
|
||||||
v-for="(row, idx) in rows"
|
v-for="(row, idx) in rows"
|
||||||
:key="keyOf(row)"
|
:key="keyOf(row)"
|
||||||
@click="$emit('row:click', row)"
|
@click="$emit('row:click', row)"
|
||||||
class="cursor-default hover:bg-gray-50/50"
|
class="cursor-default"
|
||||||
>
|
>
|
||||||
<TableCell
|
<FwbTableCell
|
||||||
v-for="col in columns"
|
v-for="col in columns"
|
||||||
:key="col.key"
|
:key="col.key"
|
||||||
:class="col.class"
|
:class="col.class"
|
||||||
|
:align="col.align || 'left'"
|
||||||
>
|
>
|
||||||
<template v-if="$slots['cell-' + col.key]">
|
<template v-if="$slots['cell-' + col.key]">
|
||||||
<slot
|
<slot
|
||||||
@@ -260,30 +261,30 @@ function goToPageInput() {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
{{ row?.[col.key] ?? "" }}
|
{{ row?.[col.key] ?? "" }}
|
||||||
</template>
|
</template>
|
||||||
</TableCell>
|
</FwbTableCell>
|
||||||
<TableCell v-if="$slots.actions" class="w-px text-right">
|
<FwbTableCell v-if="$slots.actions" class="w-px text-right">
|
||||||
<slot name="actions" :row="row" :index="idx" />
|
<slot name="actions" :row="row" :index="idx" />
|
||||||
</TableCell>
|
</FwbTableCell>
|
||||||
</TableRow>
|
</FwbTableRow>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="loading">
|
<template v-else-if="loading">
|
||||||
<TableRow>
|
<FwbTableRow>
|
||||||
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||||
<SkeletonTable :rows="5" :cols="columns.length" />
|
<div class="p-6 text-center text-gray-500">Nalagam...</div>
|
||||||
</TableCell>
|
</FwbTableCell>
|
||||||
</TableRow>
|
</FwbTableRow>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<TableRow>
|
<FwbTableRow>
|
||||||
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
|
||||||
<slot name="empty">
|
<slot name="empty">
|
||||||
<div class="p-6 text-center text-gray-500">{{ emptyText }}</div>
|
<div class="p-6 text-center text-gray-500">{{ emptyText }}</div>
|
||||||
</slot>
|
</slot>
|
||||||
</TableCell>
|
</FwbTableCell>
|
||||||
</TableRow>
|
</FwbTableRow>
|
||||||
</template>
|
</template>
|
||||||
</TableBody>
|
</FwbTableBody>
|
||||||
</Table>
|
</FwbTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav
|
<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,15 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import Modal from './Modal.vue';
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/Components/ui/dialog';
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
defineProps({
|
||||||
show: {
|
show: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -24,53 +18,30 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:show', 'close']);
|
const close = () => {
|
||||||
|
|
||||||
const open = ref(props.show);
|
|
||||||
|
|
||||||
watch(() => props.show, (newVal) => {
|
|
||||||
open.value = newVal;
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(open, (newVal) => {
|
|
||||||
emit('update:show', newVal);
|
|
||||||
if (!newVal) {
|
|
||||||
emit('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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog v-model:open="open" :modal="true">
|
<Modal
|
||||||
<DialogContent :class="maxWidthClass">
|
:show="show"
|
||||||
<DialogHeader>
|
:max-width="maxWidth"
|
||||||
<DialogTitle>
|
:closeable="closeable"
|
||||||
|
@close="close"
|
||||||
|
>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="text-lg font-medium text-gray-900">
|
||||||
<slot name="title" />
|
<slot name="title" />
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<slot name="description" />
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div class="py-4">
|
|
||||||
<slot name="content" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<div class="mt-4 text-sm text-gray-600">
|
||||||
|
<slot name="content" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-row justify-end px-6 py-4 bg-gray-100 text-end">
|
||||||
<slot name="footer" />
|
<slot name="footer" />
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
</Modal>
|
||||||
</Dialog>
|
|
||||||
</template>
|
</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>
|
<script setup>
|
||||||
// ListGroup components removed - using custom implementation
|
import { FwbListGroup, FwbListGroupItem } from 'flowbite-vue';
|
||||||
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
|
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||||
import draggable from 'vuedraggable';
|
import draggable from 'vuedraggable';
|
||||||
|
|
||||||
@@ -68,13 +68,13 @@ watch(
|
|||||||
group="actions"
|
group="actions"
|
||||||
>
|
>
|
||||||
<template #item="{element, index}">
|
<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>
|
<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"/>
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
|
||||||
</svg></button>
|
</svg></i>
|
||||||
</li>
|
</fwb-list-group-item>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
<draggable
|
<draggable
|
||||||
@@ -92,13 +92,13 @@ watch(
|
|||||||
group="actions"
|
group="actions"
|
||||||
>
|
>
|
||||||
<template #item="{element, index}">
|
<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>
|
<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"/>
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
|
||||||
</svg></button>
|
</svg></i>
|
||||||
</li>
|
</fwb-list-group-item>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/Components/ui/dropdown-menu';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
align: {
|
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 closeOnEscape = (e) => {
|
||||||
const close = () => {
|
if (open.value && e.key === 'Escape') {
|
||||||
open.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
close,
|
|
||||||
open,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close dropdown when dialog opens
|
|
||||||
const handleDialogOpen = () => {
|
|
||||||
if (open.value) {
|
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch for dialog opens using MutationObserver
|
const updatePosition = () => {
|
||||||
let observer = null;
|
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` };
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
const onWindowChange = () => {
|
||||||
// Listen for custom dialog open events
|
updatePosition();
|
||||||
window.addEventListener('dialog:open', handleDialogOpen);
|
};
|
||||||
|
|
||||||
// Watch for dialog state changes in the DOM
|
watch(open, async (val) => {
|
||||||
observer = new MutationObserver((mutations) => {
|
if (val) {
|
||||||
// Check if any dialog has data-state="open"
|
await nextTick();
|
||||||
const openDialogs = document.querySelectorAll('[data-state="open"]');
|
updatePosition();
|
||||||
const hasOpenDialog = Array.from(openDialogs).some((dialog) => {
|
window.addEventListener('resize', onWindowChange);
|
||||||
// Check if it's a dialog element (has role="dialog" or is DialogContent)
|
window.addEventListener('scroll', onWindowChange, true);
|
||||||
const role = dialog.getAttribute('role');
|
} else {
|
||||||
const isDialogContent = dialog.classList?.contains('DialogContent') ||
|
window.removeEventListener('resize', onWindowChange);
|
||||||
dialog.querySelector('[role="dialog"]') ||
|
window.removeEventListener('scroll', onWindowChange, true);
|
||||||
dialog.closest('[role="dialog"]');
|
|
||||||
|
|
||||||
return role === 'dialog' || isDialogContent;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasOpenDialog && open.value) {
|
|
||||||
handleDialogOpen();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(document.body, {
|
onMounted(() => document.addEventListener('keydown', closeOnEscape));
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['data-state'],
|
|
||||||
subtree: true,
|
|
||||||
childList: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('dialog:open', handleDialogOpen);
|
document.removeEventListener('keydown', closeOnEscape);
|
||||||
if (observer) {
|
window.removeEventListener('resize', onWindowChange);
|
||||||
observer.disconnect();
|
window.removeEventListener('scroll', onWindowChange, true);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const widthClass = computed(() => {
|
const widthClass = computed(() => {
|
||||||
@@ -95,38 +90,57 @@ const widthClass = computed(() => {
|
|||||||
'wide': 'w-[34rem] max-w-[90vw]',
|
'wide': 'w-[34rem] max-w-[90vw]',
|
||||||
'auto': '',
|
'auto': '',
|
||||||
};
|
};
|
||||||
return map[props.width.toString()] || '';
|
return map[props.width.toString()] ?? '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map align prop to shadcn-vue's align prop
|
const alignmentClasses = computed(() => {
|
||||||
// 'left' -> 'start', 'right' -> 'end'
|
if (props.align === 'left') {
|
||||||
const alignProp = computed(() => {
|
return 'ltr:origin-top-left rtl:origin-top-right start-0';
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenu v-model:open="open">
|
<div class="relative" ref="triggerEl">
|
||||||
<DropdownMenuTrigger as-child>
|
<div @click="open = ! open">
|
||||||
<slot name="trigger" />
|
<slot name="trigger" />
|
||||||
</DropdownMenuTrigger>
|
</div>
|
||||||
<DropdownMenuContent
|
|
||||||
:align="alignProp"
|
<teleport to="body">
|
||||||
:class="combinedContentClasses"
|
<!-- 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" />
|
<slot name="content" />
|
||||||
</DropdownMenuContent>
|
</div>
|
||||||
</DropdownMenu>
|
</div>
|
||||||
|
</transition>
|
||||||
|
</teleport>
|
||||||
|
</div>
|
||||||
</template>
|
</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>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user