23 Commits

Author SHA1 Message Date
Simon Pocrnjič 8031501d25 Changes to import where on reactivation if start_date not set import mapping it should fill field with current date, same for end_date but it sets it to null 2026-01-02 14:49:05 +01:00
Simon Pocrnjič adc2a64687 Fixed some field job problem where field operator could still see archived contracts 2025-12-21 21:00:49 +01:00
Simon Pocrnjič 11206fb4f7 UTF8 fixed 2025-12-18 20:48:11 +01:00
Simon Pocrnjič 39a597f6eb added #N/A check 2025-12-18 20:22:14 +01:00
Simon Pocrnjič 5d4498ac5a fixed address import 2025-12-18 19:40:27 +01:00
Simon Pocrnjič 622f53e401 removed something 2025-12-18 18:25:15 +01:00
Simon Pocrnjič 96473fd60b dwdwf 2025-12-17 22:21:36 +01:00
Simon Pocrnjič 5ddca35389 test 2025-12-17 22:17:00 +01:00
Simon Pocrnjič 94ad0c0772 test 2025-12-17 22:14:43 +01:00
Simon Pocrnjič 2140181a76 sdwsd 2025-12-17 21:50:48 +01:00
Simon Pocrnjič 06fa443b3e trimming additional spaces example TEST 2 now to TEST 2 2025-12-17 21:22:17 +01:00
Simon Pocrnjič 6c45063e47 fixed naslove 2025-12-17 21:12:53 +01:00
Simon Pocrnjič b8c9b51f29 test 2025-12-17 20:51:31 +01:00
Simon Pocrnjič a4db37adfa Fix error reporting 2025-12-17 20:49:11 +01:00
Simon Pocrnjič 76f76f73b4 error handling importer 2025-12-17 20:45:02 +01:00
Simon Pocrnjič d69f4dd6f6 On template creation for entity activities action and decision or not required anymore 2025-12-16 20:18:23 +01:00
Simon Pocrnjič a596177a68 changes to import added activity entity 2025-12-16 19:35:51 +01:00
Simon Pocrnjič aa40ebed5c Again contract format problem in excel export fixed now! 2025-12-10 21:36:17 +01:00
Simon Pocrnjič 79de54eef0 Contract reference text format inside exported excel 2025-12-10 21:15:53 +01:00
Simon Pocrnjič 53941c054e add maatwebsite/excel to composer.json as required package 2025-12-10 21:05:22 +01:00
Simon Pocrnjič 1a7d2793b0 removed csrf from blade 2025-12-10 20:52:31 +01:00
Simon Pocrnjič fa54cf48f3 Segment view contract export option 2025-12-10 20:41:10 +01:00
Simon Pocrnjič d2287ef963 Changed SMS package so it allows separate SMS for each person contract. 2025-12-07 09:45:19 +01:00
410 changed files with 11891 additions and 30381 deletions
+4 -32
View File
@@ -22,7 +22,7 @@ ## Foundational Context
- pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11
- @inertiajs/vue3 (INERTIA) - v2
- tailwindcss (TAILWINDCSS) - v4
- tailwindcss (TAILWINDCSS) - v3
- vue (VUE) - v3
@@ -359,39 +359,11 @@ ### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
=== tailwindcss/v3 rules ===
## Tailwind 4
## Tailwind 3
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
- Always use Tailwind CSS v3 - verify you're using only classes supported by this version.
=== tests rules ===
-1
View File
@@ -19,4 +19,3 @@ yarn-error.log
/.idea
/.vscode
/.zed
/shadcn-vue
@@ -1,57 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RefreshMaterializedViews extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reports:refresh-mviews {--concurrently : Use CONCURRENTLY (Postgres 9.4+; requires indexes)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Refresh configured Postgres materialized views for reporting';
/**
* Execute the console command.
*/
public function handle(): int
{
$views = (array) config('reports.materialized_views', []);
if (empty($views)) {
$this->info('No materialized views configured.');
return self::SUCCESS;
}
$concurrently = $this->option('concurrently') ? ' CONCURRENTLY' : '';
foreach ($views as $view) {
$name = trim((string) $view);
if ($name === '') {
continue;
}
$sql = 'REFRESH MATERIALIZED VIEW'.$concurrently.' '.DB::getPdo()->quote($name);
// PDO::quote wraps with single quotes; for identifiers we need double quotes or no quotes.
// Use a safe fallback: wrap with " if not already quoted
$safe = 'REFRESH MATERIALIZED VIEW'.$concurrently.' "'.str_replace('"', '""', $name).'"';
try {
DB::statement($safe);
$this->info("Refreshed: {$name}");
} catch (\Throwable $e) {
$this->error("Failed to refresh {$name}: ".$e->getMessage());
}
}
return self::SUCCESS;
}
}
-9
View File
@@ -22,15 +22,6 @@ protected function schedule(Schedule $schedule): void
'--days' => $days,
])->dailyAt('02:00');
}
// Optional: refresh configured materialized views for reporting
$views = (array) config('reports.materialized_views', []);
if (! empty($views)) {
$time = (string) (config('reports.refresh_time', '03:00') ?: '03:00');
$schedule->command('reports:refresh-mviews', [
'--concurrently' => true,
])->dailyAt($time);
}
}
/**
+170
View File
@@ -0,0 +1,170 @@
<?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 SegmentContractsExport 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' => 'Pogodba'],
'client_case' => ['label' => 'Primer'],
'client' => ['label' => 'Stranka'],
'type' => ['label' => 'Vrsta'],
'start_date' => ['label' => 'Začetek'],
'end_date' => ['label' => 'Konec'],
'account' => ['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 (in_array($column, ['start_date', 'end_date'], true)) {
$formats[$letter] = self::DATE_EXCEL_FORMAT;
}
}
return $formats;
}
private function resolveValue(Contract $contract, string $column): mixed
{
return match ($column) {
'reference' => $contract->reference,
'client_case' => optional($contract->clientCase?->person)->full_name,
'client' => optional($contract->clientCase?->client?->person)->full_name,
'type' => optional($contract->type)->name,
'start_date' => $this->formatDate($contract->start_date),
'end_date' => $this->formatDate($contract->end_date),
'account' => optional($contract->account)->balance_amount,
default => null,
};
}
private function formatDate(mixed $value): ?float
{
$carbon = Carbon::make($value);
if (! $carbon) {
return null;
}
return ExcelDate::dateTimeToExcel($carbon->copy()->startOfDay());
}
private function columnLetter(int $index): string
{
$index++;
$letter = '';
while ($index > 0) {
$remainder = ($index - 1) % 26;
$letter = chr(65 + $remainder).$letter;
$index = intdiv($index - 1, 26);
}
return $letter;
}
public function bindValue(Cell $cell, $value): bool
{
$columnKey = $this->getColumnLetterMap()[$cell->getColumn()] ?? null;
if ($columnKey === 'reference') {
$cell->setValueExplicit((string) $value, DataType::TYPE_STRING);
return true;
}
return parent::bindValue($cell, $value);
}
/**
* @return array<string, string>
*/
private function getColumnLetterMap(): array
{
if ($this->columnLetterMap === []) {
foreach ($this->columns as $index => $column) {
$this->columnLetterMap[$this->columnLetter($index)] = $column;
}
}
return $this->columnLetterMap;
}
}
-224
View File
@@ -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++;
}
}
}
}
@@ -223,6 +223,8 @@ public function store(StorePackageRequest $request): RedirectResponse
'created_by' => optional($request->user())->id,
]);
dd($data['items']);
$items = collect($data['items'])
->map(function (array $row) {
return new PackageItem([
@@ -288,20 +290,6 @@ public function cancel(Package $package): RedirectResponse
return back()->with('success', 'Package canceled');
}
public function destroy(Package $package): RedirectResponse
{
// Allow deletion only for drafts (not yet dispatched)
if ($package->status !== Package::STATUS_DRAFT) {
return back()->with('error', 'Package not in a deletable state.');
}
// Remove items first to avoid FK issues
$package->items()->delete();
$package->delete();
return back()->with('success', 'Package deleted');
}
/**
* List contracts for a given segment and include selected phone per person.
*/
@@ -471,12 +459,12 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
continue;
}
$key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu;
if ($seen->contains($key)) {
/*if ($seen->contains($key)) {
// skip duplicates across multiple contracts/persons
$skipped++;
continue;
}
}*/
$seen->push($key);
$items[] = [
'number' => (string) $phone->nu,
File diff suppressed because it is too large Load Diff
+100 -91
View File
@@ -3,51 +3,57 @@
namespace App\Http\Controllers;
use App\Models\Client;
use App\Services\ReferenceDataCache;
use DB;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ClientController extends Controller
{
public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Client $client, Request $request)
{
$search = $request->input('search');
$query = $client::query()
->select('clients.*')
->when($search, function ($que) use ($search) {
$que->join('person', 'person.id', '=', 'clients.person_id')
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('clients.id');
->with('person')
->when($request->input('search'), function ($que, $search) {
$que->whereHas('person', function ($q) use ($search) {
$q->where('full_name', 'ilike', '%'.$search.'%');
});
})
->where('clients.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('contracts', function ($join) {
$join->on('contracts.client_case_id', '=', 'client_cases.id')
->whereNull('contracts.deleted_at');
})
->leftJoin('contract_segment', function ($join) {
$join->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true);
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('clients.id')
->where('active', 1)
->addSelect([
// Number of client cases for this client that have at least one active contract
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
// Sum of account balances for active contracts
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
'cases_with_active_contracts_count' => DB::query()
->from('client_cases')
->join('contracts', 'contracts.client_case_id', '=', 'client_cases.id')
->selectRaw('COUNT(DISTINCT client_cases.id)')
->whereColumn('client_cases.client_id', 'clients.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
// Sum of account balances for active contracts that belong to this client's cases
'active_contracts_balance_sum' => DB::query()
->from('contracts')
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
->whereExists(function ($q) {
$q->from('client_cases')
->whereColumn('client_cases.id', 'contracts.client_case_id')
->whereColumn('client_cases.client_id', 'clients.id');
})
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
])
->with('person')
->orderByDesc('clients.created_at');
->orderByDesc('created_at');
return Inertia::render('Client/Index', [
'clients' => $query
->paginate($request->integer('per_page', 15))
->paginate($request->integer('perPage', 15))
->withQueryString(),
'filters' => $request->only(['search']),
]);
@@ -61,37 +67,44 @@ public function show(Client $client, Request $request)
->findOrFail($client->id);
$types = [
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
];
return Inertia::render('Client/Show', [
'client' => $data,
'client_cases' => $data->clientCases()
->select('client_cases.*')
->when($request->input('search'), function ($que, $search) {
$que->join('person', 'person.id', '=', 'client_cases.person_id')
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('client_cases.id');
})
->leftJoin('contracts', function ($join) {
$join->on('contracts.client_case_id', '=', 'client_cases.id')
->whereNull('contracts.deleted_at');
})
->leftJoin('contract_segment', function ($join) {
$join->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true);
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('client_cases.id')
->addSelect([
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->with(['person', 'client.person'])
->where('client_cases.active', 1)
->orderByDesc('client_cases.created_at')
->paginate($request->integer('per_page', 15))
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
'person',
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
))
->addSelect([
'active_contracts_count' => \DB::query()
->from('contracts')
->selectRaw('COUNT(*)')
->whereColumn('contracts.client_case_id', 'client_cases.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
'active_contracts_balance_sum' => \DB::query()
->from('contracts')
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
->whereColumn('contracts.client_case_id', 'client_cases.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
])
->where('active', 1)
->orderByDesc('created_at')
->paginate($request->integer('perPage', 15))
->withQueryString(),
'types' => $types,
'filters' => $request->only(['search']),
@@ -109,30 +122,8 @@ public function contracts(Client $client, Request $request)
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
$contractsQuery = \App\Models\Contract::query()
->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.start_date', 'contracts.client_case_id'])
->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
->where('client_cases.client_id', $client->id)
->whereNull('contracts.deleted_at')
->when($from || $to, function ($q) use ($from, $to) {
if (! empty($from)) {
$q->whereDate('contracts.start_date', '>=', $from);
}
if (! empty($to)) {
$q->whereDate('contracts.start_date', '<=', $to);
}
})
->when($search, function ($q) use ($search) {
$q->leftJoin('person', 'person.id', '=', 'client_cases.person_id')
->where(function ($inner) use ($search) {
$inner->where('contracts.reference', 'ilike', '%'.$search.'%')
->orWhere('person.full_name', 'ilike', '%'.$search.'%');
});
})
->when($segmentIds, function ($q) use ($segmentIds) {
$q->whereHas('segments', function ($s) use ($segmentIds) {
$s->whereIn('segments.id', $segmentIds)
->where('contract_segment.active', true);
});
->whereHas('clientCase', function ($q) use ($client) {
$q->where('client_id', $client->id);
})
->with([
'clientCase:id,uuid,person_id',
@@ -142,25 +133,43 @@ public function contracts(Client $client, Request $request)
},
'account:id,accounts.contract_id,balance_amount',
])
->orderByDesc('contracts.start_date');
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
->whereNull('deleted_at')
->when($from || $to, function ($q) use ($from, $to) {
if (! empty($from)) {
$q->whereDate('start_date', '>=', $from);
}
if (! empty($to)) {
$q->whereDate('start_date', '<=', $to);
}
})
->when($search, function ($q) use ($search) {
$q->where(function ($inner) use ($search) {
$inner->where('reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
});
});
})
->when($segmentIds, function ($q) use ($segmentIds) {
$q->whereHas('segments', function ($s) use ($segmentIds) {
$s->whereIn('segments.id', $segmentIds)
->where('contract_segment.active', true);
});
})
->orderByDesc('start_date');
$segments = \App\Models\Segment::orderBy('name')->get(['id', 'name']);
$types = [
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
];
// Support custom pagination parameter names used by DataTableNew2
$perPage = $request->integer('contracts_per_page', $request->integer('per_page', 15));
$pageNumber = $request->integer('contracts_page', $request->integer('page', 1));
return Inertia::render('Client/Contracts', [
'client' => $data,
'contracts' => $contractsQuery
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
->withQueryString(),
'filters' => $request->only(['from', 'to', 'search', 'segment']),
'contracts' => $contractsQuery->paginate($request->integer('perPage', 20))->withQueryString(),
'filters' => $request->only(['from', 'to', 'search', 'segments']),
'segments' => $segments,
'types' => $types,
]);
@@ -203,14 +212,14 @@ public function store(Request $request)
// \App\Models\Person\PersonAddress::create($address);
return back()->with('success', 'Client created')->with('flash_method', 'POST');
return to_route('client');
}
public function update(Client $client, Request $request)
{
return back()->with('success', 'Client updated')->with('flash_method', 'PUT');
return to_route('client.show', $client);
}
/**
+1 -1
View File
@@ -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)
+118 -52
View File
@@ -2,17 +2,15 @@
namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Activity;
use App\Models\Client;
use App\Models\Contract;
// assuming model name Import
use App\Models\Document; // assuming model name Import
use App\Models\FieldJob; // if this model exists
use App\Models\Import;
use App\Models\SmsLog;
use App\Models\SmsProfile;
use App\Services\Sms\SmsService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
@@ -23,38 +21,25 @@ class DashboardController extends Controller
public function __invoke(SmsService $sms): Response
{
$today = now()->startOfDay();
$cacheMinutes = 5;
$yesterday = now()->subDay()->startOfDay();
$staleThreshold = now()->subDays(7); // assumption: stale if no activity in last 7 days
// Active clients count - cached
$activeClientsCount = Cache::remember('dashboard:active_clients:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
return Client::where('active', true)->count();
});
$clientsTotal = Client::count();
$clientsNew7d = Client::where('created_at', '>=', now()->subDays(7))->count();
// FieldJob table does not have a scheduled_at column (schema shows: assigned_at, completed_at, cancelled_at)
// Temporary logic: if scheduled_at ever added we'll use it; otherwise fall back to assigned_at then created_at.
if (Schema::hasColumn('field_jobs', 'scheduled_at')) {
$fieldJobsToday = FieldJob::whereDate('scheduled_at', $today)->count();
} else {
// Prefer assigned_at when present, otherwise created_at
$fieldJobsToday = FieldJob::whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)->count();
}
$documentsToday = Document::whereDate('created_at', $today)->count();
$activeImports = Import::whereIn('status', ['queued', 'processing'])->count();
$activeContracts = Contract::where('active', 1)->count();
// Active contracts count - cached
$activeContractsCount = Cache::remember('dashboard:active_contracts:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
return Contract::whereNull('deleted_at')->count();
});
// 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()
// Basic activities deferred list (limit 10)
$activities = Activity::query()
->with(['clientCase:id,uuid'])
->latest()
->limit(10)
@@ -69,20 +54,30 @@ public function __invoke(SmsService $sms): Response
'action_id' => $a->action_id,
'decision_id' => $a->decision_id,
]);
});
// 7-day trends for field jobs - cached
$trends = Cache::remember('dashboard:field_jobs_trends:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
// 7-day trends (including today)
$start = now()->subDays(6)->startOfDay();
$end = now()->endOfDay();
$dateKeys = collect(range(0, 6))
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
$clientTrendRaw = Client::whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
$documentTrendRaw = Document::whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
$importTrendRaw = Import::whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
// Completed field jobs last 7 days
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
@@ -91,16 +86,50 @@ public function __invoke(SmsService $sms): Response
->groupBy('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(),
'imports_new' => $dateKeys->map(fn ($d) => (int) ($importTrendRaw[$d] ?? 0))->values(),
'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
'labels' => $dateKeys,
];
// Stale client cases (no activity in last 7 days)
$staleCases = \App\Models\ClientCase::query()
->leftJoin('activities', function ($join) {
$join->on('activities.client_case_id', '=', 'client_cases.id')
->whereNull('activities.deleted_at');
})
->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
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
return FieldJob::query()
// Field jobs assigned today
$fieldJobsAssignedToday = FieldJob::query()
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
->with(['contract' => function ($q) {
@@ -114,6 +143,7 @@ public function __invoke(SmsService $sms): Response
$contract = $fj->contract;
$segmentId = null;
if ($contract && method_exists($contract, 'segments')) {
// Determine active segment via pivot active flag if present
$activeSeg = $contract->segments->first();
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
$segmentId = $activeSeg->id;
@@ -123,6 +153,7 @@ public function __invoke(SmsService $sms): Response
return [
'id' => $fj->id,
'priority' => $fj->priority,
// Normalize to ISO8601 strings so FE retains timezone & time component
'assigned_at' => $fj->assigned_at?->toIso8601String(),
'created_at' => $fj->created_at?->toIso8601String(),
'contract' => $contract ? [
@@ -134,35 +165,69 @@ public function __invoke(SmsService $sms): Response
] : null,
];
});
});
// System health for timestamp
// Imports in progress (queued / processing)
$importsInProgress = Import::query()
->whereIn('status', ['queued', 'processing'])
->latest('created_at')
->limit(10)
->get(['id', 'uuid', 'file_name', 'status', 'total_rows', 'imported_rows', 'valid_rows', 'invalid_rows', 'started_at'])
->map(fn ($i) => [
'id' => $i->id,
'uuid' => $i->uuid,
'file_name' => $i->file_name,
'status' => $i->status,
'total_rows' => $i->total_rows,
'imported_rows' => $i->imported_rows,
'valid_rows' => $i->valid_rows,
'invalid_rows' => $i->invalid_rows,
'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1, $i->total_rows)) * 100, 1) : null,
'started_at' => $i->started_at,
]);
// Active document templates summary (active versions)
$activeTemplates = \App\Models\DocumentTemplate::query()
->where('active', true)
->latest('updated_at')
->limit(10)
->get(['id', 'name', 'slug', 'version', 'updated_at']);
// System health (deferred)
$queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null;
$failedJobs = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : null;
$recentActivity = Activity::query()->latest('created_at')->value('created_at');
$lastActivityMinutes = null;
if ($recentActivity) {
// diffInMinutes is absolute (non-negative) but guard anyway & cast to int
$lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity));
}
$systemHealth = [
'queue_backlog' => $queueBacklog,
'failed_jobs' => $failedJobs,
'last_activity_minutes' => $lastActivityMinutes,
'last_activity_iso' => $recentActivity?->toIso8601String(),
'generated_at' => now()->toIso8601String(),
];
return Inertia::render('Dashboard/Index', [
return Inertia::render('Dashboard', [
'kpis' => [
'active_clients' => $activeClientsCount,
'active_contracts' => $activeContractsCount,
'total_balance' => $totalBalance,
'active_promises' => $activePromisesCount,
'clients_total' => $clientsTotal,
'clients_new_7d' => $clientsNew7d,
'field_jobs_today' => $fieldJobsToday,
'documents_today' => $documentsToday,
'active_imports' => $activeImports,
'active_contracts' => $activeContracts,
],
'trends' => $trends,
])->with([
])->with([ // deferred props (Inertia v2 style)
'activities' => fn () => $activities,
'systemHealth' => fn () => $systemHealth,
'staleCases' => fn () => $staleCases,
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
'smsStats' => function () use ($sms, $today, $cacheMinutes) {
// SMS stats - cached
return Cache::remember('dashboard:sms_stats:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($sms, $today) {
'importsInProgress' => fn () => $importsInProgress,
'activeTemplates' => fn () => $activeTemplates,
'smsStats' => function () use ($sms, $today) {
// Aggregate counts per profile for today
$counts = SmsLog::query()
->whereDate('created_at', $today)
->selectRaw('profile_id, status, COUNT(*) as c')
@@ -184,11 +249,13 @@ public function __invoke(SmsService $sms): Response
return $map;
});
// Important: include credential fields so provider calls have proper credentials
$profiles = SmsProfile::query()
->orderBy('name')
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
// Provider balance may fail; guard and present a placeholder.
try {
$balance = $sms->getCreditBalance($p);
} catch (\Throwable $e) {
@@ -204,7 +271,6 @@ public function __invoke(SmsService $sms): Response
'today' => $c,
];
})->values();
});
},
]);
}
@@ -58,6 +58,13 @@ public function index()
'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
'ui' => ['order' => 6],
],
[
'key' => 'activities',
'canonical_root' => 'activity',
'label' => 'Activities',
'fields' => ['note', 'due_date', 'amount', 'action_id', 'decision_id', 'contract_id', 'client_case_id', 'user_id'],
'ui' => ['order' => 7],
],
]);
} else {
// Ensure fields are arrays for frontend consumption
@@ -111,10 +111,10 @@ public function store(Request $request)
'is_active' => 'boolean',
'reactivate' => 'boolean',
'entities' => 'nullable|array',
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments',
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'mappings' => 'array',
'mappings.*.source_column' => 'required|string',
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments',
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'mappings.*.target_field' => 'nullable|string',
'mappings.*.transform' => 'nullable|string|max:50',
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
@@ -124,7 +124,11 @@ public function store(Request $request)
'meta.segment_id' => 'nullable|integer|exists:segments,id',
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
'meta.action_id' => 'nullable|integer|exists:actions,id',
'meta.activity_action_id' => 'nullable|integer|exists:actions,id',
'meta.activity_decision_id' => 'nullable|integer|exists:decisions,id',
'meta.activity_created_at' => 'nullable|date',
'meta.payments_import' => 'nullable|boolean',
'meta.history_import' => 'nullable|boolean',
'meta.contract_key_mode' => 'nullable|string|in:reference',
])->validate();
@@ -142,7 +146,28 @@ public function store(Request $request)
$template = null;
DB::transaction(function () use (&$template, $request, $data) {
$paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false);
$historyImport = (bool) (data_get($data, 'meta.history_import') ?? false);
$entities = $data['entities'] ?? [];
if ($historyImport) {
$paymentsImport = false; // history import cannot be combined with payments mode
$allowedHistoryEntities = ['person', 'person_addresses', 'person_phones', 'contracts', 'activities', 'client_cases'];
$entities = array_values(array_intersect($entities, $allowedHistoryEntities));
// If contracts are present, ensure accounts are included implicitly for reference consistency
if (in_array('contracts', $entities, true) && ! in_array('accounts', $entities, true)) {
$entities[] = 'accounts';
}
// Reject mappings that target disallowed entities for history import
$disallowedMappings = collect($data['mappings'] ?? [])->filter(function ($m) use ($allowedHistoryEntities) {
if (empty($m['entity'])) {
return false;
}
return ! in_array($m['entity'], $allowedHistoryEntities, true);
});
if ($disallowedMappings->isNotEmpty()) {
abort(422, 'History import only allows entities: person, person_addresses, person_phones, contracts, activities, client_cases. Remove other mapping entities.');
}
}
if ($paymentsImport) {
$entities = ['contracts', 'accounts', 'payments'];
}
@@ -162,7 +187,11 @@ public function store(Request $request)
'segment_id' => data_get($data, 'meta.segment_id'),
'decision_id' => data_get($data, 'meta.decision_id'),
'action_id' => data_get($data, 'meta.action_id'),
'activity_action_id' => data_get($data, 'meta.activity_action_id'),
'activity_decision_id' => data_get($data, 'meta.activity_decision_id'),
'activity_created_at' => data_get($data, 'meta.activity_created_at'),
'payments_import' => $paymentsImport ?: null,
'history_import' => $historyImport ?: null,
'contract_key_mode' => data_get($data, 'meta.contract_key_mode'),
], fn ($v) => ! is_null($v) && $v !== ''),
]);
@@ -244,7 +273,7 @@ public function addMapping(Request $request, ImportTemplate $template)
}
$data = validator($raw, [
'source_column' => 'required|string',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'target_field' => 'nullable|string',
'transform' => 'nullable|string|in:trim,upper,lower',
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
@@ -314,7 +343,11 @@ public function update(Request $request, ImportTemplate $template)
'meta.segment_id' => 'nullable|integer|exists:segments,id',
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
'meta.action_id' => 'nullable|integer|exists:actions,id',
'meta.activity_action_id' => 'nullable|integer|exists:actions,id',
'meta.activity_decision_id' => 'nullable|integer|exists:decisions,id',
'meta.activity_created_at' => 'nullable|date',
'meta.payments_import' => 'nullable|boolean',
'meta.history_import' => 'nullable|boolean',
'meta.contract_key_mode' => 'nullable|string|in:reference',
])->validate();
@@ -342,6 +375,11 @@ public function update(Request $request, ImportTemplate $template)
unset($newMeta[$k]);
}
}
foreach (['activity_action_id', 'activity_decision_id', 'activity_created_at'] as $k) {
if (array_key_exists($k, $newMeta) && ($newMeta[$k] === '' || is_null($newMeta[$k]))) {
unset($newMeta[$k]);
}
}
}
// Finalize meta (ensure payments entities forced if enabled)
@@ -349,6 +387,20 @@ public function update(Request $request, ImportTemplate $template)
if (! empty($finalMeta['payments_import'])) {
$finalMeta['entities'] = ['contracts', 'accounts', 'payments'];
}
if (! empty($finalMeta['history_import'])) {
$finalMeta['payments_import'] = false;
$allowedHistoryEntities = ['person', 'person_addresses', 'person_phones', 'contracts', 'activities', 'client_cases'];
$finalMeta['entities'] = array_values(array_intersect($finalMeta['entities'] ?? [], $allowedHistoryEntities));
if (in_array('contracts', $finalMeta['entities'] ?? [], true) && ! in_array('accounts', $finalMeta['entities'] ?? [], true)) {
$finalMeta['entities'][] = 'accounts';
}
}
if (in_array('activities', $finalMeta['entities'] ?? [], true)) {
if (empty($finalMeta['activity_action_id']) || empty($finalMeta['activity_decision_id'])) {
return back()->withErrors(['meta.activity_action_id' => 'Activities import requires selecting both a default action and decision.'])->withInput();
}
}
$update = [
'name' => $data['name'],
@@ -381,7 +433,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
}
$data = validator($raw, [
'sources' => 'required|string', // comma and/or newline separated
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'default_field' => 'nullable|string', // if provided, used as the field name for all entries
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
'transform' => 'nullable|string|in:trim,upper,lower',
@@ -583,6 +635,9 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
'segment_id' => $tplMeta['segment_id'] ?? null,
'decision_id' => $tplMeta['decision_id'] ?? null,
'action_id' => $tplMeta['action_id'] ?? null,
'activity_action_id' => $tplMeta['activity_action_id'] ?? null,
'activity_decision_id' => $tplMeta['activity_decision_id'] ?? null,
'activity_created_at' => $tplMeta['activity_created_at'] ?? null,
'template_name' => $template->name,
], fn ($v) => ! is_null($v) && $v !== ''));
+49 -18
View File
@@ -26,10 +26,18 @@ public function update(Person $person, Request $request)
$person->update($attributes);
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
if ($request->header('X-Inertia')) {
return back()->with('success', 'Person updated');
}
return response()->json([
'person' => [
'full_name' => $person->full_name,
'tax_number' => $person->tax_number,
'social_security_number' => $person->social_security_number,
'description' => $person->description,
],
]);
}
public function createAddress(Person $person, Request $request)
@@ -52,8 +60,13 @@ public function createAddress(Person $person, Request $request)
], $attributes);
// Support Inertia form submissions (redirect back) and JSON (for API/axios)
return back()->with('success', 'Address created')->with('flash_method', 'POST');
if ($request->header('X-Inertia')) {
return back()->with('success', 'Address created');
}
return response()->json([
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id),
]);
}
public function updateAddress(Person $person, int $address_id, Request $request)
@@ -71,8 +84,13 @@ public function updateAddress(Person $person, int $address_id, Request $request)
$address->update($attributes);
return back()->with('success', 'Address updated')->with('flash_method', 'PUT');
if ($request->header('X-Inertia')) {
return back()->with('success', 'Address updated');
}
return response()->json([
'address' => $address,
]);
}
public function deleteAddress(Person $person, int $address_id, Request $request)
@@ -80,8 +98,11 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
$address = $person->addresses()->findOrFail($address_id);
$address->delete(); // soft delete
if ($request->header('X-Inertia')) {
return back()->with('success', 'Address deleted');
}
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
return response()->json(['status' => 'ok']);
}
public function createPhone(Person $person, Request $request)
@@ -101,7 +122,7 @@ public function createPhone(Person $person, Request $request)
'country_code' => $attributes['country_code'] ?? null,
], $attributes);
return back()->with('success', 'Phone added successfully')->with('flash_method', 'POST');
return back()->with('success', 'Phone added successfully');
}
public function updatePhone(Person $person, int $phone_id, Request $request)
@@ -119,7 +140,7 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
$phone->update($attributes);
return back()->with('success', 'Phone updated successfully')->with('flash_method', 'PUT');
return back()->with('success', 'Phone updated successfully');
}
public function deletePhone(Person $person, int $phone_id, Request $request)
@@ -127,7 +148,7 @@ public function deletePhone(Person $person, int $phone_id, Request $request)
$phone = $person->phones()->findOrFail($phone_id);
$phone->delete(); // soft delete
return back()->with('success', 'Phone deleted')->with('flash_method', 'DELETE');
return back()->with('success', 'Phone deleted');
}
public function createEmail(Person $person, Request $request)
@@ -149,7 +170,7 @@ public function createEmail(Person $person, Request $request)
'value' => $attributes['value'],
], $attributes);
return back()->with('success', 'Email added successfully')->with('flash_method', 'POST');
return back()->with('success', 'Email added successfully');
}
public function updateEmail(Person $person, int $email_id, Request $request)
@@ -170,7 +191,7 @@ public function updateEmail(Person $person, int $email_id, Request $request)
$email->update($attributes);
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
return back()->with('success', 'Email updated successfully');
}
public function deleteEmail(Person $person, int $email_id, Request $request)
@@ -182,7 +203,7 @@ public function deleteEmail(Person $person, int $email_id, Request $request)
return back()->with('success', 'Email deleted');
}
return back()->with('success', 'Email deleted')->with('flash_method', 'DELETE');
return response()->json(['status' => 'ok']);
}
// TRR (bank account) CRUD
@@ -204,10 +225,13 @@ public function createTrr(Person $person, Request $request)
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
$trr = $person->bankAccounts()->create($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'TRR added successfully');
}
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
return response()->json([
'trr' => BankAccount::findOrFail($trr->id),
]);
}
public function updateTrr(Person $person, int $trr_id, Request $request)
@@ -229,8 +253,13 @@ public function updateTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->update($attributes);
return back()->with('success', 'TRR updated successfully')->with('flash_method', 'PUT');
if ($request->header('X-Inertia')) {
return back()->with('success', 'TRR updated successfully');
}
return response()->json([
'trr' => $trr,
]);
}
public function deleteTrr(Person $person, int $trr_id, Request $request)
@@ -238,8 +267,10 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->delete();
if ($request->header('X-Inertia')) {
return back()->with('success', 'TRR deleted');
}
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
return response()->json(['status' => 'ok']);
}
}
+54 -144
View File
@@ -3,13 +3,11 @@
namespace App\Http\Controllers;
use App\Models\FieldJob;
use App\Services\ReferenceDataCache;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PhoneViewController extends Controller
{
public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Request $request)
{
$userId = $request->user()->id;
@@ -78,169 +76,81 @@ public function completedToday(Request $request)
public function showCase(\App\Models\ClientCase $clientCase, Request $request)
{
$userId = $request->user()->id;
$completedMode = (bool) $request->boolean('completed');
$completedMode = $request->boolean('completed');
// Eager load client case with person details
$case = \App\Models\ClientCase::query()
->with(['person' => fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts'])])
->findOrFail($clientCase->id);
// Eager load case with person details
$case = $clientCase->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts');
// Determine contracts of this case relevant to the current user
// - Normal mode: contracts assigned to me and still active (not completed/cancelled)
// - Completed mode (?completed=1): contracts where my field job was completed today
if ($completedMode) {
$start = now()->startOfDay();
$end = now()->endOfDay();
$contractIds = FieldJob::query()
// Query contracts based on field jobs
$contractsQuery = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('cancelled_at')
->whereBetween('completed_at', [$start, $end])
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->pluck('contract_id')
->unique()
->values();
} else {
$contractIds = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->pluck('contract_id')
->unique()
->values();
}
->when($completedMode,
fn ($q) => $q->whereNull('cancelled_at')->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]),
fn ($q) => $q->whereNull('completed_at')->whereNull('cancelled_at')
);
// Get contracts with relationships
$contracts = \App\Models\Contract::query()
->where('client_case_id', $case->id)
->whereIn('id', $contractIds)
->with(['type:id,name', 'account'])
->whereIn('id', $contractsQuery->pluck('contract_id')->unique())
->with(['type:id,name', 'account', 'latestObject'])
->orderByDesc('created_at')
->get();
// Attach latest object (if any) to each contract as last_object for display
if ($contracts->isNotEmpty()) {
$byId = $contracts->keyBy('id');
$latestObjects = \App\Models\CaseObject::query()
->whereIn('contract_id', $byId->keys())
->whereNull('deleted_at')
->select('id', 'reference', 'name', 'description', 'type', 'contract_id', 'created_at')
// Build merged documents
$documents = $case->documents()
->orderByDesc('created_at')
->get()
->groupBy('contract_id')
->map(function ($group) {
return $group->first();
});
foreach ($latestObjects as $cid => $obj) {
if (isset($byId[$cid])) {
$byId[$cid]->setAttribute('last_object', $obj);
}
}
}
// Build merged documents: case documents + documents of assigned contracts
$contractRefMap = [];
foreach ($contracts as $c) {
$contractRefMap[$c->id] = $c->reference;
}
$contractDocs = \App\Models\Document::query()
->map(fn ($d) => array_merge($d->toArray(), [
'documentable_type' => \App\Models\ClientCase::class,
'client_case_uuid' => $case->uuid,
]))
->concat(
\App\Models\Document::query()
->where('documentable_type', \App\Models\Contract::class)
->whereIn('documentable_id', $contractIds)
->whereIn('documentable_id', $contracts->pluck('id'))
->with('documentable:id,uuid,reference')
->orderByDesc('created_at')
->get()
->map(function ($d) use ($contractRefMap) {
$arr = $d->toArray();
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
$arr['documentable_type'] = \App\Models\Contract::class;
$arr['contract_uuid'] = optional(\App\Models\Contract::withTrashed()->find($d->documentable_id))->uuid;
->map(fn ($d) => array_merge($d->toArray(), [
'contract_reference' => $d->documentable?->reference,
'contract_uuid' => $d->documentable?->uuid,
]))
)
->sortByDesc('created_at')
->values();
return $arr;
});
// Get segment IDs for filtering actions
$segmentIds = \App\Models\FieldJobSetting::query()
->whereIn('id', $contractsQuery->pluck('field_job_setting_id')->filter()->unique())
->pluck('segment_id')
->filter()
->unique();
$caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) {
$arr = $d->toArray();
$arr['documentable_type'] = \App\Models\ClientCase::class;
$arr['client_case_uuid'] = $case->uuid;
return $arr;
});
$documents = $caseDocs->concat($contractDocs)->sortByDesc('created_at')->values();
// Provide minimal types for PersonInfoGrid
$types = [
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
// Case activities (compact for phone): latest 20 with relations
$activities = $case->activities()
return Inertia::render('Phone/Case/Index', [
'client' => $case->client->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts'),
'client_case' => $case,
'contracts' => $contracts,
'documents' => $documents,
'types' => [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
],
'account_types' => \App\Models\AccountType::all(),
'actions' => \App\Models\Action::query()
->when($segmentIds->isNotEmpty(), fn ($q) => $q->whereIn('segment_id', $segmentIds))
->with([
'decisions:id,name,color_tag,auto_mail,email_template_id',
'decisions.emailTemplate:id,name,entity_types,allow_attachments',
])
->get(['id', 'name', 'color_tag', 'segment_id']),
'activities' => $case->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->orderByDesc('created_at')
->limit(20)
->get()
->map(function ($a) {
$a->setAttribute('user_name', optional($a->user)->name);
return $a;
});
// Determine segment filters from FieldJobSettings for this case/user context
$settingIds = FieldJob::query()
->where('assigned_user_id', $userId)
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->when(
$completedMode,
function ($q) {
$q->whereNull('cancelled_at')
->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]);
},
function ($q) {
$q->whereNull('completed_at')->whereNull('cancelled_at');
}
)
->pluck('field_job_setting_id')
->filter()
->unique()
->values();
$segmentIds = collect();
if ($settingIds->isNotEmpty()) {
$segmentIds = \App\Models\FieldJobSetting::query()
->whereIn('id', $settingIds)
->pluck('segment_id')
->filter()
->unique()
->values();
}
// Filter actions and their decisions by the derived segment ids (decisions.segment_id)
$actions = \App\Models\Action::query()
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
// Filter actions by their segment_id matching the FieldJobSetting segment(s)
$q->whereIn('segment_id', $segmentIds);
})
->with([
'decisions' => function ($q) {
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
},
'decisions.emailTemplate' => function ($q) {
$q->select('id', 'name', 'entity_types', 'allow_attachments');
},
])
->get(['id', 'name', 'color_tag', 'segment_id']);
return Inertia::render('Phone/Case/Index', [
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(),
'client_case' => $case,
'contracts' => $contracts,
'documents' => $documents,
'types' => $types,
'account_types' => $this->referenceCache->getAccountTypes(),
// Provide decisions (filtered by segment) with linked email template metadata (entity_types, allow_attachments)
'actions' => $actions,
'activities' => $activities,
->map(fn ($a) => $a->setAttribute('user_name', $a->user?->name)),
'completed_mode' => $completedMode,
]);
}
-379
View File
@@ -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;
}
}
+135 -52
View File
@@ -2,12 +2,20 @@
namespace App\Http\Controllers;
use App\Exports\SegmentContractsExport;
use App\Http\Requests\ExportSegmentContractsRequest;
use App\Http\Requests\StoreSegmentRequest;
use App\Http\Requests\UpdateSegmentRequest;
use App\Models\Client;
use App\Models\Contract;
use App\Models\Segment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
class SegmentController extends Controller
{
@@ -44,64 +52,20 @@ public function index()
]);
}
public function show(\App\Models\Segment $segment)
public function show(Segment $segment)
{
// Retrieve contracts that are active in this segment, eager-loading required relations
$search = request('search');
$clientFilter = request('client') ?? request('client_id'); // support either ?client=<uuid|id> or ?client_id=<id>
$contractsQuery = \App\Models\Contract::query()
->whereHas('segments', function ($q) use ($segment) {
$q->where('segments.id', $segment->id)
->where('contract_segment.active', '=', 1);
})
->with([
'clientCase.person',
'clientCase.client.person',
'type',
'account',
])
->latest('id');
$clientFilter = request('client') ?? request('client_id');
$perPage = request()->integer('perPage', request()->integer('per_page', 15));
$perPage = max(1, min(200, $perPage));
// Optional filter by client (accepts numeric id or client uuid)
if (! empty($clientFilter)) {
$contractsQuery->whereHas('clientCase.client', function ($q) use ($clientFilter) {
if (is_numeric($clientFilter)) {
$q->where('clients.id', (int) $clientFilter);
} else {
$q->where('clients.uuid', $clientFilter);
}
});
}
if (! empty($search)) {
$contractsQuery->where(function ($qq) use ($search) {
$qq->where('contracts.reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
})
->orWhereHas('clientCase.client.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
});
});
}
$contracts = $contractsQuery
->paginate(15)
$contracts = $this->buildContractsQuery($segment, $search, $clientFilter)
->paginate($perPage)
->withQueryString();
// Mirror client onto the contract to simplify frontend access (c.client.person.full_name)
$items = collect($contracts->items());
$items->each(function ($contract) {
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
$contract->setRelation('client', $contract->clientCase->client);
}
});
if (method_exists($contracts, 'setCollection')) {
$contracts->setCollection($items);
}
$contracts = $this->hydrateClientShortcut($contracts);
// Build a full client list for this segment (not limited to current page) for the dropdown
$clients = \App\Models\Client::query()
$clients = Client::query()
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
$q->where('segments.id', $segment->id)
->where('contract_segment.active', '=', 1);
@@ -124,6 +88,69 @@ public function show(\App\Models\Segment $segment)
]);
}
public function export(ExportSegmentContractsRequest $request, Segment $segment)
{
$data = $request->validated();
$client = $this->resolveClient($data['client'] ?? null);
$columns = array_values(array_unique($data['columns']));
$query = $this->buildContractsQuery(
$segment,
$data['search'] ?? null,
$data['client'] ?? null
);
if (($data['scope'] ?? ExportSegmentContractsRequest::SCOPE_ALL) === ExportSegmentContractsRequest::SCOPE_CURRENT) {
$page = max(1, (int) ($data['page'] ?? 1));
$perPage = max(1, min(200, (int) ($data['per_page'] ?? 15)));
$query->forPage($page, $perPage);
}
$filename = $this->buildExportFilename($segment, $client);
return Excel::download(new SegmentContractsExport($query, $columns), $filename);
}
private function resolveClient(?string $identifier): ?Client
{
if (empty($identifier)) {
return null;
}
$query = Client::query()->with(['person:id,full_name']);
if (Str::isUuid($identifier)) {
$query->where('uuid', $identifier);
} elseif (is_numeric($identifier)) {
$query->where('id', (int) $identifier);
} else {
$query->where('uuid', $identifier);
}
return $query->first();
}
private function buildExportFilename(Segment $segment, ?Client $client): string
{
$datePrefix = now()->format('dmy');
$segmentName = $this->slugify($segment->name ?? 'segment');
$base = sprintf('%s_%s-Pogodbe', $datePrefix, $segmentName);
if ($client && $client->person?->full_name) {
$clientName = $this->slugify($client->person->full_name);
return sprintf('%s_%s.xlsx', $base, $clientName);
}
return sprintf('%s.xlsx', $base);
}
private function slugify(string $value): string
{
$slug = trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $value), '-');
return $slug !== '' ? $slug : 'data';
}
public function settings(Request $request)
{
return Inertia::render('Settings/Segments/Index', [
@@ -155,4 +182,60 @@ public function update(UpdateSegmentRequest $request, Segment $segment)
return to_route('settings.segments')->with('success', 'Segment updated');
}
private function buildContractsQuery(Segment $segment, ?string $search, ?string $clientFilter): Builder
{
$query = Contract::query()
->whereHas('segments', function ($q) use ($segment) {
$q->where('segments.id', $segment->id)
->where('contract_segment.active', '=', 1);
})
->with([
'clientCase.person',
'clientCase.client.person',
'type',
'account',
])
->latest('id');
if (! empty($clientFilter)) {
$query->whereHas('clientCase.client', function ($q) use ($clientFilter) {
if (is_numeric($clientFilter)) {
$q->where('clients.id', (int) $clientFilter);
} else {
$q->where('clients.uuid', $clientFilter);
}
});
}
if (! empty($search)) {
$query->where(function ($qq) use ($search) {
$qq->where('contracts.reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
})
->orWhereHas('clientCase.client.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
});
});
}
return $query;
}
private function hydrateClientShortcut(LengthAwarePaginator $contracts): LengthAwarePaginator
{
$items = collect($contracts->items());
$items->each(function (Contract $contract) {
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
$contract->setRelation('client', $contract->clientCase->client);
}
});
if (method_exists($contracts, 'setCollection')) {
$contracts->setCollection($items);
}
return $contracts;
}
}
@@ -57,7 +57,6 @@ public function share(Request $request): array
'error' => fn () => $request->session()->get('error'),
'warning' => fn () => $request->session()->get('warning'),
'info' => fn () => $request->session()->get('info'),
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
],
'notifications' => function () use ($request) {
try {
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests;
use App\Exports\SegmentContractsExport;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ExportSegmentContractsRequest 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(SegmentContractsExport::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'],
'client' => ['nullable', 'string', 'max:64'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'client' => $this->input('client') ?? $this->input('client_id'),
'per_page' => $this->input('per_page') ?? $this->input('perPage'),
]);
}
}
-26
View File
@@ -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();
}
}
-19
View File
@@ -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();
}
}
-21
View File
@@ -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,
];
}
}
-65
View File
@@ -2,8 +2,6 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -59,69 +57,6 @@ protected static function booted()
});
}
/**
* Scope activities to those linked to contracts within a specific segment.
*/
#[Scope]
public function scopeForSegment(Builder $query, int $segmentId, array $contractIds): Builder
{
return $query->where(function ($q) use ($contractIds) {
$q->whereNull('contract_id');
if (! empty($contractIds)) {
$q->orWhereIn('contract_id', $contractIds);
}
});
}
/**
* Scope activities with decoded base64 filters.
*/
#[Scope]
public function scopeWithFilters(Builder $query, ?string $encodedFilters, \App\Models\ClientCase $clientCase): Builder
{
if (empty($encodedFilters)) {
return $query;
}
try {
$decompressed = base64_decode($encodedFilters);
$filters = json_decode($decompressed, true);
if (! is_array($filters)) {
return $query;
}
if (! empty($filters['action_id'])) {
$query->where('action_id', $filters['action_id']);
}
if (! empty($filters['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $filters['contract_uuid'])->first(['id']);
if ($contract) {
$query->where('contract_id', $contract->id);
}
}
if (! empty($filters['user_id'])) {
$query->where('user_id', $filters['user_id']);
}
if (! empty($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
} catch (\Throwable $e) {
\Log::error('Invalid activity filter format', [
'error' => $e->getMessage(),
]);
}
return $query;
}
public function action(): BelongsTo
{
return $this->belongsTo(\App\Models\Action::class);
+17 -16
View File
@@ -3,8 +3,6 @@
namespace App\Models;
use App\Traits\Uuid;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -80,20 +78,6 @@ protected function endDate(): Attribute
);
}
/**
* Scope contracts to those in a specific segment with active pivot.
*/
#[Scope]
public function scopeForSegment(Builder $query, int $segmentId): Builder
{
return $query->whereExists(function ($q) use ($segmentId) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.segment_id', $segmentId)
->where('contract_segment.active', true);
});
}
public function type(): BelongsTo
{
return $this->belongsTo(\App\Models\ContractType::class, 'type_id');
@@ -112,6 +96,11 @@ public function segments(): BelongsToMany
->wherePivot('active', true);
}
public function attachedSegments(): BelongsToMany
{
return $this->belongsToMany(\App\Models\Segment::class);
}
public function account(): HasOne
{
// Use latestOfMany to always surface newest account snapshot if multiple exist.
@@ -130,6 +119,18 @@ public function documents(): MorphMany
return $this->morphMany(\App\Models\Document::class, 'documentable');
}
public function fieldJobs(): HasMany
{
return $this->hasMany(\App\Models\FieldJob::class);
}
public function latestObject(): HasOne
{
return $this->hasOne(\App\Models\CaseObject::class)
->whereNull('deleted_at')
->latest();
}
protected static function booted(): void
{
static::created(function (Contract $contract): void {
+6 -1
View File
@@ -24,6 +24,8 @@ class FieldJob extends Model
'priority',
'notes',
'address_snapshot ',
'last_activity',
'added_activity'
];
protected $casts = [
@@ -31,6 +33,8 @@ class FieldJob extends Model
'completed_at' => 'datetime',
'cancelled_at' => 'datetime',
'priority' => 'boolean',
'last_activity' => 'datetime',
'added_activity' => 'boolean',
'address_snapshot ' => 'array',
];
@@ -90,7 +94,8 @@ public function user(): BelongsTo
public function contract(): BelongsTo
{
return $this->belongsTo(Contract::class, 'contract_id');
return $this->belongsTo(Contract::class, 'contract_id')
->where('active', true);
}
/**
+1 -1
View File
@@ -190,7 +190,7 @@ protected static function joinNameParts(?string $first, ?string $second): ?strin
protected static function normalizeSegment(?string $value): ?string
{
if (blank($value)) {
if (empty($value)) {
return null;
}
-36
View File
@@ -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");
}
}
-78
View File
@@ -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');
}
}
-95
View File
@@ -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');
}
}
-33
View File
@@ -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);
}
}
-54
View File
@@ -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;
}
-51
View File
@@ -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");
}
}
-60
View File
@@ -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']);
}
}
-29
View File
@@ -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;
}
}
+3 -1
View File
@@ -69,6 +69,8 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null,
$entities = $flat;
}
// dd($entities);
foreach ($entities as $entityDef) {
$rawTable = $entityDef['table'] ?? null;
if (! $rawTable) {
@@ -97,7 +99,7 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null,
// Process in batches to avoid locking large tables
while (true) {
$query = DB::table($table)->whereNull('deleted_at');
if (Schema::hasColumn($table, 'active')) {
if (Schema::hasColumn($table, 'active') && ! $reactivate) {
$query->where('active', 1);
}
// Apply context filters or chain derived filters
-181
View File
@@ -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');
}
}
+616 -21
View File
@@ -25,11 +25,21 @@
use App\Models\Person\PersonType;
use App\Models\Person\PhoneType;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\QueryException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class ImportProcessor
{
/**
* Track contracts that already existed and were matched during history imports.
*
* @var array<int,bool>
*/
private array $historyFoundContractIds = [];
/**
* Process an import and apply basic dedup checks.
* Returns summary counts.
@@ -42,6 +52,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
$imported = 0;
$invalid = 0;
$fh = null;
$this->historyFoundContractIds = [];
// Only CSV/TSV supported in this pass
if (! in_array($import->source_type, ['csv', 'txt'])) {
@@ -73,6 +84,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
// Template meta flags
$tplMeta = optional($import->template)->meta ?? [];
$paymentsImport = (bool) ($tplMeta['payments_import'] ?? false);
$historyImport = (bool) ($tplMeta['history_import'] ?? false);
$contractKeyMode = $tplMeta['contract_key_mode'] ?? null;
// Prefer explicitly chosen delimiter, then template meta, else detected
$delimiter = $import->meta['forced_delimiter']
@@ -171,10 +183,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
}
// Preflight: warn if any mapped source columns are not present in the header (exact match)
$headerSet = [];
foreach ($header as $h) {
$headerSet[$h] = true;
}
$missingSources = [];
// Regex validation removed per request; rely on basic length/placeholder checks only
foreach ($mappings as $map) {
$src = (string) ($map->source_column ?? '');
if ($src !== '' && ! array_key_exists($src, $headerSet)) {
@@ -207,7 +216,28 @@ public function process(Import $import, ?Authenticatable $user = null): array
if ($isPg) {
// Establish a savepoint so a failing row does not poison the whole transaction
try {
DB::statement('SAVEPOINT import_row_'.$rowNum);
} catch (\Throwable $se) {
Log::error('Import savepoint_failed', [
'import_id' => $import->id,
'row_number' => $rowNum,
'exception' => $this->exceptionContext($se),
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'savepoint_failed',
'level' => 'error',
'message' => 'Failed to create savepoint; transaction already aborted.',
'context' => [
'row_number' => $rowNum,
'exception' => $this->exceptionContext($se),
],
]);
throw $se; // abort import so root cause surfaces
}
}
// Scope variables per row so they aren't reused after exception
@@ -299,9 +329,13 @@ public function process(Import $import, ?Authenticatable $user = null): array
$contractResult = null;
}
} else {
$contractResult = $this->upsertContractChain($import, $mapped, $mappings);
$contractResult = $this->upsertContractChain($import, $mapped, $mappings, $historyImport);
// If contract was resolved/updated/inserted and reactivation requested but not needed (already active), we just continue normal flow.
if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
// Do not attempt reactivation on freshly inserted contracts
if (($contractResult['action'] ?? null) === 'inserted') {
// Newly created contracts are already active; skip reactivation path
} else {
$found = $contractResult['contract'];
if ($found->active == 0 || $found->deleted_at) {
$reactivationApplied = $this->attemptContractReactivation($found, $user);
@@ -326,7 +360,20 @@ public function process(Import $import, ?Authenticatable $user = null): array
}
}
}
if ($contractResult['action'] === 'skipped') {
}
if ($contractResult['action'] === 'skipped_history') {
// History import: keep existing contract for downstream relations but do not update or attach segments/actions
$skipped++;
$importRow->update(['status' => 'skipped']);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_skipped',
'level' => 'info',
'message' => $contractResult['message'] ?? 'Existing contract reused (history import).',
]);
} elseif ($contractResult['action'] === 'skipped') {
// Even if no contract fields were updated, we may still need to apply template meta
// like attaching a segment or creating an activity. Do that if we have the contract.
if (isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
@@ -437,12 +484,18 @@ public function process(Import $import, ?Authenticatable $user = null): array
// Accounts
$accountResult = null;
if ($historyImport && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract && ! isset($mapped['account'])) {
$autoAcc = $this->ensureHistoryAccount($contractResult['contract'], $mapped);
if ($autoAcc) {
$accountResult = ['action' => 'inserted', 'account' => $autoAcc, 'contract' => $contractResult['contract'], 'contract_id' => $contractResult['contract']->id];
}
}
if (isset($mapped['account'])) {
// If a contract was just created or resolved above, pass its id to account mapping for this row
if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$mapped['account']['contract_id'] = $contractResult['contract']->id;
}
$accountResult = $this->upsertAccount($import, $mapped, $mappings);
$accountResult = $this->upsertAccount($import, $mapped, $mappings, $historyImport);
if ($accountResult['action'] === 'skipped') {
$skipped++;
$importRow->update(['status' => 'skipped']);
@@ -555,6 +608,55 @@ public function process(Import $import, ?Authenticatable $user = null): array
}
}
// Activities: create or update activities linked to contracts/cases
if (isset($mapped['activity'])) {
$activityResult = $this->upsertActivity($import, $mapped, $mappings, $contractResult ?? null, $accountResult ?? null);
if ($activityResult['action'] === 'skipped') {
$skipped++;
$importRow->update(['status' => 'skipped']);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_skipped',
'level' => 'info',
'message' => $activityResult['message'] ?? 'Skipped (no changes).',
'context' => $activityResult['context'] ?? null,
]);
} elseif (in_array($activityResult['action'], ['inserted', 'updated'], true)) {
$imported++;
$importRow->update([
'status' => 'imported',
'entity_type' => Activity::class,
'entity_id' => $activityResult['activity']->id,
]);
$activityFieldsStr = '';
if (! empty($activityResult['applied_fields'] ?? [])) {
$activityFieldsStr = $this->formatAppliedFieldMessage('activity', $activityResult['applied_fields']);
}
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_imported',
'level' => 'info',
'message' => ucfirst($activityResult['action']).' activity'.($activityFieldsStr ? ' '.$activityFieldsStr : ''),
'context' => ['id' => $activityResult['activity']->id, 'fields' => $activityResult['applied_fields'] ?? []],
]);
} else {
$invalid++;
$importRow->update(['status' => 'invalid', 'errors' => [$activityResult['message'] ?? 'Activity processing failed']]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_invalid',
'level' => 'error',
'message' => $activityResult['message'] ?? 'Activity processing failed',
]);
}
}
// Contacts: resolve person via Contract/Account chain, client_case.client_ref, contacts, or identifiers
$personIdForRow = null;
// Prefer person from contract created/updated above
@@ -986,13 +1088,38 @@ public function process(Import $import, ?Authenticatable $user = null): array
}
}
} catch (\Throwable $e) {
$rollbackFailed = false;
$rollbackError = null;
if ($isPg) {
// Roll back only this row's work
try {
DB::statement('ROLLBACK TO SAVEPOINT import_row_'.$rowNum);
} catch (\Throwable $ignored) { /* noop */
} catch (\Throwable $ignored) {
$rollbackFailed = true;
$rollbackError = $ignored;
}
}
if ($rollbackFailed) {
Log::error('Import row_rollback_failed', [
'import_id' => $import->id,
'row_number' => $rowNum,
'exception' => $this->exceptionContext($rollbackError ?? $e),
]);
// Abort the whole import if we cannot rollback to the row savepoint (transaction is poisoned)
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'row_rollback_failed',
'level' => 'error',
'message' => 'Rollback to savepoint failed; aborting import.',
'context' => [
'row_number' => $rowNum,
'exception' => $this->exceptionContext($rollbackError ?? $e),
],
]);
throw $rollbackError ?? $e;
}
// Ensure importRow exists for logging if failure happened before its creation
if (! $importRow) {
try {
@@ -1019,6 +1146,12 @@ public function process(Import $import, ?Authenticatable $user = null): array
}
$failedRows[] = $rowNum;
$invalid++;
Log::error('Import row_exception', [
'import_id' => $import->id,
'row_number' => $rowNum,
'exception' => $this->exceptionContext($e),
'raw_preview' => isset($rawAssoc) ? $this->buildRawDataPreview($rawAssoc) : [],
]);
try {
ImportEvent::create([
'import_id' => $import->id,
@@ -1036,6 +1169,12 @@ public function process(Import $import, ?Authenticatable $user = null): array
],
]);
} catch (\Throwable $evtErr) {
Log::error('Import row_exception_event_failed', [
'import_id' => $import->id,
'row_number' => $rowNum,
'exception' => $this->exceptionContext($evtErr),
'original_exception' => $this->exceptionContext($e),
]);
// Swallow secondary failure to ensure loop continues
}
@@ -1060,6 +1199,29 @@ public function process(Import $import, ?Authenticatable $user = null): array
]);
}
$meta = $import->meta ?? [];
if ($historyImport) {
if (! empty($this->historyFoundContractIds)) {
$found = Contract::query()
->with(['clientCase.person'])
->whereIn('id', array_keys($this->historyFoundContractIds))
->get()
->map(function (Contract $c) {
return [
'contract_uuid' => $c->uuid ?? null,
'reference' => $c->reference,
'case_uuid' => $c->clientCase?->uuid,
'full_name' => $c->clientCase?->person?->full_name,
];
})
->values()
->all();
$meta['history_found_contracts'] = $found;
} else {
$meta['history_found_contracts'] = [];
}
}
$import->update([
'status' => 'completed',
'finished_at' => now(),
@@ -1067,6 +1229,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
'imported_rows' => $imported,
'invalid_rows' => $invalid,
'valid_rows' => $total - $invalid,
'meta' => $meta,
]);
DB::commit();
@@ -1084,12 +1247,17 @@ public function process(Import $import, ?Authenticatable $user = null): array
// Mark failed and log after rollback (so no partial writes persist)
$import->refresh();
$import->update(['status' => 'failed', 'failed_at' => now()]);
Log::error('Import processing_failed', [
'import_id' => $import->id,
'exception' => $this->exceptionContext($e),
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'processing_failed',
'level' => 'error',
'message' => $e->getMessage(),
'message' => $this->safeErrorMessage($e->getMessage()),
'context' => $this->exceptionContext($e),
]);
return ['ok' => false, 'status' => 'failed', 'error' => $e->getMessage()];
@@ -1119,6 +1287,21 @@ protected function applyMappings(array $raw, $mappings, array $supportsMultiple)
{
$recordType = null;
$mapped = [];
$appendValue = function ($existing, $label, $value) {
// Skip empty new values
if (is_null($value) || (is_string($value) && trim($value) === '')) {
return $existing;
}
$stringVal = is_scalar($value) ? (string) $value : json_encode($value);
$existingStr = is_null($existing) ? '' : (is_scalar($existing) ? (string) $existing : json_encode($existing));
if ($existingStr === '') {
return $label.': '.$stringVal;
}
return $existingStr.', '.$label.': '.$stringVal;
};
$fieldFirstLabel = [];
$rootFirstLabel = [];
foreach ($mappings as $map) {
$src = $map->source_column;
$target = $map->target_field;
@@ -1278,9 +1461,34 @@ protected function applyMappings(array $raw, $mappings, array $supportsMultiple)
if (! isset($mapped[$root]) || ! is_array($mapped[$root])) {
$mapped[$root] = [];
}
$canConcat = $this->fieldAllowsConcatenation($root, $field);
if ($canConcat) {
$key = $root.'.'.$field;
if (! array_key_exists($field, $mapped[$root]) || is_null($mapped[$root][$field]) || $mapped[$root][$field] === '') {
$mapped[$root][$field] = $value;
$fieldFirstLabel[$key] = (string) $src;
} else {
$existing = $mapped[$root][$field];
$firstLabel = $fieldFirstLabel[$key] ?? null;
$existingStr = $firstLabel ? ($firstLabel.': '.(is_scalar($existing) ? (string) $existing : json_encode($existing))) : (is_scalar($existing) ? (string) $existing : json_encode($existing));
$mapped[$root][$field] = $appendValue($existingStr, (string) $src, $value);
}
} else {
// For typed fields (dates/numbers), keep the first non-empty value to avoid coercion errors
if (! array_key_exists($field, $mapped[$root]) || is_null($mapped[$root][$field]) || $mapped[$root][$field] === '') {
$mapped[$root][$field] = $value;
}
}
} else {
if (! array_key_exists($root, $mapped) || is_null($mapped[$root]) || $mapped[$root] === '') {
$mapped[$root] = $value;
$rootFirstLabel[$root] = (string) $src;
} else {
$firstLabel = $rootFirstLabel[$root] ?? null;
$existing = $mapped[$root];
$existingStr = $firstLabel ? ($firstLabel.': '.(is_scalar($existing) ? (string) $existing : json_encode($existing))) : (is_scalar($existing) ? (string) $existing : json_encode($existing));
$mapped[$root] = $appendValue($existingStr, (string) $src, $value);
}
}
}
}
@@ -1288,6 +1496,28 @@ protected function applyMappings(array $raw, $mappings, array $supportsMultiple)
return [$recordType, $mapped];
}
/**
* Decide whether multiple mapped source columns should be concatenated for a given target field.
* For date/time and numeric-like fields we avoid concatenation to prevent invalid type coercion.
*/
private function fieldAllowsConcatenation(?string $root, ?string $field): bool
{
if ($field === null) {
return true;
}
$f = strtolower($field);
// Date / datetime indicators
if ($f === 'birthday' || str_contains($f, 'date') || str_ends_with($f, '_at')) {
return false;
}
// Common numeric fields
if (in_array($f, ['amount', 'amount_cents', 'quantity', 'balance_amount'], true)) {
return false;
}
return true;
}
private function arraySetDot(array &$arr, string $path, $value): void
{
$keys = explode('.', $path);
@@ -1301,7 +1531,7 @@ private function arraySetDot(array &$arr, string $path, $value): void
$ref = $value;
}
private function upsertAccount(Import $import, array $mapped, $mappings): array
private function upsertAccount(Import $import, array $mapped, $mappings, bool $historyImport = false): array
{
$clientId = $import->client_id; // may be null, used for contract lookup/creation
$acc = $mapped['account'] ?? [];
@@ -1447,6 +1677,10 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
}
}
if ($historyImport && $existing) {
return ['action' => 'skipped', 'account' => $existing, 'contract_id' => $contractId, 'message' => 'History import does not update accounts'];
}
if ($existing) {
// Build non-null changes for account fields
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
@@ -1505,6 +1739,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
&& ($initMode === null || in_array($initMode, ['insert', 'both'], true))) {
$applyInsert['initial_amount'] = $applyInsert['balance_amount'];
}
if ($historyImport) {
// Force zero amounts for history imports regardless of mapped amounts
$applyInsert['balance_amount'] = 0;
$applyInsert['initial_amount'] = 0;
}
if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No fields marked for insert'];
}
@@ -1516,12 +1755,44 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
if (! array_key_exists('active', $data)) {
$data['active'] = 1;
}
if ($historyImport) {
$data['balance_amount'] = 0;
$data['initial_amount'] = 0;
}
$created = Account::create($data);
return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId, 'applied_fields' => $data];
}
}
private function ensureHistoryAccount(Contract $contract, array $mapped): ?Account
{
$existing = Account::query()
->where('contract_id', $contract->id)
->where('active', 1)
->first();
if ($existing) {
return $existing;
}
$reference = $mapped['account']['reference'] ?? $mapped['contract']['reference'] ?? null;
if (is_string($reference)) {
$reference = preg_replace('/\s+/', '', trim($reference));
}
if (! $reference || $reference === '') {
$reference = 'HIST-'.$contract->id;
}
return Account::create([
'contract_id' => $contract->id,
'reference' => $reference,
'type_id' => $this->getDefaultAccountTypeId(),
'active' => 1,
'balance_amount' => 0,
'initial_amount' => 0,
]);
}
private function upsertCaseObject(Import $import, array $mapped, $mappings, int $contractId): array
{
// Support both 'case_object' and 'case_objects' keys (template may use plural)
@@ -1625,6 +1896,210 @@ private function upsertCaseObject(Import $import, array $mapped, $mappings, int
}
}
private function upsertActivity(Import $import, array $mapped, $mappings, ?array $contractResult, ?array $accountResult): array
{
$activity = $mapped['activity'] ?? [];
// Default contract/client_case from freshly created or updated contract when present
if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$activity['contract_id'] = $activity['contract_id'] ?? $contractResult['contract']->id;
$activity['client_case_id'] = $activity['client_case_id'] ?? $contractResult['contract']->client_case_id;
}
$contractId = $activity['contract_id'] ?? null;
if (! $contractId && $contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$contractId = $contractResult['contract']->id;
} elseif (! $contractId && $accountResult && isset($accountResult['contract_id'])) {
$contractId = $accountResult['contract_id'];
} elseif (! $contractId && $accountResult && isset($accountResult['contract']) && $accountResult['contract'] instanceof Contract) {
$contractId = $accountResult['contract']->id;
}
if (! $contractId && $import->client_id && ! empty($mapped['contract']['reference'] ?? null)) {
$ref = preg_replace('/\s+/', '', trim((string) $mapped['contract']['reference']));
if ($ref !== '') {
$contractId = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $import->client_id)
->where('contracts.reference', $ref)
->value('contracts.id');
}
}
$clientCaseId = $activity['client_case_id'] ?? null;
if (! $clientCaseId && $contractId) {
$clientCaseId = Contract::where('id', $contractId)->value('client_case_id');
}
if (! $clientCaseId && $contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$clientCaseId = $contractResult['contract']->client_case_id;
}
if (! $clientCaseId && $accountResult && isset($accountResult['contract']) && $accountResult['contract'] instanceof Contract) {
$clientCaseId = $accountResult['contract']->client_case_id;
}
if (! $clientCaseId && $accountResult && isset($accountResult['contract_id'])) {
$clientCaseId = Contract::where('id', $accountResult['contract_id'])->value('client_case_id');
}
if (! $clientCaseId && $import->client_id && ! empty($mapped['client_case']['client_ref'] ?? null)) {
$clientCaseId = ClientCase::where('client_id', $import->client_id)
->where('client_ref', $mapped['client_case']['client_ref'])
->value('id');
}
if (! $clientCaseId) {
return [
'action' => 'invalid',
'message' => 'Activity requires a client_case_id or resolvable contract.',
'context' => [
'contract_id' => $contractId,
'contract_reference' => $mapped['contract']['reference'] ?? null,
],
];
}
// Collect apply_mode settings for activity fields
$applyModeByField = [];
foreach ($mappings as $map) {
$target = (string) ($map->target_field ?? '');
if (! str_starts_with($target, 'activity.')) {
continue;
}
$field = substr($target, strlen('activity.'));
$applyModeByField[$field] = (string) ($map->apply_mode ?? 'both');
}
$applyInsert = [];
$applyUpdate = [];
foreach ($activity as $field => $value) {
$applyMode = $applyModeByField[$field] ?? 'both';
$normalized = $value;
if ($field === 'due_date') {
$normalized = is_scalar($value) ? $this->normalizeDate((string) $value) : null;
} elseif ($field === 'created_at') {
$normalized = is_scalar($value) ? $this->normalizeDateTime((string) $value) : null;
} elseif ($field === 'amount') {
if (is_string($value)) {
$normalized = $this->normalizeDecimal($value);
}
$normalized = is_null($normalized) ? null : (float) $normalized;
} elseif (in_array($field, ['action_id', 'decision_id', 'user_id'], true)) {
$normalized = is_null($value) ? null : (int) $value;
} elseif (is_string($normalized)) {
// Clean invalid UTF-8 sequences from string fields
$normalized = mb_convert_encoding($normalized, 'UTF-8', 'UTF-8');
$normalized = trim($normalized);
}
if (in_array($applyMode, ['both', 'insert'], true)) {
$applyInsert[$field] = $normalized;
}
if (in_array($applyMode, ['both', 'update'], true)) {
$applyUpdate[$field] = $normalized;
}
}
$settings = \App\Models\PaymentSetting::query()->first();
$tplMeta = optional($import->template)->meta ?? [];
$defaultActionId = $applyInsert['action_id']
?? $applyUpdate['action_id']
?? ($import->meta['activity_action_id'] ?? null)
?? ($tplMeta['activity_action_id'] ?? null)
?? ($import->meta['action_id'] ?? null)
?? ($tplMeta['action_id'] ?? null)
?? ($settings?->default_action_id ?? null);
$defaultDecisionId = $applyInsert['decision_id']
?? $applyUpdate['decision_id']
?? ($import->meta['activity_decision_id'] ?? null)
?? ($tplMeta['activity_decision_id'] ?? null)
?? ($import->meta['decision_id'] ?? null)
?? ($tplMeta['decision_id'] ?? null);
if (! $defaultActionId) {
return [
'action' => 'invalid',
'message' => 'Activity requires action_id (provide via mapping or import meta).',
];
}
$applyInsert['action_id'] = $applyInsert['action_id'] ?? $defaultActionId;
$applyUpdate['action_id'] = $applyUpdate['action_id'] ?? $defaultActionId;
if ($defaultDecisionId) {
$applyInsert['decision_id'] = $applyInsert['decision_id'] ?? $defaultDecisionId;
$applyUpdate['decision_id'] = $applyUpdate['decision_id'] ?? $defaultDecisionId;
}
// Default created_at for inserted activities (from mapped field or template/import meta)
if (empty($applyInsert['created_at'])) {
$defaultCreated = $this->normalizeDateTime($applyInsert['created_at'] ?? $import->meta['activity_created_at'] ?? $tplMeta['activity_created_at'] ?? null);
if ($defaultCreated) {
$applyInsert['created_at'] = $defaultCreated;
if (empty($applyInsert['updated_at'])) {
$applyInsert['updated_at'] = $defaultCreated;
}
}
}
$applyInsert['client_case_id'] = $clientCaseId;
$applyUpdate['client_case_id'] = $applyUpdate['client_case_id'] ?? $clientCaseId;
if ($contractId) {
$applyInsert['contract_id'] = $contractId;
$applyUpdate['contract_id'] = $applyUpdate['contract_id'] ?? $contractId;
}
// Idempotency: if note + due_date + contract match, treat as existing
$existing = null;
$noteKey = $applyInsert['note'] ?? null;
$dueKey = $applyInsert['due_date'] ?? null;
if ($contractId && $noteKey && $dueKey) {
$existing = Activity::query()
->where('contract_id', $contractId)
->where('note', $noteKey)
->whereDate('due_date', $dueKey)
->first();
} elseif ($clientCaseId && $noteKey && $dueKey) {
$existing = Activity::query()
->where('client_case_id', $clientCaseId)
->where('note', $noteKey)
->whereDate('due_date', $dueKey)
->first();
}
if ($existing) {
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
if (! empty($changes)) {
$existing->fill($changes);
$existing->save();
return ['action' => 'updated', 'activity' => $existing, 'applied_fields' => $changes];
}
return ['action' => 'skipped', 'activity' => $existing, 'message' => 'No changes needed'];
}
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
$activityModel = new Activity;
$activityModel->forceFill($data);
if (array_key_exists('created_at', $data)) {
// Preserve provided timestamps by disabling automatic timestamps for this save
$activityModel->timestamps = false;
$activityModel->save();
$activityModel->timestamps = true;
} else {
$activityModel->save();
}
return ['action' => 'inserted', 'activity' => $activityModel, 'applied_fields' => $data];
}
private function normalizeDateTime(?string $raw): ?string
{
if ($raw === null || trim($raw) === '') {
return null;
}
try {
return Carbon::parse($raw)->format('Y-m-d H:i:s');
} catch (\Throwable $e) {
return null;
}
}
private function mappingsContainRoot($mappings, string $root): bool
{
foreach ($mappings as $map) {
@@ -1657,7 +2132,7 @@ private function findPersonIdByIdentifiers(array $p): ?int
return null;
}
private function upsertContractChain(Import $import, array $mapped, $mappings): array
private function upsertContractChain(Import $import, array $mapped, $mappings, bool $historyImport = false): array
{
$contractData = $mapped['contract'] ?? [];
$reference = $contractData['reference'] ?? null;
@@ -1821,10 +2296,36 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
}
if ($existing) {
if ($historyImport) {
$this->historyFoundContractIds[$existing->id] = true;
return ['action' => 'skipped_history', 'contract' => $existing, 'message' => 'Existing contract left unchanged (history import)'];
}
// Check if contract is soft-deleted and needs reactivation
$isTrashed = $existing->trashed();
// 1) Prepare contract field changes (non-null)
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
// 2) Prepare meta changes if provided via mapping
// 2) Handle reactivation defaults when contract is soft-deleted
if ($isTrashed || $existing->active == 0) {
// Check if start_date is in the mappings
$hasStartDateMapping = $this->mappingIncludes($mappings, 'contract.start_date');
if (!$hasStartDateMapping) {
// Default to current date when not in mappings
$changes['start_date'] = now()->toDateString();
}
// Check if end_date is in the mappings
$hasEndDateMapping = $this->mappingIncludes($mappings, 'contract.end_date');
if (!$hasEndDateMapping) {
// Default to null when not in mappings
$changes['end_date'] = null;
}
}
// 3) Prepare meta changes if provided via mapping
$metaUpdated = false;
$metaAppliedKeys = [];
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
@@ -1867,7 +2368,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
}
}
if (empty($changes) && ! $metaUpdated) {
if (empty($changes) && ! $metaUpdated && ! $isTrashed) {
// Nothing to change
return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing];
}
@@ -1875,6 +2376,12 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
if (! empty($changes)) {
$existing->fill($changes);
}
// Restore soft-deleted contract if it was trashed
if ($isTrashed) {
$existing->restore();
}
$existing->save();
// Build applied fields info, include meta keys if any
@@ -1885,7 +2392,9 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
}
}
return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $applied];
$actionType = $isTrashed ? 'reactivated' : 'updated';
return ['action' => $actionType, 'contract' => $existing, 'applied_fields' => $applied];
} else {
if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
@@ -2061,13 +2570,57 @@ private function safeErrorMessage(string $msg): string
}
// Fallback strip invalid bytes
$msg = @iconv('UTF-8', 'UTF-8//IGNORE', $msg) ?: $msg;
if (strlen($msg) > 500) {
$msg = substr($msg, 0, 497).'...';
// Use mb_strlen and mb_substr for UTF-8 safety
if (mb_strlen($msg) > 500) {
$msg = mb_substr($msg, 0, 497).'...';
}
return $msg;
}
/**
* Extract structured exception details for logging.
*/
private function exceptionContext(\Throwable $e): array
{
$ctx = [
'exception' => get_class($e),
'message' => $this->safeErrorMessage($e->getMessage()),
'code' => $e->getCode(),
'file' => $e->getFile().':'.$e->getLine(),
];
if (method_exists($e, 'getPrevious') && $e->getPrevious()) {
$prev = $e->getPrevious();
$ctx['previous'] = [
'exception' => get_class($prev),
'message' => $this->safeErrorMessage($prev->getMessage()),
'code' => $prev->getCode(),
'file' => $prev->getFile().':'.$prev->getLine(),
];
}
if ($e instanceof QueryException) {
$ctx['sql'] = $e->getSql();
$ctx['bindings'] = $e->getBindings();
$info = $e->errorInfo ?? null;
if (is_array($info)) {
$ctx['sqlstate'] = $info[0] ?? null;
$ctx['driver_error_code'] = $info[1] ?? null;
$ctx['driver_error_message'] = $info[2] ?? null;
}
} elseif (property_exists($e, 'errorInfo')) {
$info = $e->errorInfo;
if (is_array($info)) {
$ctx['sqlstate'] = $info[0] ?? null;
$ctx['driver_error_code'] = $info[1] ?? null;
$ctx['driver_error_message'] = $info[2] ?? null;
}
}
return $ctx;
}
/**
* Build a trimmed raw data preview (first 8 columns, truncated values) for logging.
*/
@@ -2108,9 +2661,9 @@ private function formatAppliedFieldMessage(string $root, array $fields): string
} else {
$disp = method_exists($v, '__toString') ? (string) $v : gettype($v);
}
// Truncate very long values for log safety
if (strlen($disp) > 60) {
$disp = substr($disp, 0, 57).'...';
// Truncate very long values for log safety (use mb_substr for UTF-8 safety)
if (mb_strlen($disp) > 60) {
$disp = mb_substr($disp, 0, 57).'...';
}
$parts[] = $k.'='.$disp;
}
@@ -2529,6 +3082,12 @@ private function upsertEmail(int $personId, array $emailData, $mappings): array
if ($value === '') {
return ['action' => 'skipped', 'message' => 'No email value'];
}
$normalizedEmail = filter_var($value, FILTER_VALIDATE_EMAIL);
if (! $normalizedEmail) {
return ['action' => 'skipped', 'message' => 'Invalid email format'];
}
$value = $normalizedEmail;
$emailData['value'] = $normalizedEmail;
$existing = Email::where('person_id', $personId)->where('value', $value)->first();
$applyInsert = [];
$applyUpdate = [];
@@ -2580,14 +3139,32 @@ private function upsertEmail(int $personId, array $emailData, $mappings): array
private function upsertAddress(int $personId, array $addrData, $mappings): array
{
$addressLine = trim((string) ($addrData['address'] ?? ''));
if ($addressLine === '') {
// Normalize whitespace: collapse multiples and tighten around separators
$addressLine = preg_replace('/\s+/', ' ', $addressLine);
$addressLine = preg_replace('/\s*([,;\/])\s*/', '$1 ', $addressLine);
$addressLine = trim($addressLine);
// Skip common placeholders or missing values
if ($addressLine === '' || $addressLine === '0' || strcasecmp($addressLine, '#N/A') === 0 || preg_match('/^(#?n\/?a|na|null|none)$/i', $addressLine)) {
return ['action' => 'skipped', 'message' => 'No address value'];
}
if (mb_strlen($addressLine) < 3) {
return ['action' => 'skipped', 'message' => 'Invalid address value'];
}
// If identical address already exists anywhere, skip to avoid constraint violation
/*$existingAny = PersonAddress::where('address', $addressLine)->first();
if ($existingAny) {
return ['action' => 'skipped', 'message' => 'Address already exists in database'];
}*/
// Default country SLO if not provided
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
$addrData['country'] = 'SLO';
}
$existing = PersonAddress::where('person_id', $personId)->where('address', $addressLine)->first();
// Compare addresses with all spaces removed to handle whitespace variations
$addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine);
$existing = PersonAddress::where('person_id', $personId)
->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces])
->first();
$applyInsert = [];
$applyUpdate = [];
foreach ($mappings as $map) {
@@ -2630,9 +3207,23 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
$data['person_id'] = $personId;
$data['country'] = $data['country'] ?? 'SLO';
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
try {
$created = PersonAddress::create($data);
return ['action' => 'inserted', 'address' => $created];
} catch (QueryException $e) {
// If unique constraint violation, skip instead of aborting
Log::warning('Address constraint violation during import', [
'person_id' => $personId,
'address' => $addressLine,
'error' => $e->getMessage(),
]);
if ($e->getCode() === '23505' || str_contains($e->getMessage(), 'unique') || str_contains($e->getMessage(), 'duplicate')) {
return ['action' => 'skipped', 'message' => 'Address already exists (constraint violation)'];
}
throw $e;
}
}
}
@@ -2644,6 +3235,10 @@ private function upsertPhone(int $personId, array $phoneData, $mappings): array
if ($nu === '') {
return ['action' => 'skipped', 'message' => 'No phone value'];
}
if (! preg_match('/^[0-9]{6,20}$/', $nu)) {
return ['action' => 'skipped', 'message' => 'Invalid phone value'];
}
$phoneData['nu'] = $nu;
// Find existing phone by normalized number (strip non-numeric from DB values too)
$existing = PersonPhone::where('person_id', $personId)
+96 -3
View File
@@ -25,6 +25,11 @@ class ImportSimulationService
*/
private ?int $clientId = null;
/**
* History import mode flag (from template meta).
*/
private bool $historyImport = false;
/**
* Public entry: simulate import applying mappings to first $limit rows.
* Keeps existing machine keys for backward compatibility, but adds Slovenian
@@ -79,6 +84,7 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
$simRows = [];
// Determine keyref behavior for contract.reference from mappings/template
$tplMeta = optional($import->template)->meta ?? [];
$this->historyImport = (bool) ($tplMeta['history_import'] ?? false);
$contractKeyModeTpl = $tplMeta['contract_key_mode'] ?? null; // e.g. 'reference'
$contractRefMode = $this->mappingModeForImport($import, 'contract.reference'); // e.g. 'keyref'
foreach ($rows as $idx => $rawValues) {
@@ -489,6 +495,38 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
}
}
// History import: auto-ensure account placeholder when contract exists but no account mapping
if ($this->historyImport && $existingContract && isset($rowEntities['contract']['id']) && ! isset($rowEntities['account'])) {
if (! isset($summaries['account'])) {
$summaries['account'] = [
'root' => 'account',
'total_rows' => 0,
'create' => 0,
'update' => 0,
'missing_ref' => 0,
'invalid' => 0,
'duplicate' => 0,
'duplicate_db' => 0,
];
}
$summaries['account']['total_rows']++;
$summaries['account']['update']++;
$ref = $rowEntities['contract']['reference'] ?? null;
if ($ref === null || $ref === '') {
$ref = 'HIST-'.$rowEntities['contract']['id'];
}
$rowEntities['account'] = [
'reference' => $ref,
'exists' => true,
'id' => null,
'balance_before' => 0,
'balance_after' => 0,
'action' => 'implicit_history',
'action_label' => $translatedActions['implicit'] ?? 'posredno',
'history_zeroed' => true,
];
}
// Payment (affects account balance; may create implicit account)
if (isset($entityRoots['payment'])) {
// Inject inferred account if none mapped explicitly
@@ -891,7 +929,7 @@ private function simulateContract(callable $val, array $summaries, array $cache,
'client_case_id' => $contract?->client_case_id,
'active' => $contract?->active,
'deleted_at' => $contract?->deleted_at,
'action' => $contract ? 'update' : ($reference ? 'create' : 'skip'),
'action' => $contract ? ($this->historyImport ? 'skipped_history' : 'update') : ($reference ? 'create' : 'skip'),
];
$summaries['contract']['total_rows']++;
if (! $reference) {
@@ -902,6 +940,11 @@ private function simulateContract(callable $val, array $summaries, array $cache,
$summaries['contract']['create']++;
}
if ($this->historyImport && $contract) {
$entity['history_reuse'] = true;
$entity['message'] = 'Existing contract reused (history import)';
}
return [$entity, $summaries, $cache];
}
@@ -931,7 +974,7 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
'exists' => (bool) $account,
'balance_before' => $account?->balance_amount,
'balance_after' => $account?->balance_amount,
'action' => $account ? 'update' : ($reference ? 'create' : 'skip'),
'action' => $account ? ($this->historyImport ? 'skipped_history' : 'update') : ($reference ? 'create' : 'skip'),
];
// Direct balance override support.
@@ -940,7 +983,7 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
$rawIncoming = $val('account.balance_amount')
?? $val('accounts.balance_amount')
?? $val('account.balance');
if ($rawIncoming !== null && $rawIncoming !== '') {
if (! $this->historyImport && $rawIncoming !== null && $rawIncoming !== '') {
$rawStr = (string) $rawIncoming;
// Remove currency symbols and non numeric punctuation except , . -
$clean = preg_replace('/[^0-9,\.\-]+/', '', $rawStr) ?? '';
@@ -974,6 +1017,19 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
$summaries['account']['create']++;
}
if ($this->historyImport) {
// History imports keep balances unchanged and do not update accounts
$entity['balance_after'] = $account?->balance_amount ?? 0;
$entity['balance_before'] = $account?->balance_amount ?? 0;
if ($account) {
$entity['message'] = 'Existing account left unchanged (history import)';
} else {
$entity['balance_after'] = 0;
$entity['balance_before'] = 0;
$entity['history_zeroed'] = true;
}
}
return [$entity, $summaries, $cache];
}
@@ -1210,6 +1266,10 @@ private function simulateGenericRoot(
$reference = $val('phone.nu');
} elseif ($root === 'email') {
$reference = $val('email.value');
} elseif ($root === 'activity') {
$noteRef = $val('activity.note');
$dueRef = $val('activity.due_date');
$reference = $noteRef || $dueRef ? trim((string) ($dueRef ?? '')).($noteRef ? ' | '.$noteRef : '') : null;
}
}
@@ -1253,6 +1313,13 @@ private function simulateGenericRoot(
$entity['description'] = $val('case_object.description') ?? null;
$entity['type'] = $val('case_object.type') ?? null;
break;
case 'activity':
$entity['note'] = $val('activity.note') ?? null;
$entity['due_date'] = $val('activity.due_date') ?? null;
$entity['amount'] = $val('activity.amount') ?? null;
$entity['action_id'] = $val('activity.action_id') ?? null;
$entity['decision_id'] = $val('activity.decision_id') ?? null;
break;
}
if ($verbose) {
@@ -1367,6 +1434,16 @@ private function genericIdentityCandidates(string $root, callable $val): array
$ids[] = 'name:'.mb_strtolower(trim((string) $name));
}
return $ids;
case 'activity':
$note = $val('activity.note');
$due = $val('activity.due_date');
$contractRef = $val('contract.reference');
$ids = [];
if ($note || $due) {
$ids[] = 'activity:'.mb_strtolower(trim((string) ($note ?? ''))).'|'.mb_strtolower(trim((string) ($due ?? ''))).'|'.mb_strtolower(trim((string) ($contractRef ?? '')));
}
return $ids;
default:
return [];
@@ -1426,6 +1503,20 @@ private function loadExistingGenericIdentities(string $root): array
}
}
break;
case 'activity':
foreach (\App\Models\Activity::query()->get(['note', 'due_date', 'contract_id', 'client_case_id']) as $rec) {
$note = mb_strtolower(trim((string) ($rec->note ?? '')));
$due = $rec->due_date ? mb_strtolower(trim((string) $rec->due_date)) : '';
$contractRef = null;
if ($rec->contract_id) {
$contractRef = Contract::where('id', $rec->contract_id)->value('reference');
}
$key = 'activity:'.$note.'|'.$due.'|'.mb_strtolower(trim((string) ($contractRef ?? '')));
if (trim($key, 'activity:|') !== '') {
$set[$key] = true;
}
}
break;
}
} catch (\Throwable) {
// swallow and return what we have
@@ -1730,6 +1821,8 @@ private function actionTranslations(): array
'skip' => 'preskoči',
'implicit' => 'posredno',
'reactivate' => 'reaktiviraj',
'skipped_history' => 'preskoči (zgodovina)',
'implicit_history' => 'posredno (zgodovina)',
];
}
-68
View File
@@ -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(),
];
}
}
-1
View File
@@ -5,5 +5,4 @@
App\Providers\AuthServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
App\Providers\ReportServiceProvider::class,
];
-21
View File
@@ -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": {}
}
-1
View File
@@ -7,7 +7,6 @@
"require": {
"php": "^8.2",
"arielmejiadev/larapex-charts": "^2.1",
"barryvdh/laravel-dompdf": "^3.1",
"diglactic/laravel-breadcrumbs": "^10.0",
"http-interop/http-factory-guzzle": "^1.2",
"inertiajs/inertia-laravel": "^2.0",
Generated
+9 -374
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d28e6760b713feea1c4ad6058f96287a",
"content-hash": "d29c47a4d6813ee8e80a7c8112c2f17e",
"packages": [
{
"name": "arielmejiadev/larapex-charts",
@@ -113,83 +113,6 @@
},
"time": "2024-10-01T13:55:55+00:00"
},
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-dompdf.git",
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
"shasum": ""
},
"require": {
"dompdf/dompdf": "^3.0",
"illuminate/support": "^9|^10|^11|^12",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.7|^3.0",
"orchestra/testbench": "^7|^8|^9|^10",
"phpro/grumphp": "^2.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
},
"providers": [
"Barryvdh\\DomPDF\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\DomPDF\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "A DOMPDF Wrapper for Laravel",
"keywords": [
"dompdf",
"laravel",
"pdf"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2025-02-13T15:07:54+00:00"
},
{
"name": "brick/math",
"version": "0.12.3",
@@ -838,161 +761,6 @@
],
"time": "2024-02-05T11:56:58+00:00"
},
{
"name": "dompdf/dompdf",
"version": "v3.1.4",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "db712c90c5b9868df3600e64e68da62e78a34623"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623",
"reference": "db712c90c5b9868df3600e64e68da62e78a34623",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.4"
},
"time": "2025-10-29T12:43:30+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
"reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.1"
},
"time": "2024-12-02T14:37:59+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
"reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0"
},
"time": "2024-04-29T13:26:35+00:00"
},
{
"name": "dragonmantank/cron-expression",
"version": "v3.4.0",
@@ -3227,16 +2995,16 @@
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.0",
"version": "3.2.1",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416"
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
"shasum": ""
},
"require": {
@@ -3247,7 +3015,7 @@
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.16",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
@@ -3293,7 +3061,7 @@
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0"
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
},
"funding": [
{
@@ -3301,7 +3069,7 @@
"type": "github"
}
],
"time": "2025-07-17T11:15:13+00:00"
"time": "2025-12-10T09:58:31+00:00"
},
{
"name": "markbaker/complex",
@@ -3410,73 +3178,6 @@
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
},
"time": "2025-07-25T09:04:22+00:00"
},
{
"name": "meilisearch/meilisearch-php",
"version": "v1.13.0",
@@ -5330,72 +5031,6 @@
],
"time": "2025-02-28T15:16:05+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v8.9.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9",
"reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
},
"require-dev": {
"phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41",
"rawr/cross-data-providers": "^2.0.0"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0"
},
"time": "2025-07-11T13:20:48+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.92.0",
@@ -11289,6 +10924,6 @@
"platform": {
"php": "^8.2"
},
"platform-dev": {},
"platform-dev": [],
"plugin-api-version": "2.6.0"
}
-10
View File
@@ -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,30 @@
<?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('field_jobs', function (Blueprint $table) {
$table->boolean('added_activity')->default(false);
$table->timestamp('last_activity')->nullable()->default(null);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('field_jobs', function (Blueprint $table) {
$table->dropColumn('last_activity');
$table->dropColumn('added_activity');
});
}
};
+23
View File
@@ -175,6 +175,29 @@ public function run(): void
],
'ui' => ['order' => 9],
],
[
'key' => 'activities',
'canonical_root' => 'activity',
'label' => 'Activities',
'fields' => ['note', 'due_date', 'amount', 'action_id', 'decision_id', 'contract_id', 'client_case_id', 'user_id'],
'field_aliases' => [
'opis' => 'note',
'datum' => 'due_date',
'rok' => 'due_date',
'znesek' => 'amount',
],
'aliases' => ['activity', 'activities', 'opravilo', 'opravila'],
'rules' => [
['pattern' => '/^(aktivnost|activity|note|opis)\b/i', 'field' => 'note'],
['pattern' => '/^(rok|due|datum|date)\b/i', 'field' => 'due_date'],
['pattern' => '/^(znesek|amount|vrednost|value)\b/i', 'field' => 'amount'],
['pattern' => '/^(akcija|action)\b/i', 'field' => 'action_id'],
['pattern' => '/^(odlocitev|odločitev|decision)\b/i', 'field' => 'decision_id'],
['pattern' => '/^(pogodba|contract)\b/i', 'field' => 'contract_id'],
['pattern' => '/^(primer|case)\b/i', 'field' => 'client_case_id'],
],
'ui' => ['order' => 10],
],
];
foreach ($defs as $d) {
+37
View File
@@ -155,5 +155,42 @@ public function run(): void
'options' => $map['options'] ?? null,
]);
}
// Activities linked to contracts demo
$activities = ImportTemplate::query()->firstOrCreate([
'name' => 'Activities CSV (contract linked)',
], [
'uuid' => (string) Str::uuid(),
'description' => 'Activities import linked to existing contracts via reference.',
'source_type' => 'csv',
'default_record_type' => 'activity',
'sample_headers' => ['contract_reference', 'note', 'due_date', 'amount', 'action', 'decision', 'user_email'],
'is_active' => true,
'meta' => [
'delimiter' => ',',
'enclosure' => '"',
'escape' => '\\',
],
]);
$activityMappings = [
['source_column' => 'contract_reference', 'target_field' => 'contract.reference', 'position' => 1],
['source_column' => 'note', 'target_field' => 'activity.note', 'position' => 2],
['source_column' => 'due_date', 'target_field' => 'activity.due_date', 'position' => 3],
['source_column' => 'amount', 'target_field' => 'activity.amount', 'position' => 4],
['source_column' => 'action', 'target_field' => 'activity.action_id', 'position' => 5],
['source_column' => 'decision', 'target_field' => 'activity.decision_id', 'position' => 6],
];
foreach ($activityMappings as $map) {
ImportTemplateMapping::firstOrCreate([
'import_template_id' => $activities->id,
'source_column' => $map['source_column'],
], [
'target_field' => $map['target_field'],
'position' => $map['position'],
'options' => $map['options'] ?? null,
]);
}
}
}
+1620 -3164
View File
File diff suppressed because it is too large Load Diff
+8 -25
View File
@@ -3,26 +3,21 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
"build": "vite build"
},
"devDependencies": {
"@inertiajs/vue3": "2.0",
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/typography": "^0.5.10",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.16",
"axios": "^1.7.4",
"laravel-vite-plugin": "^2.0.1",
"postcss": "^8.4.32",
"tailwindcss": "^4.1.16",
"typescript": "^5.9.3",
"tailwindcss": "^3.4.0",
"vite": "^7.1.7",
"vue": "^3.3.13",
"vue-tsc": "^3.1.5"
"vue": "^3.3.13"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.6.0",
@@ -30,36 +25,24 @@
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/vue-fontawesome": "^3.0.8",
"quill": "^1.3.7",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@internationalized/date": "^3.9.0",
"@tanstack/vue-table": "^8.21.3",
"@unovis/ts": "^1.6.2",
"@unovis/vue": "^1.6.2",
"@vee-validate/zod": "^4.15.1",
"@vuepic/vue-datepicker": "^11.0.2",
"@vueuse/core": "^14.0.0",
"apexcharts": "^4.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"flowbite": "^2.5.2",
"flowbite-vue": "^0.1.6",
"lodash": "^4.17.21",
"lucide-vue-next": "^0.552.0",
"material-design-icons-iconfont": "^6.7.0",
"preline": "^2.7.0",
"quill": "^1.3.7",
"reka-ui": "^2.6.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"reka-ui": "^2.5.1",
"tailwindcss-inner-border": "^0.2.0",
"v-calendar": "^3.1.2",
"vee-validate": "^4.15.1",
"vue-currency-input": "^3.2.1",
"vue-multiselect": "^3.1.0",
"vue-search-input": "^1.1.16",
"vue-sonner": "^2.0.9",
"vue3-apexcharts": "^1.7.0",
"vuedraggable": "^4.1.0",
"zod": "^3.25.76"
"vue-currency-input": "^3.2.1"
}
}
+4 -7
View File
@@ -1,9 +1,6 @@
import tailwindcss from '@tailwindcss/postcss';
import autoprefixer from 'autoprefixer';
export default {
plugins: [
tailwindcss(),
autoprefixer(),
],
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+6 -263
View File
@@ -1,143 +1,10 @@
@import "tailwindcss";
@import "./themes.css";
@import '/node_modules/floating-vue/dist/style.css';
@import '/node_modules/vue-search-input/dist/styles.css';
@import '/node_modules/vue-multiselect/dist/vue-multiselect.min.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme {
/* Disable dark mode */
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
/* Font Family */
--font-family-sans: 'Figtree', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* Primary brand colors */
--color-primary-50: #eef2ff;
--color-primary-100: #e0e7ff;
--color-primary-200: #c7d2fe;
--color-primary-300: #a5b4fc;
--color-primary-400: #818cf8;
--color-primary-500: #6366f1;
--color-primary-600: #4f46e5;
--color-primary-700: #4338ca;
--color-primary-800: #3730a3;
--color-primary-900: #312e81;
--color-primary-950: #1e1b4b;
/* Semantic colors - Success */
--color-success-50: #f0fdf4;
--color-success-100: #dcfce7;
--color-success-200: #bbf7d0;
--color-success-300: #86efac;
--color-success-400: #4ade80;
--color-success-500: #22c55e;
--color-success-600: #16a34a;
--color-success-700: #15803d;
--color-success-800: #166534;
--color-success-900: #14532d;
/* Semantic colors - Warning */
--color-warning-50: #fffbeb;
--color-warning-100: #fef3c7;
--color-warning-200: #fde68a;
--color-warning-300: #fcd34d;
--color-warning-400: #fbbf24;
--color-warning-500: #f59e0b;
--color-warning-600: #d97706;
--color-warning-700: #b45309;
--color-warning-800: #92400e;
--color-warning-900: #78350f;
/* Semantic colors - Error */
--color-error-50: #fef2f2;
--color-error-100: #fee2e2;
--color-error-200: #fecaca;
--color-error-300: #fca5a5;
--color-error-400: #f87171;
--color-error-500: #ef4444;
--color-error-600: #dc2626;
--color-error-700: #b91c1c;
--color-error-800: #991b1b;
--color-error-900: #7f1d1d;
/* Semantic colors - Info */
--color-info-50: #eff6ff;
--color-info-100: #dbeafe;
--color-info-200: #bfdbfe;
--color-info-300: #93c5fd;
--color-info-400: #60a5fa;
--color-info-500: #3b82f6;
--color-info-600: #2563eb;
--color-info-700: #1d4ed8;
--color-info-800: #1e40af;
--color-info-900: #1e3a8a;
/* Neutral grays */
--color-neutral-50: #f9fafb;
--color-neutral-100: #f3f4f6;
--color-neutral-200: #e5e7eb;
--color-neutral-300: #d1d5db;
--color-neutral-400: #9ca3af;
--color-neutral-500: #6b7280;
--color-neutral-600: #4b5563;
--color-neutral-700: #374151;
--color-neutral-800: #1f2937;
--color-neutral-900: #111827;
/* Spacing scale */
--spacing-18: 4.5rem;
--spacing-88: 22rem;
--spacing-112: 28rem;
--spacing-128: 32rem;
/* Border radius */
--radius-4xl: 2rem;
/* Box shadows */
--shadow-soft: 0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04);
--shadow-medium: 0 4px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--shadow-strong: 0 10px 40px -10px rgba(0, 0, 0, 0.2);
/* Animations */
--animate-fade-in: fade-in 0.2s ease-in-out;
--animate-slide-up: slide-up 0.3s ease-out;
--animate-slide-down: slide-down 0.3s ease-out;
--animate-shimmer: shimmer 2s infinite linear;
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slide-down {
from {
transform: translateY(-10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes shimmer {
from { background-position: -1000px 0; }
to { background-position: 1000px 0; }
}
}
[x-cloak] {
display: none;
@@ -145,127 +12,3 @@ [x-cloak] {
/* Ensure dropdowns/menus render above dialog overlays when appended to body */
.multiselect__content-wrapper { z-index: 2147483647 !important; }
/* stylelint-disable-next-line at-rule-no-unknown */
/* @theme is a valid Tailwind CSS v4 at-rule */
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.648 0.2 131.684);
--primary-foreground: oklch(0.986 0.031 120.757);
--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.841 0.238 128.85);
--chart-1: oklch(0.871 0.15 154.449);
--chart-2: oklch(0.723 0.219 149.579);
--chart-3: oklch(0.627 0.194 149.214);
--chart-4: oklch(0.527 0.154 150.069);
--chart-5: oklch(0.448 0.119 151.328);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.648 0.2 131.684);
--sidebar-primary-foreground: oklch(0.986 0.031 120.757);
--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.841 0.238 128.85);
}
.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.648 0.2 131.684);
--primary-foreground: oklch(0.986 0.031 120.757);
--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.405 0.101 131.063);
--chart-1: oklch(0.871 0.15 154.449);
--chart-2: oklch(0.723 0.219 149.579);
--chart-3: oklch(0.627 0.194 149.214);
--chart-4: oklch(0.527 0.154 150.069);
--chart-5: oklch(0.448 0.119 151.328);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.768 0.233 130.85);
--sidebar-primary-foreground: oklch(0.986 0.031 120.757);
--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.405 0.101 131.063);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
button:not([disabled]),
[role="button"]:not([disabled]) {
cursor: pointer;
}
}
-396
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-auto" viewBox="0 -960 960 960" fill="#5985E1" preserveAspectRatio="xMidYMid meet"><path d="M480-80q-140-35-230-162.5T160-522v-238l320-120 320 120v238q0 78-21.5 154.5T703-225L563-359q-19 11-40.16 18-21.16 7-42.84 7-62 0-105.5-43T331-482.5q0-62.5 43.5-106T480-632q62 0 105.5 43.5T629-482q0 21-6 42t-19 38l88 84q24-43 36-96.5T740-522v-198.48L480-815l-260 94.52V-522q0 131 72.5 236.5T480.2-142q28.8-8 70.3-33t65.5-48l42 43q-35 32-83.5 60.5T480-80Zm.2-314q36.8 0 62.8-25.5t26-63q0-37.5-26.2-63.5-26.21-26-63-26-36.8 0-62.8 26t-26 63.5q0 37.5 26.2 63 26.21 25.5 63 25.5Zm-1.2-90Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" class="w-20 h-20" viewBox="0 -960 960 960" fill="#5985E1"><path d="M480-80q-140-35-230-162.5T160-522v-238l320-120 320 120v238q0 78-21.5 154.5T703-225L563-359q-19 11-40.16 18-21.16 7-42.84 7-62 0-105.5-43T331-482.5q0-62.5 43.5-106T480-632q62 0 105.5 43.5T629-482q0 21-6 42t-19 38l88 84q24-43 36-96.5T740-522v-198.48L480-815l-260 94.52V-522q0 131 72.5 236.5T480.2-142q28.8-8 70.3-33t65.5-48l42 43q-35 32-83.5 60.5T480-80Zm.2-314q36.8 0 62.8-25.5t26-63q0-37.5-26.2-63.5-26.21-26-63-26-36.8 0-62.8 26t-26 63.5q0 37.5 26.2 63 26.21 25.5 63 25.5Zm-1.2-90Z"/></svg>
</template>
+20 -23
View File
@@ -1,5 +1,5 @@
<script setup>
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
import { FwbButton, FwbTable, FwbTableBody, FwbTableCell, FwbTableHead, FwbTableHeadCell, FwbTableRow } from 'flowbite-vue';
import DialogModal from './DialogModal.vue';
import { useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
@@ -9,7 +9,6 @@ import ActionMessage from './ActionMessage.vue';
import PrimaryButton from './PrimaryButton.vue';
import Modal from './Modal.vue';
import SecondaryButton from './SecondaryButton.vue';
import { Button } from '@/Components/ui/button';
const props = defineProps({
@@ -122,32 +121,30 @@ const remove = () => {
</div>
<div :class="['relative rounded-lg border border-gray-200 bg-white shadow-sm overflow-x-auto overflow-y-auto', bodyMaxHeight, stickyHeader ? 'table-sticky' : '']">
<Table class="text-sm">
<TableHeader class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur supports-[backdrop-filter]:bg-gray-50/80 border-b border-gray-200 shadow-sm">
<TableRow class="border-b">
<TableHead
<FwbTable hoverable striped class="text-sm">
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur supports-[backdrop-filter]:bg-gray-50/80 border-b border-gray-200 shadow-sm">
<FwbTableHeadCell
v-for="(h, hIndex) in header"
:key="hIndex"
class="sticky top-0 z-10 uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6 bg-gray-50/90"
>{{ h.data }}</TableHead>
<TableHead v-if="editor" class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90"></TableHead>
<TableHead v-else class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="(row, key, parent_index) in body" :key="key" :class="[row.options.class, 'hover:bg-gray-50/50']">
<TableCell v-for="(col, colIndex) in row.cols" :key="colIndex" class="align-middle">
>{{ h.data }}</FwbTableHeadCell>
<FwbTableHeadCell v-if="editor" class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90"></FwbTableHeadCell>
<FwbTableHeadCell v-else class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90" />
</FwbTableHead>
<FwbTableBody>
<FwbTableRow v-for="(row, key, parent_index) in body" :key="key" :class="row.options.class">
<FwbTableCell v-for="(col, colIndex) in row.cols" :key="colIndex" class="align-middle">
<a v-if="col.link !== undefined" :class="col.link.css" :href="route(col.link.route, col.link.options)">{{ col.data }}</a>
<span v-else>{{ col.data }}</span>
</TableCell>
<TableCell v-if="editor" class="text-right whitespace-nowrap">
<Button class="mr-2" size="sm" variant="outline" @click="openEditor(row.options.ref, row.options.editable)">Edit</Button>
<Button size="sm" variant="destructive" @click="showModal(row.options.ref.val, row.options.title)">Remove</Button>
</TableCell>
<TableCell v-else />
</TableRow>
</TableBody>
</Table>
</FwbTableCell>
<FwbTableCell v-if="editor" class="text-right whitespace-nowrap">
<fwb-button class="mr-2" size="sm" color="default" @click="openEditor(row.options.ref, row.options.editable)" outline>Edit</fwb-button>
<fwb-button size="sm" color="red" @click="showModal(row.options.ref.val, row.options.title)" outline>Remove</fwb-button>
</FwbTableCell>
<FwbTableCell v-else />
</FwbTableRow>
</FwbTableBody>
</FwbTable>
<div v-if="!body || body.length === 0" class="p-6 text-center text-sm text-gray-500">No records found.</div>
</div>
</div>
+2 -2
View File
@@ -13,8 +13,8 @@ defineProps({
<svg v-if="index !== 0" class="rtl:rotate-180 w-3 h-3 text-gray-400 mx-1" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
</svg>
<a v-if="page.current === undefined" :href="page.url" class="ms-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ms-2">{{ page.title }}</a>
<span v-else class="ms-1 text-sm font-medium text-gray-500 md:ms-2">{{ page.title }}</span>
<a v-if="page.current === undefined" :href="page.url" class="ms-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ms-2 dark:text-gray-400 dark:hover:text-white">{{ page.title }}</a>
<span v-else class="ms-1 text-sm font-medium text-gray-500 md:ms-2 dark:text-gray-400">{{ page.title }}</span>
</li>
</ol>
</nav>
+19 -14
View File
@@ -1,5 +1,6 @@
<script setup>
import DeleteDialog from './Dialogs/DeleteDialog.vue';
import DialogModal from './DialogModal.vue';
import PrimaryButton from './PrimaryButton.vue';
const props = defineProps({
show: { type: Boolean, default: false },
@@ -8,8 +9,6 @@ const props = defineProps({
confirmText: { type: String, default: 'Potrdi' },
cancelText: { type: String, default: 'Prekliči' },
danger: { type: Boolean, default: false },
itemName: { type: String, default: null },
processing: { type: Boolean, default: false },
});
const emit = defineEmits(['close', 'confirm']);
@@ -19,15 +18,21 @@ const onConfirm = () => emit('confirm');
</script>
<template>
<DeleteDialog
:show="show"
:title="title"
:message="message"
:item-name="itemName"
:confirm-text="confirmText"
:cancel-text="cancelText"
:processing="processing"
@close="onClose"
@confirm="onConfirm"
/>
<DialogModal :show="show" @close="onClose">
<template #title>
{{ title }}
</template>
<template #content>
<p class="text-sm text-gray-700">{{ message }}</p>
<div class="mt-6 flex items-center justify-end gap-3">
<button type="button" class="px-4 py-2 rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50" @click="onClose">
{{ cancelText }}
</button>
<PrimaryButton :class="danger ? 'bg-red-600 hover:bg-red-700 focus:ring-red-500' : ''" @click="onConfirm">
{{ confirmText }}
</PrimaryButton>
</div>
</template>
</DialogModal>
</template>
+1 -4
View File
@@ -1,8 +1,6 @@
<script setup>
import { watch, onMounted } from "vue";
import { useCurrencyInput } from "vue-currency-input";
import { Input } from "@/Components/ui/input";
import { cn } from "@/lib/utils";
const props = defineProps({
modelValue: { type: [Number, String, null], default: null },
@@ -16,7 +14,6 @@ const props = defineProps({
precision: { type: [Number, Object], default: 2 },
allowNegative: { type: Boolean, default: false },
useGrouping: { type: Boolean, default: true },
class: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue", "change"]);
@@ -84,7 +81,7 @@ onMounted(() => {
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:class="cn('flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)"
class="mt-1 block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-800 dark:border-gray-600"
autocomplete="off"
@change="$emit('change', numberValue)"
/>
+3 -3
View File
@@ -16,15 +16,15 @@ provide('selected', selected);
</script>
<template>
<div class="text-sm font-medium text-center text-gray-500 border-b border-gray-200">
<div class="text-sm font-medium text-center text-gray-500 border-b border-gray-200 dark:text-gray-400 dark:border-gray-700">
<ul class="flex flex-wrap -mb-px">
<li class="me-2" v-for="tab in tabs" :key="tab.name">
<button
@click="selected = tab.name"
class="inline-block p-4"
:class="{
'border-b-2 border-transparent rounded-t-lg hover:text-gray-600 hover:border-gray-300': tab.name !== selected,
[`text-${selectedColor} border-b-2 border-${selectedColor} rounded-t-lg active`]: tab.name === selected
['border-b-2 border-transparent rounded-t-lg hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300']: tab.name !== selected,
[`text-${ selectedColor } border-b-2 border-${ selectedColor } rounded-t-lg active dark:text-blue-500 dark:border-${ selectedColor }`]: tab.name === selected
}"
>
{{ tab.title }}
@@ -1,54 +0,0 @@
<script setup>
import { DropdownMenuItem } from '@/Components/ui/dropdown-menu';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { cn } from '@/lib/utils';
const props = defineProps({
icon: {
type: [String, Object, Array],
default: null,
},
label: {
type: String,
required: true,
},
danger: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
function handleClick(event) {
if (!props.disabled) {
emit('click', event);
}
}
</script>
<template>
<DropdownMenuItem
:disabled="disabled"
:class="
cn(
'flex items-center gap-2 cursor-pointer',
danger && 'text-red-700 focus:text-red-700 focus:bg-red-50',
)
"
@select="handleClick"
>
<FontAwesomeIcon
v-if="icon"
:icon="icon"
class="w-4 h-4 flex-shrink-0"
/>
<span>{{ label }}</span>
</DropdownMenuItem>
</template>
@@ -1,134 +0,0 @@
<script setup>
import { ref, watch } from 'vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faFilter, faTimes } from '@fortawesome/free-solid-svg-icons';
import DatePicker from '@/Components/DatePicker.vue';
const props = defineProps({
column: {
type: Object,
required: true,
},
value: {
type: [String, Number, Array, null],
default: null,
},
type: {
type: String,
default: 'text', // text, select, date, date-range, number
validator: (v) => ['text', 'select', 'date', 'date-range', 'number'].includes(v),
},
options: {
type: Array,
default: () => [], // For select type: [{ value, label }]
},
});
const emit = defineEmits(['update:value', 'clear']);
const internalValue = ref(props.value);
watch(
() => props.value,
(newVal) => {
internalValue.value = newVal;
}
);
watch(internalValue, (newVal) => {
emit('update:value', newVal);
});
function clear() {
internalValue.value = props.type === 'select' || props.type === 'date-range' ? null : '';
emit('clear');
}
</script>
<template>
<div class="relative">
<!-- Filter Button -->
<button
type="button"
:class="[
'inline-flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium transition-colors',
value && value !== '' && (Array.isArray(value) ? value.length > 0 : true)
? 'bg-primary-100 text-primary-700 hover:bg-primary-200'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200',
]"
@click.stop
>
<FontAwesomeIcon :icon="faFilter" class="h-3 w-3 mr-1" />
{{ column.label }}
<button
v-if="value && value !== '' && (Array.isArray(value) ? value.length > 0 : true)"
@click.stop="clear"
class="ml-1.5 text-gray-500 hover:text-gray-700"
>
<FontAwesomeIcon :icon="faTimes" class="h-3 w-3" />
</button>
</button>
<!-- Filter Dropdown -->
<div
v-if="showDropdown"
class="absolute z-50 mt-1 w-64 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5"
@click.stop
>
<div class="p-3">
<!-- Text Filter -->
<input
v-if="type === 'text'"
v-model="internalValue"
type="text"
:placeholder="`Filtriraj ${column.label.toLowerCase()}...`"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500"
/>
<!-- Select Filter -->
<select
v-else-if="type === 'select'"
v-model="internalValue"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option :value="null">Vse</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<!-- Number Filter -->
<div v-else-if="type === 'number'" class="space-y-2">
<input
v-model.number="internalValue"
type="number"
:placeholder="`Filtriraj ${column.label.toLowerCase()}...`"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500"
/>
</div>
<!-- Date Filter -->
<DatePicker
v-else-if="type === 'date'"
v-model="internalValue"
format="dd.MM.yyyy"
placeholder="Izberi datum"
/>
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue';
const showDropdown = ref(false);
// Close dropdown when clicking outside
if (typeof window !== 'undefined') {
document.addEventListener('click', () => {
showDropdown.value = false;
});
}
</script>
@@ -1,705 +0,0 @@
<script setup>
import { ref, computed, watch, h, useSlots } from "vue";
import { router } from "@inertiajs/vue3";
import {
useVueTable,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
} from "@tanstack/vue-table";
import DataTableColumnHeader from "./DataTableColumnHeader.vue";
import DataTablePagination from "./DataTablePagination.vue";
import DataTableViewOptions from "./DataTableViewOptions.vue";
import SkeletonTable from "../Skeleton/SkeletonTable.vue";
import EmptyState from "../EmptyState.vue";
import Pagination from "../Pagination.vue";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import Checkbox from "@/Components/ui/checkbox/Checkbox.vue";
import { cn } from "@/lib/utils";
const slots = useSlots();
const props = defineProps({
// Data
rows: { type: Array, default: () => [] },
columns: {
type: Array,
required: true,
validator: (cols) =>
cols.every(
(col) =>
col.key &&
col.label &&
typeof col.key === "string" &&
typeof col.label === "string"
),
},
// Pagination (for server-side)
meta: {
type: Object,
default: null,
validator: (meta) =>
!meta ||
(typeof meta.current_page === "number" &&
typeof meta.per_page === "number" &&
typeof meta.total === "number" &&
typeof meta.last_page === "number"),
},
// Sorting
sort: {
type: Object,
default: () => ({ key: null, direction: null }),
},
// Search
search: { type: String, default: "" },
// Loading state
loading: { type: Boolean, default: false },
// Client-side pagination (when meta is null)
pageSize: { type: Number, default: 10 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
// Routing (for server-side)
routeName: { type: String, default: null },
routeParams: { type: Object, default: () => ({}) },
pageParamName: { type: String, default: "page" },
onlyProps: { type: Array, default: () => [] },
// Features
showPagination: { type: Boolean, default: true },
showViewOptions: { type: Boolean, default: false },
rowKey: { type: [String, Function], default: "uuid" },
selectable: { type: Boolean, default: false },
striped: { type: Boolean, default: false },
hoverable: { type: Boolean, default: true },
// Empty state
emptyText: { type: String, default: "Ni podatkov" },
emptyIcon: { type: [String, Object, Array], default: null },
emptyDescription: { type: String, default: null },
// Actions
showActions: { type: Boolean, default: false },
actionsPosition: {
type: String,
default: "right",
validator: (v) => ["left", "right"].includes(v),
},
// Mobile
mobileCardView: { type: Boolean, default: true },
mobileBreakpoint: { type: Number, default: 768 },
// State preservation
preserveState: { type: Boolean, default: true },
preserveScroll: { type: Boolean, default: true },
});
const emit = defineEmits([
"update:search",
"update:sort",
"update:page",
"update:pageSize",
"row:click",
"row:select",
"selection:change",
]);
// Determine if this is server-side (has meta and routeName)
const isServerSide = computed(() => !!(props.meta && props.routeName));
const isClientSide = computed(() => !isServerSide.value);
// Row key helper
function keyOf(row) {
if (typeof props.rowKey === "function") return props.rowKey(row);
if (typeof props.rowKey === "string" && row && row[props.rowKey] != null)
return row[props.rowKey];
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
}
// Convert simple column format to TanStack Table ColumnDef format
const columnDefinitions = computed(() => {
return props.columns.map((col) => ({
accessorKey: col.key,
id: col.key,
header: ({ column }) => {
return h(DataTableColumnHeader, {
column,
title: col.label,
class: col.class,
});
},
cell: ({ row, getValue }) => {
return getValue();
},
enableSorting: col.sortable !== false,
enableHiding: col.hideable !== false,
meta: {
align: col.align || "left",
class: col.class,
},
}));
});
// Add selection column if selectable
const columnsWithSelection = computed(() => {
if (!props.selectable) return columnDefinitions.value;
return [
{
id: "select",
enableHiding: false,
enableSorting: false,
header: ({ table }) => {
return h(Checkbox, {
modelValue: table.getIsAllPageRowsSelected(),
indeterminate: table.getIsSomePageRowsSelected(),
"onUpdate:modelValue": (value) => table.toggleAllPageRowsSelected(!!value),
"aria-label": "Select all",
});
},
cell: ({ row }) => {
return h(Checkbox, {
modelValue: row.getIsSelected(),
"onUpdate:modelValue": (value) => row.toggleSelected(!!value),
"aria-label": "Select row",
});
},
},
...columnDefinitions.value,
];
});
// Add actions column if showActions
const finalColumns = computed(() => {
if (!props.showActions && !slots.actions) return columnsWithSelection.value;
return [
...columnsWithSelection.value,
{
id: "actions",
enableHiding: false,
enableSorting: false,
header: () => h("span", { class: "sr-only" }, "Actions"),
cell: ({ row }) => {
// Actions will be rendered via slot
return null;
},
},
];
});
// Internal search state
const internalSearch = ref(props.search);
watch(
() => props.search,
(newVal) => {
internalSearch.value = newVal;
}
);
// Internal sorting state
const sorting = computed(() => {
if (!props.sort?.key || !props.sort?.direction) return [];
return [
{
id: props.sort.key,
desc: props.sort.direction === "desc",
},
];
});
// Internal pagination state
const pagination = computed(() => {
if (isServerSide.value) {
return {
pageIndex: (props.meta?.current_page ?? 1) - 1,
pageSize: props.meta?.per_page ?? props.pageSize,
};
}
return {
pageIndex: internalPage.value - 1,
pageSize: internalPageSize.value,
};
});
const internalPage = ref(1);
const internalPageSize = ref(props.pageSize);
// Row selection
const rowSelection = ref({});
// Create TanStack Table instance
const table = useVueTable({
get data() {
return props.rows;
},
get columns() {
return finalColumns.value;
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: isClientSide.value ? getPaginationRowModel() : undefined,
getSortedRowModel: isClientSide.value ? getSortedRowModel() : undefined,
getFilteredRowModel: isClientSide.value ? getFilteredRowModel() : undefined,
globalFilterFn: "includesString",
onGlobalFilterChange: (updater) => {
const newFilter =
typeof updater === "function" ? updater(internalSearch.value) : updater;
handleSearchChange(newFilter);
},
onSortingChange: (updater) => {
const newSorting = typeof updater === "function" ? updater(sorting.value) : updater;
if (newSorting.length > 0) {
const sort = newSorting[0];
emit("update:sort", {
key: sort.id,
direction: sort.desc ? "desc" : "asc",
});
if (isServerSide.value) {
doServerRequest({
sort: sort.id,
direction: sort.desc ? "desc" : "asc",
page: 1,
});
}
} else {
emit("update:sort", { key: null, direction: null });
if (isServerSide.value) {
doServerRequest({ sort: null, direction: null, page: 1 });
}
}
},
onPaginationChange: (updater) => {
const newPagination =
typeof updater === "function" ? updater(pagination.value) : updater;
if (isServerSide.value) {
doServerRequest({ page: newPagination.pageIndex + 1 });
} else {
internalPage.value = newPagination.pageIndex + 1;
emit("update:page", newPagination.pageIndex + 1);
}
internalPageSize.value = newPagination.pageSize;
emit("update:pageSize", newPagination.pageSize);
},
onRowSelectionChange: (updater) => {
const newSelection =
typeof updater === "function" ? updater(rowSelection.value) : updater;
rowSelection.value = newSelection;
const selectedKeys = Object.keys(newSelection).filter((key) => newSelection[key]);
emit("selection:change", selectedKeys);
},
manualSorting: isServerSide.value,
manualPagination: isServerSide.value,
manualFiltering: isServerSide.value,
enableRowSelection: props.selectable,
state: {
get sorting() {
return sorting.value;
},
get pagination() {
return pagination.value;
},
get rowSelection() {
return rowSelection.value;
},
get globalFilter() {
return internalSearch.value;
},
},
});
// Server-side request
function doServerRequest(overrides = {}) {
const existingParams = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
const q = {
...existingParams,
per_page: overrides.perPage ?? props.meta?.per_page ?? internalPageSize.value,
sort: overrides.sort ?? props.sort?.key ?? existingParams.sort ?? null,
direction:
overrides.direction ?? props.sort?.direction ?? existingParams.direction ?? null,
search: overrides.search ?? internalSearch.value ?? existingParams.search ?? "",
};
const pageParam = props.pageParamName || "page";
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
if (pageParam !== "page") {
delete q.page;
}
Object.keys(q).forEach((k) => {
if (q[k] === null || q[k] === undefined || q[k] === "") delete q[k];
});
const url = route(props.routeName, props.routeParams || {});
router.get(url, q, {
preserveScroll: props.preserveScroll,
preserveState: props.preserveState,
replace: true,
only: props.onlyProps.length ? props.onlyProps : undefined,
});
}
function handleSearchChange(value) {
internalSearch.value = value;
emit("update:search", value);
if (isServerSide.value) {
clearTimeout(searchTimer.value);
searchTimer.value = setTimeout(() => {
doServerRequest({ search: value, page: 1 });
}, 300);
}
}
function handlePageSizeChange(size) {
const newSize = Number(size);
internalPageSize.value = newSize;
emit("update:pageSize", newSize);
if (isServerSide.value) {
doServerRequest({ perPage: newSize, page: 1 });
} else {
table.setPageSize(newSize);
}
}
const searchTimer = ref(null);
// Mobile detection
const isMobile = ref(false);
if (typeof window !== "undefined") {
const checkMobile = () => {
isMobile.value = window.innerWidth < props.mobileBreakpoint;
};
checkMobile();
window.addEventListener("resize", checkMobile);
}
// Display rows
const displayRows = computed(() => {
if (isServerSide.value) return props.rows;
return table.getRowModel().rows.map((row) => row.original);
});
const total = computed(() => {
if (isServerSide.value) return props.meta?.total ?? 0;
return table.getFilteredRowModel().rows.length;
});
const from = computed(() => {
if (isServerSide.value) return props.meta?.from ?? 0;
const pageIndex = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return total.value === 0 ? 0 : pageIndex * pageSize + 1;
});
const to = computed(() => {
if (isServerSide.value) return props.meta?.to ?? 0;
const pageIndex = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return Math.min((pageIndex + 1) * pageSize, total.value);
});
// Export functionality
function handleExport(format) {
const data = displayRows.value.map((row) => {
const exported = {};
props.columns.forEach((col) => {
exported[col.label] = row?.[col.key] ?? "";
});
return exported;
});
if (format === "csv") {
exportToCSV(data);
} else if (format === "xlsx") {
exportToXLSX(data);
}
}
function exportToCSV(data) {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csvContent = [
headers.join(","),
...data.map((row) =>
headers
.map((header) => {
const value = row[header];
if (value == null) return "";
const stringValue = String(value).replace(/"/g, '""');
return `"${stringValue}"`;
})
.join(",")
),
].join("\n");
const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `export_${new Date().toISOString().split("T")[0]}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportToXLSX(data) {
exportToCSV(data);
}
// Expose table instance and utilities for toolbar usage
defineExpose({
table,
internalSearch,
internalPageSize,
rowSelection,
handleSearchChange,
handlePageSizeChange,
handleExport,
exportToCSV,
exportToXLSX,
});
</script>
<template>
<div class="w-full space-y-4">
<!-- Toolbar Slot - Users can build their own toolbar using the table instance -->
<slot
name="toolbar"
:table="table"
:search="internalSearch"
:page-size="internalPageSize"
:row-selection="rowSelection"
/>
<!-- View Options -->
<div v-if="showViewOptions" class="flex items-center space-x-2">
<DataTableViewOptions :table="table" />
</div>
<!-- Table Container -->
<div data-table-container class="relative overflow-hidden">
<!-- Desktop Table View -->
<div v-if="!isMobile || !mobileCardView" class="overflow-x-auto">
<Table>
<TableHeader class="p-4">
<TableRow
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:class="[
header.column.columnDef.meta?.class,
header.column.columnDef.meta?.align === 'right'
? 'text-right'
: header.column.columnDef.meta?.align === 'center'
? 'text-center'
: 'text-left',
]"
>
<div v-if="!header.isPlaceholder">
<component :is="header.column.columnDef.header(header.getContext())" />
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<!-- Loading State -->
<template v-if="loading">
<TableRow>
<TableCell
:colspan="
columns.length +
(selectable ? 1 : 0) +
(showActions || slots.actions ? 1 : 0)
"
class="h-24 text-center"
>
<SkeletonTable :rows="5" :cols="columns.length" />
</TableCell>
</TableRow>
</template>
<!-- Empty State -->
<template v-else-if="!loading && table.getRowModel().rows.length === 0">
<TableRow>
<TableCell
:colspan="
columns.length +
(selectable ? 1 : 0) +
(showActions || slots.actions ? 1 : 0)
"
class="h-24 text-center"
>
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</TableCell>
</TableRow>
</template>
<!-- Rows -->
<template v-else>
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() && 'selected'"
:class="
cn(
hoverable && 'cursor-pointer',
striped && row.index % 2 === 1 && 'bg-muted/50'
)
"
@click="
(e) => {
const interactive = e.target.closest(
'button, a, [role=button], [data-dropdown], .relative, input[type=checkbox]'
);
if (interactive) return;
$emit('row:click', row.original, row.index);
}
"
>
<TableCell
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="[
cell.column.columnDef.meta?.class,
cell.column.columnDef.meta?.align === 'right'
? 'text-right'
: cell.column.columnDef.meta?.align === 'center'
? 'text-center'
: 'text-left',
]"
>
<template v-if="cell.column.id === 'actions'">
<slot name="actions" :row="row.original" :index="row.index">
<slot name="row-actions" :row="row.original" :index="row.index" />
</slot>
</template>
<template v-else>
<slot
:name="`cell-${cell.column.id}`"
:row="row.original"
:column="cell.column.columnDef"
:value="cell.getValue()"
:index="row.index"
>
<slot
name="cell"
:row="row.original"
:column="cell.column.columnDef"
:value="cell.getValue()"
:index="row.index"
>
<component :is="cell.column.columnDef.cell(cell.getContext())" />
</slot>
</slot>
</template>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
<!-- Mobile Card View -->
<div v-else-if="isMobile && mobileCardView" class="divide-y divide-gray-200">
<template v-if="loading">
<div class="p-4">
<SkeletonTable :rows="3" :cols="1" />
</div>
</template>
<template v-else-if="!loading && table.getRowModel().rows.length === 0">
<div class="p-6">
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</div>
</template>
<template v-else>
<div
v-for="row in table.getRowModel().rows"
:key="row.id"
@click="$emit('row:click', row.original, row.index)"
class="p-4 space-y-2 hover:bg-gray-50 transition-colors"
:class="{ 'cursor-pointer': !!$attrs.onRowClick }"
>
<slot name="mobile-card" :row="row.original" :index="row.index">
<!-- Default mobile card layout -->
<div
v-for="col in columns.slice(0, 3)"
:key="col.key"
class="flex justify-between items-start"
>
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">
{{ col.label }}
</span>
<span class="text-sm text-gray-900 text-right">
<slot
:name="`cell-${col.key}`"
:row="row.original"
:column="col"
:value="row.original?.[col.key]"
:index="row.index"
>
{{ row.original?.[col.key] ?? "—" }}
</slot>
</span>
</div>
<div
v-if="showActions || $slots.actions"
class="pt-2 border-t border-gray-100"
>
<slot name="actions" :row="row.original" :index="row.index">
<slot name="row-actions" :row="row.original" :index="row.index" />
</slot>
</div>
</slot>
</div>
</template>
</div>
</div>
<!-- Pagination -->
<div v-if="showPagination">
<!-- Use existing Pagination component for server-side -->
<template v-if="isServerSide && meta?.links">
<Pagination :links="meta.links" :from="from" :to="to" :total="total" />
</template>
<!-- TanStack Table Pagination for client-side -->
<template v-else>
<DataTablePagination :table="table" />
</template>
</div>
</div>
</template>
@@ -1,15 +1,13 @@
<script setup>
import { computed, ref, watch } from "vue";
import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue";
import EmptyState from "@/Components/EmptyState.vue";
import {
Table,
TableHeader,
TableHead,
TableBody,
TableRow,
TableCell,
} from "@/Components/ui/table";
FwbTable,
FwbTableHead,
FwbTableHeadCell,
FwbTableBody,
FwbTableRow,
FwbTableCell,
} from "flowbite-vue";
const props = defineProps({
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class?, formatter? }]
@@ -199,12 +197,11 @@ function setPageSize(ps) {
<div
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
>
<Table class="text-sm">
<TableHeader
<FwbTable hoverable striped class="text-sm">
<FwbTableHead
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
>
<TableRow class="border-b">
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
<FwbTableHeadCell v-for="col in columns" :key="col.key" :class="col.class">
<button
v-if="col.sortable"
type="button"
@@ -219,23 +216,23 @@ function setPageSize(ps) {
>
</button>
<span v-else>{{ col.label }}</span>
</TableHead>
<TableHead v-if="$slots.actions" class="w-px">&nbsp;</TableHead>
</TableRow>
</TableHeader>
</FwbTableHeadCell>
<FwbTableHeadCell v-if="$slots.actions" class="w-px">&nbsp;</FwbTableHeadCell>
</FwbTableHead>
<TableBody>
<FwbTableBody>
<template v-if="!loading && pageRows.length">
<TableRow
<FwbTableRow
v-for="(row, idx) in pageRows"
:key="keyOf(row)"
@click="$emit('row:click', row)"
class="cursor-default hover:bg-gray-50/50"
class="cursor-default"
>
<TableCell
<FwbTableCell
v-for="col in columns"
:key="col.key"
:class="col.class"
:align="col.align || 'left'"
>
<template v-if="$slots['cell-' + col.key]">
<slot
@@ -258,37 +255,33 @@ function setPageSize(ps) {
<template v-else>
{{ col.formatter ? col.formatter(row) : row?.[col.key] ?? "" }}
</template>
</TableCell>
<TableCell v-if="$slots.actions" class="w-px text-right">
</FwbTableCell>
<FwbTableCell v-if="$slots.actions" class="w-px text-right">
<slot name="actions" :row="row" :index="idx" />
</TableCell>
</TableRow>
</FwbTableCell>
</FwbTableRow>
</template>
<template v-else-if="loading">
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<SkeletonTable :rows="5" :cols="columns.length" />
</TableCell>
</TableRow>
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<div class="p-6 text-center text-gray-500">Nalagam...</div>
</FwbTableCell>
</FwbTableRow>
</template>
<template v-else>
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<slot name="empty">
<EmptyState
:title="emptyText"
size="sm"
/>
<div class="p-6 text-center text-gray-500">{{ emptyText }}</div>
</slot>
</TableCell>
</TableRow>
</FwbTableCell>
</FwbTableRow>
</template>
</TableBody>
</Table>
</FwbTableBody>
</FwbTable>
</div>
<nav
v-if="showPagination"
class="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm text-gray-700"
aria-label="Pagination"
>
@@ -1,72 +0,0 @@
<script setup>
import { h } from 'vue';
import { ArrowUpDown, ArrowUp, ArrowDown, EyeOff } from 'lucide-vue-next';
import { Button } from '@/Components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
const props = defineProps({
column: {
type: Object,
required: true,
},
title: {
type: String,
required: true,
},
class: {
type: [String, Object, Array],
default: null,
},
});
const getSortIcon = (column) => {
if (!column.getIsSorted()) return ArrowUpDown;
return column.getIsSorted() === 'asc' ? ArrowUp : ArrowDown;
};
</script>
<template>
<div v-if="column.getCanSort()" :class="cn('flex items-center space-x-2', props.class)">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
class="-ml-3 h-8 data-[state=open]:bg-accent"
>
<span>{{ title }}</span>
<component
:is="getSortIcon(column)"
class="ml-2 h-4 w-4"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="column.toggleSorting(false)">
<ArrowUp class="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Asc
</DropdownMenuItem>
<DropdownMenuItem @click="column.toggleSorting(true)">
<ArrowDown class="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Desc
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="column.toggleVisibility(false)">
<EyeOff class="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Hide
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div v-else :class="cn('', props.class)">
{{ title }}
</div>
</template>
@@ -1,708 +0,0 @@
<script setup>
import { ref, computed, watch, h } from 'vue';
import { router } from '@inertiajs/vue3';
import {
useVueTable,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
} from '@tanstack/vue-table';
import DataTableToolbar from './DataTableToolbar.vue';
import DataTableColumnHeader from './DataTableColumnHeader.vue';
import DataTablePagination from './DataTablePagination.vue';
import DataTableViewOptions from './DataTableViewOptions.vue';
import SkeletonTable from '../Skeleton/SkeletonTable.vue';
import EmptyState from '../EmptyState.vue';
import Pagination from '../Pagination.vue';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/Components/ui/table';
import Checkbox from '@/Components/ui/checkbox/Checkbox.vue';
import { cn } from '@/lib/utils';
const props = defineProps({
// Data
rows: { type: Array, default: () => [] },
columns: {
type: Array,
required: true,
validator: (cols) =>
cols.every(
(col) => col.key && col.label && typeof col.key === 'string' && typeof col.label === 'string'
),
},
// Pagination (for server-side)
meta: {
type: Object,
default: null,
validator: (meta) =>
!meta ||
(typeof meta.current_page === 'number' &&
typeof meta.per_page === 'number' &&
typeof meta.total === 'number' &&
typeof meta.last_page === 'number'),
},
// Sorting
sort: {
type: Object,
default: () => ({ key: null, direction: null }),
},
// Search
search: { type: String, default: '' },
// Loading state
loading: { type: Boolean, default: false },
// Client-side pagination (when meta is null)
pageSize: { type: Number, default: 10 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
// Routing (for server-side)
routeName: { type: String, default: null },
routeParams: { type: Object, default: () => ({}) },
pageParamName: { type: String, default: 'page' },
onlyProps: { type: Array, default: () => [] },
// Features
showToolbar: { type: Boolean, default: true },
showSearch: { type: Boolean, default: false },
showPageSize: { type: Boolean, default: false },
showPagination: { type: Boolean, default: true },
showFilters: { type: Boolean, default: false },
showExport: { type: Boolean, default: false },
showAdd: { type: Boolean, default: false },
showOptions: { type: Boolean, default: false },
showSelectedCount: { type: Boolean, default: false },
showOptionsMenu: { type: Boolean, default: false },
showViewOptions: { type: Boolean, default: false },
compactToolbar: { type: Boolean, default: false },
hasActiveFilters: { type: Boolean, default: false },
rowKey: { type: [String, Function], default: 'uuid' },
selectable: { type: Boolean, default: false },
striped: { type: Boolean, default: false },
hoverable: { type: Boolean, default: true },
// Empty state
emptyText: { type: String, default: 'Ni podatkov' },
emptyIcon: { type: [String, Object, Array], default: null },
emptyDescription: { type: String, default: null },
// Actions
showActions: { type: Boolean, default: false },
actionsPosition: { type: String, default: 'right', validator: (v) => ['left', 'right'].includes(v) },
// Mobile
mobileCardView: { type: Boolean, default: true },
mobileBreakpoint: { type: Number, default: 768 },
// State preservation
preserveState: { type: Boolean, default: true },
preserveScroll: { type: Boolean, default: true },
});
const emit = defineEmits([
'update:search',
'update:sort',
'update:page',
'update:pageSize',
'row:click',
'row:select',
'selection:change',
]);
// Determine if this is server-side (has meta and routeName)
const isServerSide = computed(() => !!(props.meta && props.routeName));
const isClientSide = computed(() => !isServerSide.value);
// Row key helper
function keyOf(row) {
if (typeof props.rowKey === 'function') return props.rowKey(row);
if (typeof props.rowKey === 'string' && row && row[props.rowKey] != null)
return row[props.rowKey];
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
}
// Convert simple column format to TanStack Table ColumnDef format
const columnDefinitions = computed(() => {
return props.columns.map((col) => ({
accessorKey: col.key,
id: col.key,
header: ({ column }) => {
return h(DataTableColumnHeader, {
column,
title: col.label,
class: col.class,
});
},
cell: ({ row, getValue }) => {
return getValue();
},
enableSorting: col.sortable !== false,
enableHiding: col.hideable !== false,
meta: {
align: col.align || 'left',
class: col.class,
},
}));
});
// Add selection column if selectable
const columnsWithSelection = computed(() => {
if (!props.selectable) return columnDefinitions.value;
return [
{
id: 'select',
enableHiding: false,
enableSorting: false,
header: ({ table }) => {
return h(Checkbox, {
modelValue: table.getIsAllPageRowsSelected(),
indeterminate: table.getIsSomePageRowsSelected(),
'onUpdate:modelValue': (value) => table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all',
});
},
cell: ({ row }) => {
return h(Checkbox, {
modelValue: row.getIsSelected(),
'onUpdate:modelValue': (value) => row.toggleSelected(!!value),
'aria-label': 'Select row',
});
},
},
...columnDefinitions.value,
];
});
// Add actions column if showActions
const finalColumns = computed(() => {
if (!props.showActions && !props.$slots.actions) return columnsWithSelection.value;
return [
...columnsWithSelection.value,
{
id: 'actions',
enableHiding: false,
enableSorting: false,
header: () => h('span', { class: 'sr-only' }, 'Actions'),
cell: ({ row }) => {
// Actions will be rendered via slot
return null;
},
},
];
});
// Internal search state
const internalSearch = ref(props.search);
watch(
() => props.search,
(newVal) => {
internalSearch.value = newVal;
}
);
// Internal sorting state
const sorting = computed(() => {
if (!props.sort?.key || !props.sort?.direction) return [];
return [
{
id: props.sort.key,
desc: props.sort.direction === 'desc',
},
];
});
// Internal pagination state
const pagination = computed(() => {
if (isServerSide.value) {
return {
pageIndex: (props.meta?.current_page ?? 1) - 1,
pageSize: props.meta?.per_page ?? props.pageSize,
};
}
return {
pageIndex: internalPage.value - 1,
pageSize: internalPageSize.value,
};
});
const internalPage = ref(1);
const internalPageSize = ref(props.pageSize);
// Row selection
const rowSelection = ref({});
// Create TanStack Table instance
const table = useVueTable({
get data() {
return props.rows;
},
get columns() {
return finalColumns.value;
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: isClientSide.value ? getPaginationRowModel() : undefined,
getSortedRowModel: isClientSide.value ? getSortedRowModel() : undefined,
getFilteredRowModel: isClientSide.value ? getFilteredRowModel() : undefined,
onSortingChange: (updater) => {
const newSorting = typeof updater === 'function' ? updater(sorting.value) : updater;
if (newSorting.length > 0) {
const sort = newSorting[0];
emit('update:sort', {
key: sort.id,
direction: sort.desc ? 'desc' : 'asc',
});
if (isServerSide.value) {
doServerRequest({
sort: sort.id,
direction: sort.desc ? 'desc' : 'asc',
page: 1,
});
}
} else {
emit('update:sort', { key: null, direction: null });
if (isServerSide.value) {
doServerRequest({ sort: null, direction: null, page: 1 });
}
}
},
onPaginationChange: (updater) => {
const newPagination = typeof updater === 'function' ? updater(pagination.value) : updater;
if (isServerSide.value) {
doServerRequest({ page: newPagination.pageIndex + 1 });
} else {
internalPage.value = newPagination.pageIndex + 1;
emit('update:page', newPagination.pageIndex + 1);
}
internalPageSize.value = newPagination.pageSize;
emit('update:pageSize', newPagination.pageSize);
},
onRowSelectionChange: (updater) => {
const newSelection = typeof updater === 'function' ? updater(rowSelection.value) : updater;
rowSelection.value = newSelection;
const selectedKeys = Object.keys(newSelection).filter((key) => newSelection[key]);
emit('selection:change', selectedKeys);
},
manualSorting: isServerSide.value,
manualPagination: isServerSide.value,
manualFiltering: isServerSide.value,
enableRowSelection: props.selectable,
state: {
get sorting() {
return sorting.value;
},
get pagination() {
return pagination.value;
},
get rowSelection() {
return rowSelection.value;
},
get globalFilter() {
return internalSearch.value;
},
},
});
// Server-side request
function doServerRequest(overrides = {}) {
const existingParams = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
const q = {
...existingParams,
per_page: overrides.perPage ?? props.meta?.per_page ?? internalPageSize.value,
sort: overrides.sort ?? props.sort?.key ?? existingParams.sort ?? null,
direction: overrides.direction ?? props.sort?.direction ?? existingParams.direction ?? null,
search: overrides.search ?? internalSearch.value ?? existingParams.search ?? '',
};
const pageParam = props.pageParamName || 'page';
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
if (pageParam !== 'page') {
delete q.page;
}
Object.keys(q).forEach((k) => {
if (q[k] === null || q[k] === undefined || q[k] === '') delete q[k];
});
const url = route(props.routeName, props.routeParams || {});
router.get(
url,
q,
{
preserveScroll: props.preserveScroll,
preserveState: props.preserveState,
replace: true,
only: props.onlyProps.length ? props.onlyProps : undefined,
}
);
}
function handleSearchChange(value) {
internalSearch.value = value;
emit('update:search', value);
if (isServerSide.value) {
clearTimeout(searchTimer.value);
searchTimer.value = setTimeout(() => {
doServerRequest({ search: value, page: 1 });
}, 300);
}
}
function handlePageSizeChange(size) {
const newSize = Number(size);
internalPageSize.value = newSize;
emit('update:pageSize', newSize);
if (isServerSide.value) {
doServerRequest({ perPage: newSize, page: 1 });
} else {
table.setPageSize(newSize);
}
}
const searchTimer = ref(null);
// Mobile detection
const isMobile = ref(false);
if (typeof window !== 'undefined') {
const checkMobile = () => {
isMobile.value = window.innerWidth < props.mobileBreakpoint;
};
checkMobile();
window.addEventListener('resize', checkMobile);
}
// Display rows
const displayRows = computed(() => {
if (isServerSide.value) return props.rows;
return table.getRowModel().rows.map((row) => row.original);
});
const total = computed(() => {
if (isServerSide.value) return props.meta?.total ?? 0;
return table.getFilteredRowModel().rows.length;
});
const from = computed(() => {
if (isServerSide.value) return props.meta?.from ?? 0;
const pageIndex = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return total.value === 0 ? 0 : pageIndex * pageSize + 1;
});
const to = computed(() => {
if (isServerSide.value) return props.meta?.to ?? 0;
const pageIndex = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return Math.min((pageIndex + 1) * pageSize, total.value);
});
// Export functionality
function handleExport(format) {
const data = displayRows.value.map((row) => {
const exported = {};
props.columns.forEach((col) => {
exported[col.label] = row?.[col.key] ?? '';
});
return exported;
});
if (format === 'csv') {
exportToCSV(data);
} else if (format === 'xlsx') {
exportToXLSX(data);
}
}
function exportToCSV(data) {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csvContent = [
headers.join(','),
...data.map((row) =>
headers
.map((header) => {
const value = row[header];
if (value == null) return '';
const stringValue = String(value).replace(/"/g, '""');
return `"${stringValue}"`;
})
.join(',')
),
].join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `export_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportToXLSX(data) {
exportToCSV(data);
}
</script>
<template>
<div class="w-full space-y-4">
<!-- Toolbar -->
<DataTableToolbar
v-if="showToolbar"
:search="internalSearch"
:show-search="showSearch"
:show-page-size="showPageSize"
:page-size="internalPageSize"
:page-size-options="pageSizeOptions"
:selected-count="Object.keys(rowSelection).filter((key) => rowSelection[key]).length"
:show-selected-count="showSelectedCount"
:show-export="showExport"
:show-add="showAdd"
:show-options="showOptions"
:show-filters="showFilters"
:show-options-menu="showOptionsMenu"
:has-active-filters="hasActiveFilters"
:compact="compactToolbar"
@update:search="handleSearchChange"
@update:page-size="handlePageSizeChange"
@export="handleExport"
>
<template #add>
<slot name="toolbar-add" />
</template>
<template #options>
<slot name="toolbar-options" />
</template>
<template #filters>
<slot name="toolbar-filters" />
</template>
<template #actions>
<slot name="toolbar-actions" />
</template>
</DataTableToolbar>
<!-- View Options -->
<div v-if="showViewOptions" class="flex items-center space-x-2">
<DataTableViewOptions :table="table" />
</div>
<!-- Table Container -->
<div data-table-container class="relative overflow-hidden">
<!-- Desktop Table View -->
<div v-if="!isMobile || !mobileCardView" class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:class="[
header.column.columnDef.meta?.class,
header.column.columnDef.meta?.align === 'right' ? 'text-right' :
header.column.columnDef.meta?.align === 'center' ? 'text-center' : 'text-left',
]"
>
<div v-if="!header.isPlaceholder">
<component
:is="flexRender(header.column.columnDef.header, header.getContext())"
/>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<!-- Loading State -->
<template v-if="loading">
<TableRow>
<TableCell
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
class="h-24 text-center"
>
<SkeletonTable :rows="5" :cols="columns.length" />
</TableCell>
</TableRow>
</template>
<!-- Empty State -->
<template v-else-if="!loading && table.getRowModel().rows.length === 0">
<TableRow>
<TableCell
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
class="h-24 text-center"
>
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</TableCell>
</TableRow>
</template>
<!-- Rows -->
<template v-else>
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() && 'selected'"
:class="cn(
hoverable && 'cursor-pointer',
striped && row.index % 2 === 1 && 'bg-muted/50',
)"
@click="(e) => {
const interactive = e.target.closest('button, a, [role=button], [data-dropdown], .relative, input[type=checkbox]');
if (interactive) return;
$emit('row:click', row.original, row.index);
}"
>
<TableCell
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="[
cell.column.columnDef.meta?.class,
cell.column.columnDef.meta?.align === 'right' ? 'text-right' :
cell.column.columnDef.meta?.align === 'center' ? 'text-center' : 'text-left',
]"
>
<template v-if="cell.column.id === 'actions'">
<slot name="actions" :row="row.original" :index="row.index">
<slot name="row-actions" :row="row.original" :index="row.index" />
</slot>
</template>
<template v-else>
<slot
:name="`cell-${cell.column.id}`"
:row="row.original"
:column="cell.column.columnDef"
:value="cell.getValue()"
:index="row.index"
>
<slot
name="cell"
:row="row.original"
:column="cell.column.columnDef"
:value="cell.getValue()"
:index="row.index"
>
<component
:is="flexRender(cell.column.columnDef.cell, cell.getContext())"
/>
</slot>
</slot>
</template>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
<!-- Mobile Card View -->
<div v-else-if="isMobile && mobileCardView" class="divide-y divide-gray-200">
<template v-if="loading">
<div class="p-4">
<SkeletonTable :rows="3" :cols="1" />
</div>
</template>
<template v-else-if="!loading && table.getRowModel().rows.length === 0">
<div class="p-6">
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</div>
</template>
<template v-else>
<div
v-for="row in table.getRowModel().rows"
:key="row.id"
@click="$emit('row:click', row.original, row.index)"
class="p-4 space-y-2 hover:bg-gray-50 transition-colors"
:class="{ 'cursor-pointer': !!$attrs.onRowClick }"
>
<slot name="mobile-card" :row="row.original" :index="row.index">
<!-- Default mobile card layout -->
<div
v-for="col in columns.slice(0, 3)"
:key="col.key"
class="flex justify-between items-start"
>
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">
{{ col.label }}
</span>
<span class="text-sm text-gray-900 text-right">
<slot
:name="`cell-${col.key}`"
:row="row.original"
:column="col"
:value="row.original?.[col.key]"
:index="row.index"
>
{{ row.original?.[col.key] ?? '—' }}
</slot>
</span>
</div>
<div v-if="showActions || $slots.actions" class="pt-2 border-t border-gray-100">
<slot name="actions" :row="row.original" :index="row.index">
<slot name="row-actions" :row="row.original" :index="row.index" />
</slot>
</div>
</slot>
</div>
</template>
</div>
</div>
<!-- Pagination -->
<div v-if="showPagination">
<!-- Use existing Pagination component for server-side -->
<template v-if="isServerSide && meta?.links">
<Pagination
:links="meta.links"
:from="from"
:to="to"
:total="total"
:current-page="meta.current_page"
:last-page="meta.last_page"
:per-page="meta.per_page"
:page-param="pageParamName"
:per-page-param="'per_page'"<!-- legacy component may not have custom per-page name prop -->
/>
</template>
<!-- TanStack Table Pagination for client-side -->
<template v-else>
<DataTablePagination :table="table" />
</template>
</div>
</div>
</template>
@@ -1,608 +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 || {});
router.get(url, q, {
preserveScroll: props.preserveScroll,
preserveState: props.preserveState,
replace: true,
only: props.onlyProps.length ? props.onlyProps : undefined,
});
}
// Row key helper
function keyOf(row) {
if (typeof props.rowKey === "function") return props.rowKey(row);
if (typeof props.rowKey === "string" && row && row[props.rowKey] != null)
return row[props.rowKey];
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
}
</script>
<template>
<div class="w-full">
<!-- Toolbar -->
<DataTableToolbar
v-if="showToolbar"
:table="table"
:filter-column="filterColumn"
:filter-placeholder="filterPlaceholder"
:show-per-page-selector="isServerSide"
:per-page="pagination.pageSize"
:page-size-options="pageSizeOptions"
@update:per-page="(value) => table.setPageSize(value)"
class="p-2 border-t"
>
<template #filters="slotProps">
<slot name="toolbar-filters" v-bind="slotProps" />
</template>
<template #actions="slotProps">
<slot name="toolbar-actions" v-bind="slotProps" />
</template>
</DataTableToolbar>
<!-- Custom toolbar slot for full control -->
<slot name="toolbar" :table="table" />
<!-- Table -->
<div class="border-t">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:class="[
'p-3',
header.column.columnDef.meta?.class,
header.column.columnDef.meta?.align === 'right'
? 'text-right'
: header.column.columnDef.meta?.align === 'center'
? 'text-center'
: 'text-left',
'bg-muted/50',
]"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<!-- Loading State -->
<template v-if="loading">
<TableRow>
<TableCell :colspan="table.getAllColumns().length" class="h-24 text-center">
<SkeletonTable :rows="5" :cols="columns.length" />
</TableCell>
</TableRow>
</template>
<!-- Empty State -->
<template v-else-if="table.getRowModel().rows.length === 0">
<TableRow>
<TableCell :colspan="table.getAllColumns().length" class="h-24 text-center">
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</TableCell>
</TableRow>
</template>
<!-- Data Rows -->
<template v-else>
<TableRow
v-for="row in table.getRowModel().rows"
:key="keyOf(row.original)"
:data-state="row.getIsSelected() && 'selected'"
:class="
cn(
hoverable && 'cursor-pointer hover:bg-muted/50',
striped && row.index % 2 === 1 && 'bg-muted/50'
)
"
@click="$emit('row:click', row.original, row.index)"
>
<TableCell
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="[
cell.column.columnDef.meta?.class,
cell.column.columnDef.meta?.align === 'right'
? 'text-right'
: cell.column.columnDef.meta?.align === 'center'
? 'text-center'
: 'text-left',
'p-3',
]"
>
<!-- Use slot if provided -->
<slot
:name="`cell-${cell.column.id}`"
:row="row.original"
:column="cell.column"
:value="cell.getValue()"
:index="row.index"
>
<!-- Otherwise use FlexRender -->
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</slot>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
<!-- Pagination -->
<div v-if="showPagination" class="border-t border-gray-200 p-4">
<!-- Server-side pagination -->
<template v-if="isServerSide && meta?.links">
<Pagination
:links="meta.links"
:from="meta.from"
:to="meta.to"
:total="meta.total"
:current-page="meta.current_page"
:last-page="meta.last_page"
:per-page="meta.per_page"
:page-param="pageParamName"
:per-page-param="perPageParamName"
/>
</template>
<!-- Client-side pagination -->
<template v-else>
<DataTablePagination :table="table" />
</template>
</div>
</div>
</template>
@@ -1,884 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { router } from '@inertiajs/vue3';
import DataTableToolbar from './DataTableToolbar.vue';
import SkeletonTable from '../Skeleton/SkeletonTable.vue';
import EmptyState from '../EmptyState.vue';
import Pagination from '../Pagination.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import {
faChevronLeft,
faChevronRight,
faSort,
faSortUp,
faSortDown,
} from '@fortawesome/free-solid-svg-icons';
const props = defineProps({
// Data
rows: { type: Array, default: () => [] },
columns: {
type: Array,
required: true,
validator: (cols) =>
cols.every(
(col) => col.key && col.label && typeof col.key === 'string' && typeof col.label === 'string'
),
},
// Pagination (for server-side)
meta: {
type: Object,
default: null,
validator: (meta) =>
!meta ||
(typeof meta.current_page === 'number' &&
typeof meta.per_page === 'number' &&
typeof meta.total === 'number' &&
typeof meta.last_page === 'number'),
},
// Sorting
sort: {
type: Object,
default: () => ({ key: null, direction: null }),
},
// Search
search: { type: String, default: '' },
// Loading state
loading: { type: Boolean, default: false },
// Client-side pagination (when meta is null)
pageSize: { type: Number, default: 10 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
// Routing (for server-side)
routeName: { type: String, default: null },
routeParams: { type: Object, default: () => ({}) },
pageParamName: { type: String, default: 'page' },
onlyProps: { type: Array, default: () => [] },
// Features
showToolbar: { type: Boolean, default: true },
showSearch: { type: Boolean, default: false },
showPageSize: { type: Boolean, default: false },
showPagination: { type: Boolean, default: true },
showFilters: { type: Boolean, default: false },
showExport: { type: Boolean, default: false },
showAdd: { type: Boolean, default: false }, // Show add buttons dropdown
showOptions: { type: Boolean, default: false }, // Show custom options slot
showSelectedCount: { type: Boolean, default: false }, // Show selected count badge
showOptionsMenu: { type: Boolean, default: false }, // Show options menu (three dots)
compactToolbar: { type: Boolean, default: false }, // Compact mode: move search/page size to menu
hasActiveFilters: { type: Boolean, default: false }, // External indicator for active filters
rowKey: { type: [String, Function], default: 'uuid' },
selectable: { type: Boolean, default: false },
striped: { type: Boolean, default: false },
hoverable: { type: Boolean, default: true },
// Empty state
emptyText: { type: String, default: 'Ni podatkov' },
emptyIcon: { type: [String, Object, Array], default: null },
emptyDescription: { type: String, default: null },
// Actions
showActions: { type: Boolean, default: false },
actionsPosition: { type: String, default: 'right', validator: (v) => ['left', 'right'].includes(v) },
// Mobile
mobileCardView: { type: Boolean, default: true },
mobileBreakpoint: { type: Number, default: 768 }, // Tailwind md breakpoint
// State preservation
preserveState: { type: Boolean, default: true },
preserveScroll: { type: Boolean, default: true },
});
const emit = defineEmits([
'update:search',
'update:sort',
'update:page',
'update:pageSize',
'row:click',
'row:select',
'selection:change',
]);
// Determine if this is server-side (has meta and routeName)
const isServerSide = computed(() => !!(props.meta && props.routeName));
const isClientSide = computed(() => !isServerSide.value);
// Row key helper
function keyOf(row) {
if (typeof props.rowKey === 'function') return props.rowKey(row);
if (typeof props.rowKey === 'string' && row && row[props.rowKey] != null)
return row[props.rowKey];
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
}
// Client-side sorting
const sortedRows = computed(() => {
if (isServerSide.value || !props.sort?.key || !props.sort?.direction) {
return props.rows;
}
const key = props.sort.key;
const direction = props.sort.direction;
const sorted = [...props.rows];
sorted.sort((a, b) => {
let aVal = a?.[key];
let bVal = b?.[key];
// Handle nulls/undefined
if (aVal == null) return 1;
if (bVal == null) return -1;
// Handle dates
if (aVal instanceof Date || (typeof aVal === 'string' && aVal.match(/\d{4}-\d{2}-\d{2}/))) {
aVal = new Date(aVal);
bVal = new Date(bVal);
}
// Handle numbers
if (typeof aVal === 'number' && typeof bVal === 'number') {
return direction === 'asc' ? aVal - bVal : bVal - aVal;
}
// Handle strings
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (direction === 'asc') {
return aStr.localeCompare(bStr);
}
return bStr.localeCompare(aStr);
});
return sorted;
});
// Client-side pagination
const currentPage = computed(() => {
if (isServerSide.value) return props.meta?.current_page ?? 1;
return internalPage.value;
});
const internalPage = ref(1);
// Use computed for pageSize to always reflect the correct value
// For server-side: use meta.per_page, for client-side: use internal state or props.pageSize
const internalPageSize = computed({
get: () => {
if (isServerSide.value && props.meta?.per_page) {
return props.meta.per_page;
}
return internalPageSizeState.value ?? props.pageSize;
},
set: (value) => {
internalPageSizeState.value = value;
}
});
// Internal state for client-side or when user changes page size before server responds
const internalPageSizeState = ref(null);
const totalPages = computed(() => {
if (isServerSide.value) return props.meta?.last_page ?? 1;
return Math.ceil(sortedRows.value.length / internalPageSize.value);
});
const paginatedRows = computed(() => {
if (isServerSide.value) return props.rows;
const start = (currentPage.value - 1) * internalPageSize.value;
const end = start + internalPageSize.value;
return sortedRows.value.slice(start, end);
});
// Client-side search
const filteredRows = computed(() => {
if (isServerSide.value || !internalSearch.value) {
return paginatedRows.value;
}
const searchTerm = internalSearch.value.toLowerCase();
return paginatedRows.value.filter((row) => {
return props.columns.some((col) => {
const value = row?.[col.key];
if (value == null) return false;
return String(value).toLowerCase().includes(searchTerm);
});
});
});
// Search handling
const internalSearch = ref(props.search);
watch(
() => props.search,
(newVal) => {
internalSearch.value = newVal;
}
);
// Selection
const selectedRows = ref(new Set());
const isAllSelected = computed(() => {
if (filteredRows.value.length === 0) return false;
return filteredRows.value.every((row) => selectedRows.value.has(keyOf(row)));
});
const isSomeSelected = computed(() => {
return (
selectedRows.value.size > 0 &&
filteredRows.value.some((row) => selectedRows.value.has(keyOf(row)))
);
});
function toggleSelectAll() {
if (isAllSelected.value) {
filteredRows.value.forEach((row) => {
selectedRows.value.delete(keyOf(row));
});
} else {
filteredRows.value.forEach((row) => {
selectedRows.value.add(keyOf(row));
});
}
emit('selection:change', Array.from(selectedRows.value));
}
function toggleSelectRow(row) {
const key = keyOf(row);
if (selectedRows.value.has(key)) {
selectedRows.value.delete(key);
} else {
selectedRows.value.add(key);
}
emit('row:select', row, selectedRows.value.has(key));
emit('selection:change', Array.from(selectedRows.value));
}
// Sorting
function toggleSort(col) {
if (!col.sortable) return;
if (isServerSide.value) {
const current = props.sort || { key: null, direction: null };
let direction = 'asc';
if (current.key === col.key) {
direction =
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
}
emit('update:sort', { key: direction ? col.key : null, direction });
doServerRequest({ sort: direction ? col.key : null, direction, page: 1 });
} else {
const current = props.sort || { key: null, direction: null };
let direction = 'asc';
if (current.key === col.key) {
direction =
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
}
emit('update:sort', { key: direction ? col.key : null, direction });
}
}
function getSortIcon(col) {
if (props.sort?.key !== col.key) return faSort;
if (props.sort?.direction === 'asc') return faSortUp;
if (props.sort?.direction === 'desc') return faSortDown;
return faSort;
}
// Server-side request
function doServerRequest(overrides = {}) {
// Preserve existing query parameters from URL
const existingParams = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
const q = {
...existingParams, // Preserve all existing query parameters
per_page: overrides.perPage ?? props.meta?.per_page ?? internalPageSizeState.value ?? internalPageSize.value,
sort: overrides.sort ?? props.sort?.key ?? existingParams.sort ?? null,
direction: overrides.direction ?? props.sort?.direction ?? existingParams.direction ?? null,
search: overrides.search ?? internalSearch.value ?? existingParams.search ?? '',
};
const pageParam = props.pageParamName || 'page';
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
if (pageParam !== 'page') {
delete q.page;
}
// Clean nulls and empty strings
Object.keys(q).forEach((k) => {
if (q[k] === null || q[k] === undefined || q[k] === '') delete q[k];
});
const url = route(props.routeName, props.routeParams || {});
router.get(
url,
q,
{
preserveScroll: props.preserveScroll,
preserveState: props.preserveState,
replace: true,
only: props.onlyProps.length ? props.onlyProps : undefined,
onSuccess: () => {
// Scroll to top of table after server request completes
setTimeout(() => {
const tableElement = document.querySelector('[data-table-container]');
if (tableElement) {
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, 100);
},
}
);
}
function handleSearchChange(value) {
internalSearch.value = value;
emit('update:search', value);
if (isServerSide.value) {
clearTimeout(searchTimer.value);
searchTimer.value = setTimeout(() => {
doServerRequest({ search: value, page: 1 });
}, 300);
}
}
function handlePageSizeChange(size) {
const newSize = Number(size);
internalPageSizeState.value = newSize;
emit('update:pageSize', newSize);
if (isServerSide.value) {
// Reset to page 1 when changing page size to avoid being on a non-existent page
doServerRequest({ perPage: newSize, page: 1 });
} else {
// Calculate total pages with new size
const newTotalPages = Math.ceil(sortedRows.value.length / newSize);
// If current page exceeds new total, reset to last page or page 1
const targetPage = currentPage.value > newTotalPages && newTotalPages > 0 ? newTotalPages : 1;
internalPage.value = targetPage;
emit('update:page', targetPage);
}
}
function handlePageChange(page) {
if (isServerSide.value) {
doServerRequest({ page });
} else {
internalPage.value = page;
emit('update:page', page);
}
// Scroll to top of table after page change
setTimeout(() => {
const tableElement = document.querySelector('[data-table-container]');
if (tableElement) {
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, isServerSide.value ? 100 : 50);
}
const searchTimer = ref(null);
// Mobile detection
const isMobile = ref(false);
if (typeof window !== 'undefined') {
const checkMobile = () => {
isMobile.value = window.innerWidth < props.mobileBreakpoint;
};
checkMobile();
window.addEventListener('resize', checkMobile);
}
// Display rows
const displayRows = computed(() => {
if (isServerSide.value || !internalSearch.value) {
return paginatedRows.value;
}
return filteredRows.value;
});
const total = computed(() => {
if (isServerSide.value) return props.meta?.total ?? 0;
return internalSearch.value ? filteredRows.value.length : sortedRows.value.length;
});
const from = computed(() => {
if (isServerSide.value) return props.meta?.from ?? 0;
return total.value === 0 ? 0 : (currentPage.value - 1) * internalPageSize.value + 1;
});
const to = computed(() => {
if (isServerSide.value) return props.meta?.to ?? 0;
return Math.min(currentPage.value * internalPageSize.value, total.value);
});
// Export functionality
function handleExport(format) {
const data = displayRows.value.map((row) => {
const exported = {};
props.columns.forEach((col) => {
exported[col.label] = row?.[col.key] ?? '';
});
return exported;
});
if (format === 'csv') {
exportToCSV(data);
} else if (format === 'xlsx') {
exportToXLSX(data);
}
}
function exportToCSV(data) {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csvContent = [
headers.join(','),
...data.map((row) =>
headers
.map((header) => {
const value = row[header];
if (value == null) return '';
const stringValue = String(value).replace(/"/g, '""');
return `"${stringValue}"`;
})
.join(',')
),
].join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `export_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportToXLSX(data) {
// For XLSX, we'll use a CSV-like format or alert user to install xlsx library
// Simple implementation: use CSV format with .xlsx extension
exportToCSV(data);
// In production, you might want to use a library like 'xlsx' or 'exceljs'
}
// Generate visible page numbers with ellipsis
function getVisiblePages() {
const pages = [];
const total = totalPages.value;
const current = currentPage.value;
const maxVisible = 7;
if (total <= maxVisible) {
for (let i = 1; i <= total; i++) {
pages.push(i);
}
return pages;
}
// Always show first page
pages.push(1);
// Calculate start and end
let start = Math.max(2, current - 1);
let end = Math.min(total - 1, current + 1);
// Adjust if near start
if (current <= 3) {
end = Math.min(4, total - 1);
}
// Adjust if near end
if (current >= total - 2) {
start = Math.max(2, total - 3);
}
// Add ellipsis after first page if needed
if (start > 2) {
pages.push('...');
}
// Add middle pages
for (let i = start; i <= end; i++) {
pages.push(i);
}
// Add ellipsis before last page if needed
if (end < total - 1) {
pages.push('...');
}
// Always show last page
if (total > 1) {
pages.push(total);
}
return pages;
}
</script>
<template>
<div class="w-full space-y-4">
<!-- Toolbar -->
<DataTableToolbar
v-if="showToolbar"
:search="internalSearch"
:show-search="showSearch"
:show-page-size="showPageSize"
:page-size="internalPageSize"
:page-size-options="pageSizeOptions"
:selected-count="selectedRows.size"
:show-selected-count="showSelectedCount"
:show-export="showExport"
:show-add="showAdd"
:show-options="showOptions"
:show-filters="showFilters"
:show-options-menu="showOptionsMenu"
:has-active-filters="hasActiveFilters"
:compact="compactToolbar"
@update:search="handleSearchChange"
@update:page-size="handlePageSizeChange"
@export="handleExport"
>
<template #add>
<slot name="toolbar-add" />
</template>
<template #options>
<slot name="toolbar-options" />
</template>
<template #filters>
<slot name="toolbar-filters" />
</template>
<template #actions>
<slot name="toolbar-actions" />
</template>
</DataTableToolbar>
<!-- Table Container -->
<div
data-table-container
class="relative overflow-hidden"
>
<!-- Desktop Table View -->
<div v-if="!isMobile || !mobileCardView" class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<!-- Header -->
<thead>
<tr>
<!-- Select All Checkbox -->
<th
v-if="selectable"
class="w-12 px-6 py-3 text-left"
scope="col"
>
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isSomeSelected && !isAllSelected"
@change="toggleSelectAll"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</th>
<!-- Column Headers -->
<th
v-for="col in columns"
:key="col.key"
scope="col"
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
:class="[
col.class,
col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : 'text-left',
]"
>
<button
v-if="col.sortable"
type="button"
class="group inline-flex items-center gap-1.5 hover:text-gray-700 transition-colors"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
<span>{{ col.label }}</span>
<FontAwesomeIcon
:icon="getSortIcon(col)"
class="w-3 h-3 transition-colors"
:class="{
'text-gray-700': sort?.key === col.key,
'text-gray-400 group-hover:text-gray-500': sort?.key !== col.key,
}"
/>
</button>
<span v-else>{{ col.label }}</span>
</th>
<!-- Actions Column -->
<th
v-if="showActions || $slots.actions"
scope="col"
class="relative w-px px-6 py-3"
>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<!-- Body -->
<tbody class="divide-y divide-gray-200 bg-white">
<!-- Loading State -->
<template v-if="loading">
<tr>
<td :colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)" class="px-6 py-4">
<SkeletonTable :rows="5" :cols="columns.length" />
</td>
</tr>
</template>
<!-- Empty State -->
<template v-else-if="!loading && displayRows.length === 0">
<tr>
<td
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
class="px-6 py-12 text-center"
>
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</td>
</tr>
</template>
<!-- Rows -->
<template v-else>
<tr
v-for="(row, idx) in displayRows"
:key="keyOf(row)"
@click="(e) => { const interactive = e.target.closest('button, a, [role=button], [data-dropdown], .relative'); if (interactive) return; $emit('row:click', row, idx); }"
class="transition-colors"
:class="{
'cursor-pointer': !!$attrs.onRowClick,
'bg-gray-50': striped && idx % 2 === 1,
'hover:bg-gray-50': hoverable && !selectedRows.has(keyOf(row)),
'bg-primary-50': selectedRows.has(keyOf(row)),
}"
>
<!-- Select Checkbox -->
<td v-if="selectable" class="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
:checked="selectedRows.has(keyOf(row))"
@click.stop="toggleSelectRow(row)"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</td>
<!-- Data Cells -->
<td
v-for="col in columns"
:key="col.key"
class="px-6 py-4 whitespace-nowrap text-sm"
:class="[
col.class,
col.align === 'right'
? 'text-right text-gray-900'
: col.align === 'center'
? 'text-center text-gray-900'
: 'text-left text-gray-900',
]"
>
<slot
:name="`cell-${col.key}`"
:row="row"
:column="col"
:value="row?.[col.key]"
:index="idx"
>
<slot name="cell" :row="row" :column="col" :value="row?.[col.key]" :index="idx">
<span>{{ row?.[col.key] ?? '—' }}</span>
</slot>
</slot>
</td>
<!-- Actions Cell -->
<td
v-if="showActions || $slots.actions"
class="relative whitespace-nowrap py-4 pl-3 pr-6 text-right text-sm font-medium"
>
<slot name="actions" :row="row" :index="idx">
<slot name="row-actions" :row="row" :index="idx" />
</slot>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div v-else-if="isMobile && mobileCardView" class="divide-y divide-gray-200">
<template v-if="loading">
<div class="p-4">
<SkeletonTable :rows="3" :cols="1" />
</div>
</template>
<template v-else-if="!loading && displayRows.length === 0">
<div class="p-6">
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</div>
</template>
<template v-else>
<div
v-for="(row, idx) in displayRows"
:key="keyOf(row)"
@click="$emit('row:click', row, idx)"
class="p-4 space-y-2 hover:bg-gray-50 transition-colors"
:class="{ 'cursor-pointer': !!$attrs.onRowClick }"
>
<slot name="mobile-card" :row="row" :index="idx">
<!-- Default mobile card layout -->
<div
v-for="col in columns.slice(0, 3)"
:key="col.key"
class="flex justify-between items-start"
>
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">
{{ col.label }}
</span>
<span class="text-sm text-gray-900 text-right">
<slot
:name="`cell-${col.key}`"
:row="row"
:column="col"
:value="row?.[col.key]"
:index="idx"
>
{{ row?.[col.key] ?? '—' }}
</slot>
</span>
</div>
<div v-if="showActions || $slots.actions" class="pt-2 border-t border-gray-100">
<slot name="actions" :row="row" :index="idx">
<slot name="row-actions" :row="row" :index="idx" />
</slot>
</div>
</slot>
</div>
</template>
</div>
</div>
<!-- Pagination -->
<div v-if="showPagination && totalPages > 1">
<!-- Use existing Pagination component for server-side -->
<template v-if="isServerSide && meta?.links">
<Pagination
:links="meta.links"
:from="from"
:to="to"
:total="total"
/>
</template>
<!-- Custom pagination for client-side -->
<template v-else>
<div class="flex flex-1 justify-between sm:hidden">
<button
@click="handlePageChange(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Prejšnja
</button>
<button
@click="handlePageChange(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Naslednja
</button>
</div>
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Prikazano
<span class="font-medium">{{ from }}</span>
do
<span class="font-medium">{{ to }}</span>
od
<span class="font-medium">{{ total }}</span>
rezultatov
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<button
@click="handlePageChange(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Prejšnja</span>
<FontAwesomeIcon :icon="faChevronLeft" class="h-5 w-5" />
</button>
<template v-for="page in getVisiblePages()" :key="page">
<button
v-if="page !== '...'"
@click="handlePageChange(page)"
:aria-current="page === currentPage ? 'page' : undefined"
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20"
:class="
page === currentPage
? 'z-10 bg-primary-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600'
: 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0'
"
>
{{ page }}
</button>
<span
v-else
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300"
>
...
</span>
</template>
<button
@click="handlePageChange(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Naslednja</span>
<FontAwesomeIcon :icon="faChevronRight" class="h-5 w-5" />
</button>
</nav>
</div>
</div>
</template>
</div>
</div>
</template>
@@ -1,95 +0,0 @@
<script setup>
import { computed } from 'vue';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next';
import { Button } from '@/Components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
const props = defineProps({
table: {
type: Object,
required: true,
},
});
const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
</script>
<template>
<div class="flex items-center justify-between px-2">
<div class="flex-1 text-sm text-muted-foreground">
{{ table.getFilteredSelectedRowModel().rows.length }} of
{{ table.getFilteredRowModel().rows.length }} row(s) selected.
</div>
<div class="flex items-center space-x-6 lg:space-x-8">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Rows per page</p>
<Select
:model-value="`${table.getState().pagination.pageSize}`"
@update:model-value="(value) => table.setPageSize(Number(value))"
>
<SelectTrigger class="h-8 w-[70px]">
<SelectValue :placeholder="`${table.getState().pagination.pageSize}`" />
</SelectTrigger>
<SelectContent side="top">
<SelectItem
v-for="pageSize in pageSizeOptions"
:key="pageSize"
:value="`${pageSize}`"
>
{{ pageSize }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex w-[100px] items-center justify-center text-sm font-medium">
Page {{ table.getState().pagination.pageIndex + 1 }} of
{{ table.getPageCount() }}
</div>
<div class="flex items-center space-x-2">
<Button
variant="outline"
class="hidden h-8 w-8 p-0 lg:flex"
:disabled="!table.getCanPreviousPage()"
@click="table.setPageIndex(0)"
>
<span class="sr-only">Go to first page</span>
<ChevronsLeft class="h-4 w-4" />
</Button>
<Button
variant="outline"
class="h-8 w-8 p-0"
:disabled="!table.getCanPreviousPage()"
@click="table.previousPage()"
>
<span class="sr-only">Go to previous page</span>
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
variant="outline"
class="h-8 w-8 p-0"
:disabled="!table.getCanNextPage()"
@click="table.nextPage()"
>
<span class="sr-only">Go to next page</span>
<ChevronRight class="h-4 w-4" />
</Button>
<Button
variant="outline"
class="hidden h-8 w-8 p-0 lg:flex"
:disabled="!table.getCanNextPage()"
@click="table.setPageIndex(table.getPageCount() - 1)"
>
<span class="sr-only">Go to last page</span>
<ChevronsRight class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</template>
@@ -1,16 +1,14 @@
<script setup>
import { ref, watch, computed } from "vue";
import { router } from "@inertiajs/vue3";
import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue";
import EmptyState from "@/Components/EmptyState.vue";
import {
Table,
TableHeader,
TableHead,
TableBody,
TableRow,
TableCell,
} from "@/Components/ui/table";
FwbTable,
FwbTableHead,
FwbTableHeadCell,
FwbTableBody,
FwbTableRow,
FwbTableCell,
} from "flowbite-vue";
const props = defineProps({
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class? }]
@@ -96,8 +94,7 @@ function setPageSize(ps) {
function doRequest(overrides = {}) {
const q = {
...props.query,
// Laravel expects snake_case per_page
per_page: overrides.perPage ?? props.meta?.per_page ?? props.pageSize ?? 10,
perPage: overrides.perPage ?? props.meta?.per_page ?? props.pageSize ?? 10,
sort: overrides.sort ?? props.sort?.key ?? null,
direction: overrides.direction ?? props.sort?.direction ?? null,
search: overrides.search ?? props.search ?? "",
@@ -201,12 +198,15 @@ function goToPageInput() {
<div
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
>
<Table class="text-sm">
<TableHeader
<FwbTable hoverable striped class="text-sm">
<FwbTableHead
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
>
<TableRow class="border-b">
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
<FwbTableHeadCell v-for="col in columns" :key="col.key" :class="col.class">
<template v-if="$slots['header-' + col.key]">
<slot :name="'header-' + col.key" :column="col" />
</template>
<template v-else>
<button
v-if="col.sortable"
type="button"
@@ -221,23 +221,24 @@ function goToPageInput() {
>
</button>
<span v-else>{{ col.label }}</span>
</TableHead>
<TableHead v-if="$slots.actions" class="w-px">&nbsp;</TableHead>
</TableRow>
</TableHeader>
</template>
</FwbTableHeadCell>
<FwbTableHeadCell v-if="$slots.actions" class="w-px">&nbsp;</FwbTableHeadCell>
</FwbTableHead>
<TableBody>
<FwbTableBody>
<template v-if="!loading && rows.length">
<TableRow
<FwbTableRow
v-for="(row, idx) in rows"
:key="keyOf(row)"
@click="$emit('row:click', row)"
class="cursor-default hover:bg-gray-50/50"
class="cursor-default"
>
<TableCell
<FwbTableCell
v-for="col in columns"
:key="col.key"
:class="col.class"
:align="col.align || 'left'"
>
<template v-if="$slots['cell-' + col.key]">
<slot
@@ -260,30 +261,30 @@ function goToPageInput() {
<template v-else>
{{ row?.[col.key] ?? "" }}
</template>
</TableCell>
<TableCell v-if="$slots.actions" class="w-px text-right">
</FwbTableCell>
<FwbTableCell v-if="$slots.actions" class="w-px text-right">
<slot name="actions" :row="row" :index="idx" />
</TableCell>
</TableRow>
</FwbTableCell>
</FwbTableRow>
</template>
<template v-else-if="loading">
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<SkeletonTable :rows="5" :cols="columns.length" />
</TableCell>
</TableRow>
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<div class="p-6 text-center text-gray-500">Nalagam...</div>
</FwbTableCell>
</FwbTableRow>
</template>
<template v-else>
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<slot name="empty">
<div class="p-6 text-center text-gray-500">{{ emptyText }}</div>
</slot>
</TableCell>
</TableRow>
</FwbTableCell>
</FwbTableRow>
</template>
</TableBody>
</Table>
</FwbTableBody>
</FwbTable>
</div>
<nav
@@ -1,182 +0,0 @@
<script setup>
import { computed, ref } from "vue";
import { X, Settings2 } from "lucide-vue-next";
import { Input } from "@/Components/ui/input";
import { Button } from "@/Components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
import DataTableViewOptions from "./DataTableViewOptions.vue";
/**
* DataTable Toolbar Component
* Simplified toolbar following shadcn-vue patterns for TanStack Table integration
*/
const props = defineProps({
// TanStack Table instance
table: {
type: Object,
required: true,
},
// Column to filter on (e.g., 'email', 'name')
filterColumn: {
type: String,
default: null,
},
// Placeholder text for filter input
filterPlaceholder: {
type: String,
default: "Filter...",
},
// Show view options (column visibility toggle)
showViewOptions: {
type: Boolean,
default: true,
},
// Show per-page selector
showPerPageSelector: {
type: Boolean,
default: false,
},
// Current per page value
perPage: {
type: Number,
default: 15,
},
// Per page options
pageSizeOptions: {
type: Array,
default: () => [10, 15, 25, 50, 100],
},
});
const emit = defineEmits(["update:perPage"]);
// Popover state
const settingsPopoverOpen = ref(false);
// Check if any filters are active
const isFiltered = computed(() => {
if (!props.filterColumn) return false;
const column = props.table.getColumn(props.filterColumn);
return column && column.getFilterValue();
});
// Get/set filter value
const filterValue = computed({
get() {
if (!props.filterColumn) return "";
const column = props.table.getColumn(props.filterColumn);
return column?.getFilterValue() ?? "";
},
set(value) {
if (!props.filterColumn) return;
const column = props.table.getColumn(props.filterColumn);
column?.setFilterValue(value);
},
});
// Reset all filters
function resetFilters() {
props.table.resetColumnFilters();
}
</script>
<template>
<div class="flex items-center justify-between">
<!-- Left side: Search and Filters -->
<div class="flex flex-1 items-center space-x-2">
<!-- Filter Input -->
<Input
v-if="filterColumn"
v-model="filterValue"
:placeholder="filterPlaceholder"
class="h-8 w-[150px] lg:w-[250px]"
/>
<!-- Custom filter slots -->
<slot name="filters" :table="table" />
<!-- Reset filters button -->
<Button
v-if="isFiltered"
variant="ghost"
@click="resetFilters"
class="h-8 px-2 lg:px-3"
>
Reset
<X class="ml-2 h-4 w-4" />
</Button>
</div>
<!-- Right side: Actions and View Options -->
<div class="flex items-center space-x-2">
<!-- Custom action slots -->
<slot name="actions" :table="table" />
<!-- Settings Popover (Per-page selector + View Options) -->
<Popover v-model:open="settingsPopoverOpen">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="gap-2">
<Settings2 class="h-4 w-4" />
Pogled
</Button>
</PopoverTrigger>
<PopoverContent class="w-[300px]" align="end">
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium text-sm">Nastavitve pogleda</h4>
</div>
<div class="space-y-3">
<!-- Per page selector -->
<div
v-if="showPerPageSelector"
class="flex items-center justify-between gap-4"
>
<label class="text-sm whitespace-nowrap">Elementov na stran</label>
<Select
:model-value="String(perPage)"
@update:model-value="
(value) => {
emit('update:perPage', Number(value));
settingsPopoverOpen = false;
}
"
>
<SelectTrigger class="h-9 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem
v-for="size in pageSizeOptions"
:key="size"
:value="String(size)"
>
{{ size }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Column visibility -->
<div v-if="showViewOptions" class="flex items-center justify-between gap-4">
<label class="text-sm whitespace-nowrap">Vidnost stolpcev</label>
<DataTableViewOptions
:table="table"
@column-toggle="settingsPopoverOpen = false"
/>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</template>
@@ -1,86 +0,0 @@
<script setup>
import { ref } from 'vue';
import DataTableToolbar from './DataTableToolbar.vue';
// Example: Using DataTableToolbar standalone
const search = ref('');
const pageSize = ref(10);
const selectedCount = ref(0);
const handleSearchChange = (value) => {
search.value = value;
console.log('Search changed:', value);
};
const handlePageSizeChange = (value) => {
pageSize.value = value;
console.log('Page size changed:', value);
};
const handleExport = (format) => {
console.log('Export:', format);
};
const handleAdd = () => {
console.log('Add button clicked');
};
</script>
<template>
<div class="space-y-4">
<!-- Standalone DataTableToolbar -->
<DataTableToolbar
:search="search"
:show-search="true"
:show-page-size="true"
:page-size="pageSize"
:selected-count="selectedCount"
:show-selected-count="true"
:show-export="true"
:show-add="true"
:show-filters="true"
@update:search="handleSearchChange"
@update:page-size="handlePageSizeChange"
@export="handleExport"
>
<!-- Add button dropdown content -->
<template #add>
<button
@click="handleAdd"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
Dodaj novo
</button>
</template>
<!-- Custom options -->
<template #options>
<button class="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded">
Opcija 1
</button>
</template>
<!-- Filters -->
<template #filters>
<div class="space-y-2">
<label class="text-sm font-medium">Filtriraj po:</label>
<input type="text" class="w-full px-2 py-1 border rounded" />
</div>
</template>
<!-- Custom actions -->
<template #actions>
<button class="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded">
Akcija
</button>
</template>
</DataTableToolbar>
<!-- Your content here -->
<div class="p-4 bg-gray-50 rounded">
<p>Search: {{ search }}</p>
<p>Page Size: {{ pageSize }}</p>
<p>Selected: {{ selectedCount }}</p>
</div>
</div>
</template>
@@ -1,50 +0,0 @@
<script setup>
import { computed } from "vue";
import { Settings } from "lucide-vue-next";
import { Button } from "@/Components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
const props = defineProps({
table: {
type: Object,
required: true,
},
});
const columns = computed(() =>
props.table
.getAllColumns()
.filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide())
);
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="ml-auto hidden h-8 lg:flex">
<Settings class="mr-2 h-4 w-4" />
Pogled
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-[150px]">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="column in columns"
:key="column.id"
class="capitalize"
:model-value="column.getIsVisible()"
@update:model-value="(value) => column.toggleVisibility(!!value)"
>
{{ column.id }}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
@@ -1,291 +0,0 @@
# DataTable Migration Guide
## Summary of Changes
The DataTable component has been updated to follow **shadcn-vue** architecture patterns using **TanStack Table v8**. This provides better flexibility, more features, and follows industry-standard patterns.
## What's New
### ✅ Components Created/Updated
1. **`DataTableNew2.vue`** - New main component with shadcn-vue architecture
2. **`DataTableColumnHeader.vue`** - Already good, uses lucide-vue-next icons
3. **`DataTablePagination.vue`** - Already follows shadcn-vue patterns
4. **`DataTableViewOptions.vue`** - Already follows shadcn-vue patterns
5. **`DataTableToolbar.vue`** - Already exists with advanced features
6. **`columns-example.js`** - Column definition examples
7. **`README.md`** - Comprehensive documentation
8. **`DataTableExample.vue`** - Working example page
### ✅ Utilities Added
- **`valueUpdater()`** in `lib/utils.js` - Helper for TanStack Table state management
## Key Improvements
### 1. **FlexRender Integration**
Now properly uses TanStack Table's FlexRender for column headers and cells:
```vue
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
```
### 2. **Better Column Definitions**
Supports both simple and advanced formats:
**Simple:**
```javascript
{ key: 'name', label: 'Name', sortable: true }
```
**Advanced:**
```javascript
{
accessorKey: 'name',
header: ({ column }) => h(DataTableColumnHeader, { column, title: 'Name' }),
cell: ({ row }) => h('div', {}, row.getValue('name')),
}
```
### 3. **Enhanced Features**
- ✅ Row selection with checkboxes
- ✅ Column visibility toggle
- ✅ Advanced filtering
- ✅ Better loading/empty states
- ✅ Custom cell slots
- ✅ Flexible toolbar
### 4. **Better State Management**
Uses `valueUpdater()` helper for proper Vue reactivity with TanStack Table:
```javascript
onSortingChange: (updater) => valueUpdater(updater, sorting)
```
## Migration Steps
### Step 1: Update Imports
**Before:**
```vue
import DataTable from '@/Components/DataTable/DataTable.vue';
```
**After:**
```vue
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
```
### Step 2: Update Props
**Before:**
```vue
<DataTable
:rows="clients.data"
:columns="columns"
:meta="clients.meta"
/>
```
**After:**
```vue
<DataTable
:data="clients.data"
:columns="columns"
:meta="clients.meta"
route-name="clients.index"
/>
```
Main prop changes:
- `rows``data`
- Add `route-name` for server-side pagination
### Step 3: Column Definitions
Your existing simple column format still works:
```javascript
const columns = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'name', label: 'Name', sortable: true },
];
```
But you can now use advanced format for more control:
```javascript
import { h } from 'vue';
import DataTableColumnHeader from '@/Components/DataTable/DataTableColumnHeader.vue';
const columns = [
{
accessorKey: 'name',
header: ({ column }) => h(DataTableColumnHeader, { column, title: 'Name' }),
cell: ({ row }) => h('div', { class: 'font-medium' }, row.getValue('name')),
},
];
```
### Step 4: Custom Cell Rendering
**Before:** Required editing component
**After:** Use slots!
```vue
<DataTable :columns="columns" :data="data">
<template #cell-status="{ value, row }">
<Badge :variant="value === 'active' ? 'default' : 'secondary'">
{{ value }}
</Badge>
</template>
</DataTable>
```
## Backward Compatibility
The **old DataTable components are still available**:
- `DataTable.vue` - Your current enhanced version
- `DataTableServer.vue` - Your server-side version
- `DataTableOld.vue` - Original version
You can migrate pages gradually. Both old and new can coexist.
## Example Migration
### Before (Client/Index.vue)
```vue
<script setup>
import DataTable from '@/Components/DataTable/DataTable.vue';
const props = defineProps({
clients: Object,
filters: Object,
});
const columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
];
</script>
<template>
<DataTable
:rows="clients.data"
:columns="columns"
:meta="clients.meta"
:search="filters.search"
:sort="filters.sort"
route-name="clients.index"
/>
</template>
```
### After (Using DataTableNew2)
```vue
<script setup>
import DataTable from '@/Components/DataTable/DataTableNew2.vue';
const props = defineProps({
clients: Object,
filters: Object,
});
const columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'status', label: 'Status', sortable: false },
];
</script>
<template>
<DataTable
:data="clients.data"
:columns="columns"
:meta="clients.meta"
:search="filters.search"
:sort="filters.sort"
route-name="clients.index"
filter-column="email"
filter-placeholder="Search clients..."
:only-props="['clients']"
>
<!-- Add custom cell rendering -->
<template #cell-status="{ value }">
<Badge :variant="value === 'active' ? 'default' : 'secondary'">
{{ value }}
</Badge>
</template>
</DataTable>
</template>
```
## Testing Your Migration
1. **Check the example page:**
```
Visit: /examples/datatable
```
(You'll need to add a route for this)
2. **Test features:**
- ✅ Sorting (click column headers)
- ✅ Filtering (use search input)
- ✅ Pagination (navigate pages)
- ✅ Row selection (if enabled)
- ✅ Column visibility (View button)
3. **Check browser console:**
- No errors
- Events firing correctly
## Common Issues
### Issue: "FlexRender is not defined"
**Solution:** Make sure you imported it:
```javascript
import { FlexRender } from '@tanstack/vue-table';
```
### Issue: Column not sorting
**Solution:** Make sure `sortable: true` is set:
```javascript
{ key: 'name', label: 'Name', sortable: true }
```
### Issue: Server-side not working
**Solution:** Provide both `meta` and `route-name`:
```vue
<DataTable
:data="data"
:meta="meta"
route-name="your.route.name"
/>
```
### Issue: Custom cells not rendering
**Solution:** Use the correct slot name format:
```vue
<template #cell-columnKey="{ value, row }">
<!-- Your content -->
</template>
```
## Need Help?
1. Check `README.md` for detailed documentation
2. Look at `columns-example.js` for column patterns
3. Review `DataTableExample.vue` for working examples
4. Check TanStack Table docs: https://tanstack.com/table/v8
## Rollback Plan
If you encounter issues, you can always use the old components:
```vue
import DataTable from '@/Components/DataTable/DataTable.vue';
// or
import DataTableServer from '@/Components/DataTable/DataTableServer.vue';
```
Nothing breaks your existing code!
-390
View File
@@ -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;
-152
View File
@@ -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>
-231
View File
@@ -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>
+21 -50
View File
@@ -1,15 +1,9 @@
<script setup>
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/Components/ui/dialog';
import { computed, ref, watch } from 'vue';
import Modal from './Modal.vue';
const props = defineProps({
const emit = defineEmits(['close']);
defineProps({
show: {
type: Boolean,
default: false,
@@ -24,53 +18,30 @@ const props = defineProps({
},
});
const emit = defineEmits(['update:show', 'close']);
const open = ref(props.show);
watch(() => props.show, (newVal) => {
open.value = newVal;
});
watch(open, (newVal) => {
emit('update:show', newVal);
if (!newVal) {
const close = () => {
emit('close');
}
});
const maxWidthClass = computed(() => {
const maxWidthMap = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
wide: 'sm:max-w-[1200px]',
};
return maxWidthMap[props.maxWidth] || 'sm:max-w-2xl';
});
</script>
<template>
<Dialog v-model:open="open" :modal="true">
<DialogContent :class="maxWidthClass">
<DialogHeader>
<DialogTitle>
<Modal
:show="show"
:max-width="maxWidth"
:closeable="closeable"
@close="close"
>
<div class="px-6 py-4">
<div class="text-lg font-medium text-gray-900">
<slot name="title" />
</DialogTitle>
<DialogDescription>
<slot name="description" />
</DialogDescription>
</DialogHeader>
<div class="py-4">
<slot name="content" />
</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" />
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Modal>
</template>
@@ -1,111 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/Components/ui/dialog';
import { Button } from '@/Components/ui/button';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faCircleQuestion, faCheckCircle } from '@fortawesome/free-solid-svg-icons';
const props = defineProps({
show: { type: Boolean, default: false },
title: { type: String, default: 'Potrditev' },
message: { type: String, default: 'Ali ste prepričani?' },
confirmText: { type: String, default: 'Potrdi' },
cancelText: { type: String, default: 'Prekliči' },
processing: { type: Boolean, default: false },
maxWidth: { type: String, default: 'md' },
variant: {
type: String,
default: 'default', // 'default' or 'success'
validator: (v) => ['default', 'success'].includes(v)
},
});
const emit = defineEmits(['update:show', 'close', 'confirm']);
const open = ref(props.show);
watch(() => props.show, (newVal) => {
open.value = newVal;
});
watch(open, (newVal) => {
emit('update:show', newVal);
if (!newVal) {
emit('close');
}
});
const onClose = () => {
open.value = false;
};
const onConfirm = () => {
emit('confirm');
};
const icon = computed(() => props.variant === 'success' ? faCheckCircle : faCircleQuestion);
const iconColor = computed(() => props.variant === 'success' ? 'text-green-600' : 'text-primary-600');
const iconBg = computed(() => props.variant === 'success' ? 'bg-green-100' : 'bg-primary-100');
const maxWidthClass = computed(() => {
const maxWidthMap = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
wide: 'sm:max-w-[1200px]',
};
return maxWidthMap[props.maxWidth] || 'sm:max-w-md';
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent :class="maxWidthClass">
<DialogHeader>
<DialogTitle>
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="icon" :class="['h-5 w-5', iconColor]" />
<span>{{ title }}</span>
</div>
</DialogTitle>
<DialogDescription>
<div class="flex items-start gap-4 pt-4">
<div class="flex-shrink-0">
<div :class="['flex items-center justify-center h-12 w-12 rounded-full', iconBg]">
<FontAwesomeIcon :icon="icon" :class="['h-6 w-6', iconColor]" />
</div>
</div>
<div class="flex-1">
<p class="text-sm text-gray-700">
{{ message }}
</p>
<slot />
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="onClose" :disabled="processing">
{{ cancelText }}
</Button>
<Button
@click="onConfirm"
:disabled="processing"
>
{{ confirmText }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -1,100 +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-600" />
<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,100 +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-600" />
<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>
+411
View File
@@ -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>
+9 -9
View File
@@ -1,5 +1,5 @@
<script setup>
// ListGroup components removed - using custom implementation
import { FwbListGroup, FwbListGroupItem } from 'flowbite-vue';
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
import draggable from 'vuedraggable';
@@ -68,13 +68,13 @@ watch(
group="actions"
>
<template #item="{element, index}">
<li class="flex justify-between items-center p-2 bg-white border border-gray-200 rounded-md mb-1 hover:bg-gray-50 transition-colors">
<fwb-list-group-item class="flex justify-between">
<span class="text">{{ element.name }} </span>
<button type="button" class="cursor-pointer p-1 hover:bg-gray-100 rounded" @click="removeAt(index, containerOne)"><svg class="w-6 h-6 text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<i class=" cursor-pointer" @click="removeAt(index, containerOne)"><svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg></button>
</li>
</svg></i>
</fwb-list-group-item>
</template>
</draggable>
<draggable
@@ -92,13 +92,13 @@ watch(
group="actions"
>
<template #item="{element, index}">
<li class="flex justify-between items-center p-2 bg-white border border-gray-200 rounded-md mb-1 hover:bg-gray-50 transition-colors">
<fwb-list-group-item class="flex justify-between">
<span class="text">{{ element.name }} </span>
<button type="button" class="cursor-pointer p-1 hover:bg-gray-100 rounded" @click="removeAt(index, containerOne)"><svg class="w-6 h-6 text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<i class="cursor-pointer" @click="removeAt(index, containerOne)"><svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg></button>
</li>
</svg></i>
</fwb-list-group-item>
</template>
</draggable>
</div>
+91 -77
View File
@@ -1,10 +1,5 @@
<script setup>
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps({
align: {
@@ -25,64 +20,64 @@ const props = defineProps({
},
});
const open = ref(false);
let open = ref(false);
const triggerEl = ref(null);
const panelEl = ref(null);
const panelStyle = ref({ top: '0px', left: '0px' });
// Expose close method for parent components
const close = () => {
open.value = false;
};
defineExpose({
close,
open,
});
// Close dropdown when dialog opens
const handleDialogOpen = () => {
if (open.value) {
const closeOnEscape = (e) => {
if (open.value && e.key === 'Escape') {
open.value = false;
}
};
// Watch for dialog opens using MutationObserver
let observer = null;
const updatePosition = () => {
const t = triggerEl.value;
const p = panelEl.value;
if (!t || !p) return;
const rect = t.getBoundingClientRect();
// Ensure we have updated width
const pw = p.offsetWidth || 0;
const ph = p.offsetHeight || 0;
const margin = 8; // small spacing from trigger
let left = rect.left;
if (props.align === 'right') {
left = rect.right - pw;
} else if (props.align === 'left') {
left = rect.left;
}
// Clamp within viewport
const maxLeft = Math.max(0, window.innerWidth - pw - margin);
left = Math.min(Math.max(margin, left), maxLeft);
let top = rect.bottom + margin;
// If not enough space below, place above the trigger
if (top + ph > window.innerHeight) {
top = Math.max(margin, rect.top - ph - margin);
}
panelStyle.value = { top: `${top}px`, left: `${left}px` };
};
onMounted(() => {
// Listen for custom dialog open events
window.addEventListener('dialog:open', handleDialogOpen);
const onWindowChange = () => {
updatePosition();
};
// Watch for dialog state changes in the DOM
observer = new MutationObserver((mutations) => {
// Check if any dialog has data-state="open"
const openDialogs = document.querySelectorAll('[data-state="open"]');
const hasOpenDialog = Array.from(openDialogs).some((dialog) => {
// Check if it's a dialog element (has role="dialog" or is DialogContent)
const role = dialog.getAttribute('role');
const isDialogContent = dialog.classList?.contains('DialogContent') ||
dialog.querySelector('[role="dialog"]') ||
dialog.closest('[role="dialog"]');
return role === 'dialog' || isDialogContent;
});
if (hasOpenDialog && open.value) {
handleDialogOpen();
watch(open, async (val) => {
if (val) {
await nextTick();
updatePosition();
window.addEventListener('resize', onWindowChange);
window.addEventListener('scroll', onWindowChange, true);
} else {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
}
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['data-state'],
subtree: true,
childList: true,
});
});
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => {
window.removeEventListener('dialog:open', handleDialogOpen);
if (observer) {
observer.disconnect();
}
document.removeEventListener('keydown', closeOnEscape);
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
});
const widthClass = computed(() => {
@@ -95,38 +90,57 @@ const widthClass = computed(() => {
'wide': 'w-[34rem] max-w-[90vw]',
'auto': '',
};
return map[props.width.toString()] || '';
return map[props.width.toString()] ?? '';
});
// Map align prop to shadcn-vue's align prop
// 'left' -> 'start', 'right' -> 'end'
const alignProp = computed(() => {
if (props.align === 'left') return 'start';
if (props.align === 'right') return 'end';
return 'start';
});
const combinedContentClasses = computed(() => {
// Merge width class with custom content classes
// Note: shadcn-vue already provides base styling, so we append custom classes
const classes = [widthClass.value];
if (props.contentClasses && props.contentClasses.length) {
classes.push(...props.contentClasses);
const alignmentClasses = computed(() => {
if (props.align === 'left') {
return 'ltr:origin-top-left rtl:origin-top-right start-0';
}
return classes.filter(Boolean).join(' ');
if (props.align === 'right') {
return 'ltr:origin-top-right rtl:origin-top-left end-0';
}
return 'origin-top';
});
const onContentClick = () => {
if (props.closeOnContentClick) {
open.value = false;
}
};
</script>
<template>
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child>
<div class="relative" ref="triggerEl">
<div @click="open = ! open">
<slot name="trigger" />
</DropdownMenuTrigger>
<DropdownMenuContent
:align="alignProp"
:class="combinedContentClasses"
</div>
<teleport to="body">
<!-- Full Screen Dropdown Overlay at body level -->
<div v-show="open" class="fixed inset-0 z-[2147483646]" @click="open = false" />
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-show="open"
ref="panelEl"
class="fixed z-[2147483647] rounded-md shadow-lg"
:class="[widthClass]"
:style="[panelStyle]"
>
<div class="rounded-md ring-1 ring-black ring-opacity-5" :class="contentClasses" @click="onContentClick">
<slot name="content" />
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</transition>
</teleport>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More