Dev branch

This commit is contained in:
Simon Pocrnjič 2025-11-02 12:31:01 +01:00
parent 5f879c9436
commit 63e0958b66
241 changed files with 17686 additions and 7327 deletions

View File

@ -0,0 +1,57 @@
<?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;
}
}

View File

@ -15,11 +15,22 @@ protected function schedule(Schedule $schedule): void
// Optionally prune old previews daily
if (config('files.enable_preview_prune', true)) {
$days = (int) config('files.preview_retention_days', 90);
if ($days < 1) { $days = 90; }
if ($days < 1) {
$days = 90;
}
$schedule->command('documents:prune-previews', [
'--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);
}
}
/**

View File

@ -280,6 +280,20 @@ 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.
*/

View File

@ -7,6 +7,8 @@
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
use App\Services\Documents\DocumentStreamService;
use App\Services\ReferenceDataCache;
use App\Services\Sms\SmsService;
use Exception;
use Illuminate\Database\QueryException;
@ -16,45 +18,45 @@
class ClientCaseContoller extends Controller
{
public function __construct(
protected ReferenceDataCache $referenceCache,
protected DocumentStreamService $documentStream
) {}
/**
* Display a listing of the resource.
*/
public function index(ClientCase $clientCase, Request $request)
{
$search = $request->input('search');
$query = $clientCase::query()
->with(['person.client', 'client.person'])
->where('active', 1)
->when($request->input('search'), function ($que, $search) {
$que->whereHas('person', function ($q) use ($search) {
$q->where('full_name', 'ilike', '%'.$search.'%');
});
->select('client_cases.*')
->when($search, function ($que) use ($search) {
$que->join('person', 'person.id', '=', 'client_cases.person_id')
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('client_cases.id');
})
->where('client_cases.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->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([
// Count of active contracts (a contract is considered active if it has an active pivot in contract_segment)
'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);
}),
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
// Sum of balances for accounts of active contracts
'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);
}),
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->orderByDesc('created_at');
->with(['person.client', 'client.person'])
->orderByDesc('client_cases.created_at');
return Inertia::render('Cases/Index', [
'client_cases' => $query
@ -609,188 +611,7 @@ public function viewDocument(ClientCase $clientCase, Document $document, Request
abort(404);
}
// Optional: add authz checks here (e.g., policies)
$disk = $document->disk ?: 'public';
// Normalize relative path (handle legacy 'public/' or 'public\\' prefixes and backslashes on Windows)
$relPath = $document->path ?? '';
$relPath = str_replace('\\', '/', $relPath); // unify slashes
$relPath = ltrim($relPath, '/');
if (str_starts_with($relPath, 'public/')) {
$relPath = substr($relPath, 7);
}
// If a preview exists (e.g., PDF generated for doc/docx), stream that
$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) {
abort(404);
}
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, [
'Content-Type' => $document->preview_mime ?: 'application/pdf',
'Content-Disposition' => 'inline; filename="'.addslashes(($document->original_name ?: $document->file_name).'.pdf').'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
}
// If it's a DOC/DOCX and no preview yet, queue generation and show 202 Accepted
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
if (in_array($ext, ['doc', 'docx'])) {
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
return response('Preview is being generated. Please try again shortly.', 202);
}
// Try multiple path candidates to account for legacy prefixes
$candidates = [];
$candidates[] = $relPath;
// also try raw original (normalized slashes, trimmed)
$raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null;
if ($raw && $raw !== $relPath) {
$candidates[] = $raw;
}
// if path accidentally contains 'storage/' prefix (public symlink), strip it
if (str_starts_with($relPath, 'storage/')) {
$candidates[] = substr($relPath, 8);
}
if ($raw && str_starts_with($raw, 'storage/')) {
$candidates[] = substr($raw, 8);
}
$existsOnDisk = false;
foreach ($candidates as $cand) {
if (Storage::disk($disk)->exists($cand)) {
$existsOnDisk = true;
$relPath = $cand;
break;
}
}
if (! $existsOnDisk) {
// Fallback: some legacy files may live directly under public/, attempt to stream from there
$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)) {
logger()->info('Document view fallback: serving from public path', [
'document_id' => $document->id,
'path' => $realN,
]);
$fp = @fopen($real, 'rb');
if ($fp === false) {
abort(404);
}
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
}
logger()->warning('Document view 404: file missing on disk and public fallback failed', [
'document_id' => $document->id,
'document_uuid' => $document->uuid,
'disk' => $disk,
'path' => $document->path,
'normalizedCandidates' => $candidates,
'public_candidate' => $publicFull,
]);
abort(404);
}
$stream = Storage::disk($disk)->readStream($relPath);
if ($stream === false) {
logger()->warning('Document view: readStream failed, attempting fallbacks', [
'document_id' => $document->id,
'disk' => $disk,
'relPath' => $relPath,
]);
$headers = [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
];
// Fallback 1: get() the bytes directly
try {
$bytes = Storage::disk($disk)->get($relPath);
} catch (\Throwable $e) {
$bytes = null;
}
if (! is_null($bytes) && $bytes !== false) {
return response($bytes, 200, $headers);
}
// Fallback 2: open via absolute path (local driver)
$abs = null;
try {
if (method_exists(Storage::disk($disk), 'path')) {
$abs = Storage::disk($disk)->path($relPath);
}
} catch (\Throwable $e) {
$abs = null;
}
if ($abs && is_file($abs)) {
$fp = @fopen($abs, 'rb');
if ($fp !== false) {
logger()->info('Document view fallback: serving from absolute storage path', [
'document_id' => $document->id,
'abs' => str_replace('\\\\', '/', (string) realpath($abs)),
]);
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
// Fallback 3: serve from public path if available
$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)) {
logger()->info('Document view fallback: serving from public path (post-readStream failure)', [
'document_id' => $document->id,
'path' => $realN,
]);
$fp = @fopen($real, 'rb');
if ($fp !== false) {
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
logger()->warning('Document view 404: all fallbacks failed after readStream failure', [
'document_id' => $document->id,
'disk' => $disk,
'relPath' => $relPath,
]);
abort(404);
}
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
return $this->documentStream->stream($document, inline: true);
}
public function downloadDocument(ClientCase $clientCase, Document $document, Request $request)
@ -814,163 +635,8 @@ public function downloadDocument(ClientCase $clientCase, Document $document, Req
]);
abort(404);
}
$disk = $document->disk ?: 'public';
// Normalize relative path for Windows and legacy prefixes
$relPath = $document->path ?? '';
$relPath = str_replace('\\', '/', $relPath);
$relPath = ltrim($relPath, '/');
if (str_starts_with($relPath, 'public/')) {
$relPath = substr($relPath, 7);
}
$candidates = [];
$candidates[] = $relPath;
$raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : 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);
}
$existsOnDisk = false;
foreach ($candidates as $cand) {
if (Storage::disk($disk)->exists($cand)) {
$existsOnDisk = true;
$relPath = $cand;
break;
}
}
if (! $existsOnDisk) {
// Fallback to public/ direct path if present
$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)) {
logger()->info('Document download fallback: serving from public path', [
'document_id' => $document->id,
'path' => $realN,
]);
$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;
$fp = @fopen($real, 'rb');
if ($fp === false) {
abort(404);
}
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
}
logger()->warning('Document download 404: file missing on disk and public fallback failed', [
'document_id' => $document->id,
'document_uuid' => $document->uuid,
'disk' => $disk,
'path' => $document->path,
'normalizedCandidates' => $candidates,
'public_candidate' => $publicFull,
]);
abort(404);
}
$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;
$stream = Storage::disk($disk)->readStream($relPath);
if ($stream === false) {
logger()->warning('Document download: readStream failed, attempting fallbacks', [
'document_id' => $document->id,
'disk' => $disk,
'relPath' => $relPath,
]);
$headers = [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
];
// Fallback 1: get() the bytes directly
try {
$bytes = Storage::disk($disk)->get($relPath);
} catch (\Throwable $e) {
$bytes = null;
}
if (! is_null($bytes) && $bytes !== false) {
return response($bytes, 200, $headers);
}
// Fallback 2: open via absolute storage path
$abs = null;
try {
if (method_exists(Storage::disk($disk), 'path')) {
$abs = Storage::disk($disk)->path($relPath);
}
} catch (\Throwable $e) {
$abs = null;
}
if ($abs && is_file($abs)) {
$fp = @fopen($abs, 'rb');
if ($fp !== false) {
logger()->info('Document download fallback: serving from absolute storage path', [
'document_id' => $document->id,
'abs' => str_replace('\\\\', '/', (string) realpath($abs)),
]);
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
// Fallback 3: serve from public path if available
$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)) {
logger()->info('Document download fallback: serving from public path (post-readStream failure)', [
'document_id' => $document->id,
'path' => $realN,
]);
$fp = @fopen($real, 'rb');
if ($fp !== false) {
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
logger()->warning('Document download 404: all fallbacks failed after readStream failure', [
'document_id' => $document->id,
'disk' => $disk,
'relPath' => $relPath,
]);
abort(404);
}
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
return $this->documentStream->stream($document, inline: false);
}
/**
@ -984,8 +650,7 @@ public function viewContractDocument(Contract $contract, Document $document, Req
abort(404);
}
// Reuse the existing logic by delegating to a small helper
return $this->streamDocumentForDisk($document, inline: true);
return $this->documentStream->stream($document, inline: true);
}
/**
@ -998,138 +663,7 @@ public function downloadContractDocument(Contract $contract, Document $document,
abort(404);
}
return $this->streamDocumentForDisk($document, inline: false);
}
/**
* Internal helper to stream a document either inline or as attachment with all Windows/public fallbacks.
*/
protected function streamDocumentForDisk(Document $document, bool $inline = true)
{
$disk = $document->disk ?: 'public';
$relPath = $document->path ?? '';
$relPath = str_replace('\\', '/', $relPath);
$relPath = ltrim($relPath, '/');
if (str_starts_with($relPath, 'public/')) {
$relPath = substr($relPath, 7);
}
// Previews for DOC/DOCX
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
$previewDisk = config('files.preview_disk', 'public');
if ($inline && in_array($ext, ['doc', 'docx'])) {
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',
]);
}
}
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
return response('Preview is being generated. Please try again shortly.', 202);
}
// Try storage candidates
$candidates = [$relPath];
$raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : 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);
}
$found = null;
foreach ($candidates as $cand) {
if (Storage::disk($disk)->exists($cand)) {
$found = $cand;
break;
}
}
$headers = [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($document->original_name ?: $document->file_name).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
];
if (! $found) {
// public/ fallback
$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)) {
$fp = @fopen($real, 'rb');
if ($fp !== false) {
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
abort(404);
}
$stream = Storage::disk($disk)->readStream($found);
if ($stream !== false) {
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, $headers);
}
// Fallbacks on readStream failure
try {
$bytes = Storage::disk($disk)->get($found);
if (! is_null($bytes) && $bytes !== false) {
return response($bytes, 200, $headers);
}
} catch (\Throwable $e) {
}
$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);
}
}
// public/ again as last try
$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);
return $this->documentStream->stream($document, inline: false);
}
/**
@ -1142,8 +676,8 @@ public function show(ClientCase $clientCase)
])->where('active', 1)->findOrFail($clientCase->id);
$types = [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
// $active = false;
@ -1210,10 +744,10 @@ public function show(ClientCase $clientCase)
});
}
// NOTE: If a case has an extremely large number of contracts this can still be heavy.
// Consider pagination or deferred (Inertia lazy) loading. For now, hard-cap to 500 to prevent
// pathological memory / header growth. Frontend can request more via future endpoint.
$contracts = $contractsQuery->limit(500)->get();
// Use pagination for contracts to avoid loading too many at once
// Default to 50 per page, but allow frontend to request more
$perPage = request()->integer('contracts_per_page', 50);
$contracts = $contractsQuery->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
// TEMP DEBUG: log what balances are being sent to Inertia (remove once issue resolved)
try {
@ -1234,49 +768,19 @@ public function show(ClientCase $clientCase)
// swallow
}
// Prepare contract reference and UUID maps from paginated contracts
$contractItems = $contracts instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator
? $contracts->items()
: $contracts->all();
$contractRefMap = [];
foreach ($contracts as $c) {
$contractUuidMap = [];
foreach ($contractItems as $c) {
$contractRefMap[$c->id] = $c->reference;
$contractUuidMap[$c->id] = $c->uuid;
}
// Merge client case and contract documents into a single array and include contract reference when applicable
$contractIds = $contracts->pluck('id');
// Include 'uuid' so frontend can build document routes (was causing missing 'document' param error)
// IMPORTANT: If there are no contracts for this case we must NOT return all contract documents from other cases.
if ($contractIds->isEmpty()) {
$contractDocs = collect();
} else {
$contractDocs = Document::query()
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
->where('documentable_type', Contract::class)
->whereIn('documentable_id', $contractIds)
->orderByDesc('created_at')
->limit(300) // cap to prevent excessive payload; add pagination later if needed
->get()
->map(function ($d) use ($contractRefMap) {
$arr = method_exists($d, 'toArray') ? $d->toArray() : (array) $d;
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
$arr['contract_uuid'] = optional(Contract::withTrashed()->find($d->documentable_id))->uuid;
return $arr;
});
}
$caseDocs = $case->documents()
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
->orderByDesc('created_at')
->limit(200)
->get()
->map(function ($d) use ($case) {
$arr = method_exists($d, 'toArray') ? $d->toArray() : (array) $d;
$arr['client_case_uuid'] = $case->uuid;
return $arr;
});
$mergedDocs = $caseDocs
->concat($contractDocs)
->sortByDesc('created_at')
->values();
$contractIds = collect($contractItems)->pluck('id');
// Resolve current segment for display when filtered
$currentSegment = null;
@ -1284,10 +788,55 @@ public function show(ClientCase $clientCase)
$currentSegment = \App\Models\Segment::query()->select('id', 'name')->find($segmentId);
}
// Load initial batch of documents (limit to reduce payload size)
$contractDocs = collect();
if ($contractIds->isNotEmpty()) {
// Build UUID map for all contracts (including trashed) to avoid N+1 queries
$allContractUuids = Contract::withTrashed()
->whereIn('id', $contractIds->all())
->pluck('uuid', 'id')
->toArray();
// Merge with contracts already loaded
$contractUuidMap = array_merge($contractUuidMap, $allContractUuids);
$contractDocs = Document::query()
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
->where('documentable_type', Contract::class)
->whereIn('documentable_id', $contractIds->all())
->orderByDesc('created_at')
->limit(50) // Initial batch - frontend can request more via separate endpoint if needed
->get()
->map(function ($d) use ($contractRefMap, $contractUuidMap) {
$arr = $d->toArray();
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
$arr['contract_uuid'] = $contractUuidMap[$d->documentable_id] ?? null;
return $arr;
});
}
$caseDocs = $case->documents()
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
->orderByDesc('created_at')
->limit(50) // Initial batch
->get()
->map(function ($d) use ($case) {
$arr = $d->toArray();
$arr['client_case_uuid'] = $case->uuid;
return $arr;
});
$mergedDocs = $caseDocs
->concat($contractDocs)
->sortByDesc('created_at')
->values();
return Inertia::render('Cases/Show', [
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(),
'client_case' => $case,
'contracts' => $contracts,
'contracts' => $contracts, // Now paginated
'documents' => $mergedDocs,
])->with([
// Active document templates for contracts (latest version per slug)
'contract_doc_templates' => \App\Models\DocumentTemplate::query()
->where('active', true)
@ -1326,9 +875,8 @@ function ($p) {
});
}
),
'documents' => $mergedDocs,
'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(),
'account_types' => \App\Models\AccountType::all(),
'contract_types' => $this->referenceCache->getContractTypes(),
'account_types' => $this->referenceCache->getAccountTypes(),
// Include decisions with auto-mail metadata and the linked email template entity_types for UI logic
'actions' => \App\Models\Action::query()
->with([

View File

@ -3,57 +3,51 @@
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()
->with('person')
->when($request->input('search'), function ($que, $search) {
$que->whereHas('person', function ($q) use ($search) {
$q->where('full_name', 'ilike', '%'.$search.'%');
});
->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');
})
->where('active', 1)
->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')
->addSelect([
// Number of client cases for this client that have at least one active contract
'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);
}),
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'),
])
->orderByDesc('created_at');
->with('person')
->orderByDesc('clients.created_at');
return Inertia::render('Client/Index', [
'clients' => $query
->paginate($request->integer('perPage', 15))
->paginate($request->integer('per_page', 15))
->withQueryString(),
'filters' => $request->only(['search']),
]);
@ -67,44 +61,37 @@ public function show(Client $client, Request $request)
->findOrFail($client->id);
$types = [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
return Inertia::render('Client/Show', [
'client' => $data,
'client_cases' => $data->clientCases()
->with(['person', 'client.person'])
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
'person',
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
))
->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([
'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);
}),
\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'),
])
->where('active', 1)
->orderByDesc('created_at')
->paginate($request->integer('perPage', 15))
->with(['person', 'client.person'])
->where('client_cases.active', 1)
->orderByDesc('client_cases.created_at')
->paginate($request->integer('per_page', 15))
->withQueryString(),
'types' => $types,
'filters' => $request->only(['search']),
@ -121,8 +108,31 @@ public function contracts(Client $client, Request $request)
$segmentId = $request->input('segment');
$contractsQuery = \App\Models\Contract::query()
->whereHas('clientCase', function ($q) use ($client) {
$q->where('client_id', $client->id);
->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($segmentId, function ($q) use ($segmentId) {
$q->join('contract_segment', function ($join) use ($segmentId) {
$join->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.segment_id', $segmentId)
->where('contract_segment.active', true);
});
})
->with([
'clientCase:id,uuid,person_id',
@ -132,42 +142,18 @@ public function contracts(Client $client, Request $request)
},
'account:id,accounts.contract_id,balance_amount',
])
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
->whereNull('deleted_at')
->when($from || $to, function ($q) use ($from, $to) {
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($segmentId, function ($q) use ($segmentId) {
$q->whereHas('segments', function ($s) use ($segmentId) {
$s->where('segments.id', $segmentId)
->where('contract_segment.active', true);
});
})
->orderByDesc('start_date');
->orderByDesc('contracts.start_date');
$segments = \App\Models\Segment::orderBy('name')->get(['id', 'name']);
$types = [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
return Inertia::render('Client/Contracts', [
'client' => $data,
'contracts' => $contractsQuery->paginate($request->integer('perPage', 20))->withQueryString(),
'contracts' => $contractsQuery->paginate($request->integer('per_page', 20))->withQueryString(),
'filters' => $request->only(['from', 'to', 'search', 'segment']),
'segments' => $segments,
'types' => $types,

View File

@ -3,11 +3,13 @@
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;
@ -168,8 +170,8 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
// Provide minimal types for PersonInfoGrid
$types = [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
// Case activities (compact for phone): latest 20 with relations
@ -235,7 +237,7 @@ function ($q) {
'contracts' => $contracts,
'documents' => $documents,
'types' => $types,
'account_types' => \App\Models\AccountType::all(),
'account_types' => $this->referenceCache->getAccountTypes(),
// Provide decisions (filtered by segment) with linked email template metadata (entity_types, allow_attachments)
'actions' => $actions,
'activities' => $activities,

View File

@ -0,0 +1,379 @@
<?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;
}
}

View File

@ -0,0 +1,36 @@
<?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
{
//
}
}

View File

@ -0,0 +1,53 @@
<?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");
}
}

View File

@ -0,0 +1,78 @@
<?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');
}
}

View File

@ -0,0 +1,95 @@
<?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');
}
}

View File

@ -0,0 +1,33 @@
<?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);
}
}

View File

@ -0,0 +1,54 @@
<?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;
}

View File

@ -0,0 +1,51 @@
<?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");
}
}

View File

@ -0,0 +1,60 @@
<?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']);
}
}

View File

@ -0,0 +1,29 @@
<?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;
}
}

View File

@ -0,0 +1,54 @@
<?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;
}
}

View File

@ -0,0 +1,221 @@
<?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');
}
}

View File

@ -0,0 +1,68 @@
<?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(),
];
}
}

View File

@ -5,4 +5,5 @@
App\Providers\AuthServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
App\Providers\ReportServiceProvider::class,
];

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$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": {}
}

View File

@ -5,9 +5,9 @@
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"tijsverkoyen/css-to-inline-styles": "^2.2",
"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",
@ -16,9 +16,11 @@
"laravel/sanctum": "^4.0",
"laravel/scout": "^10.11",
"laravel/tinker": "^2.9",
"maatwebsite/excel": "^3.1",
"meilisearch/meilisearch-php": "^1.11",
"robertboes/inertia-breadcrumbs": "dev-laravel-12",
"tightenco/ziggy": "^2.0"
"tightenco/ziggy": "^2.0",
"tijsverkoyen/css-to-inline-styles": "^2.2"
},
"require-dev": {
"fakerphp/faker": "^1.23",

958
composer.lock generated

File diff suppressed because it is too large Load Diff

10
config/reports.php Normal file
View File

@ -0,0 +1,10 @@
<?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',
];

View File

@ -0,0 +1,143 @@
<?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;
}
};

2292
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,13 +9,14 @@
"@inertiajs/vue3": "2.0",
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/typography": "^0.5.10",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.16",
"axios": "^1.7.4",
"laravel-vite-plugin": "^2.0.1",
"laravel-vite-plugin": "^2.0.1",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"tailwindcss": "^4.1.16",
"vite": "^7.1.7",
"vue": "^3.3.13"
},
@ -25,24 +26,33 @@
"@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",
"@vee-validate/zod": "^4.15.1",
"@vuepic/vue-datepicker": "^11.0.2",
"@vueuse/core": "^14.0.0",
"apexcharts": "^4.0.0",
"flowbite": "^2.5.2",
"flowbite-vue": "^0.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"lucide-vue-next": "^0.552.0",
"material-design-icons-iconfont": "^6.7.0",
"preline": "^2.7.0",
"reka-ui": "^2.5.1",
"quill": "^1.3.7",
"reka-ui": "^2.6.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"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",
"vue-currency-input": "^3.2.1"
"zod": "^3.25.76"
}
}

View File

@ -1,6 +1,9 @@
import tailwindcss from '@tailwindcss/postcss';
import autoprefixer from 'autoprefixer';
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
plugins: [
tailwindcss(),
autoprefixer(),
],
};

View File

@ -1,10 +1,142 @@
@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;
@import "tailwindcss";
@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;
@ -12,3 +144,124 @@ [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.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.129 0.042 264.695);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.129 0.042 264.695);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.984 0.003 247.858);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.279 0.041 260.031);
--input: oklch(0.279 0.041 260.031);
--ring: oklch(0.446 0.043 257.281);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(0.279 0.041 260.031);
--sidebar-ring: oklch(0.446 0.043 257.281);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -1,228 +0,0 @@
<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>

View File

@ -1,197 +0,0 @@
<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>

View File

@ -1,3 +1,3 @@
<template>
<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>
<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>
</template>

View File

@ -1,5 +1,5 @@
<script setup>
import { FwbButton, FwbTable, FwbTableBody, FwbTableCell, FwbTableHead, FwbTableHeadCell, FwbTableRow } from 'flowbite-vue';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
import DialogModal from './DialogModal.vue';
import { useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
@ -9,6 +9,7 @@ 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({
@ -121,30 +122,32 @@ 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' : '']">
<FwbTable hoverable striped class="text-sm">
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur supports-[backdrop-filter]:bg-gray-50/80 border-b border-gray-200 shadow-sm">
<FwbTableHeadCell
v-for="(h, hIndex) in header"
:key="hIndex"
class="sticky top-0 z-10 uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6 bg-gray-50/90"
>{{ h.data }}</FwbTableHeadCell>
<FwbTableHeadCell v-if="editor" class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90"></FwbTableHeadCell>
<FwbTableHeadCell v-else class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90" />
</FwbTableHead>
<FwbTableBody>
<FwbTableRow v-for="(row, key, parent_index) in body" :key="key" :class="row.options.class">
<FwbTableCell v-for="(col, colIndex) in row.cols" :key="colIndex" class="align-middle">
<Table class="text-sm">
<TableHeader class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur supports-[backdrop-filter]:bg-gray-50/80 border-b border-gray-200 shadow-sm">
<TableRow class="border-b">
<TableHead
v-for="(h, hIndex) in header"
:key="hIndex"
class="sticky top-0 z-10 uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6 bg-gray-50/90"
>{{ h.data }}</TableHead>
<TableHead v-if="editor" class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90"></TableHead>
<TableHead v-else class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="(row, key, parent_index) in body" :key="key" :class="[row.options.class, 'hover:bg-gray-50/50']">
<TableCell v-for="(col, colIndex) in row.cols" :key="colIndex" class="align-middle">
<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>
</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>
</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>
<div v-if="!body || body.length === 0" class="p-6 text-center text-sm text-gray-500">No records found.</div>
</div>
</div>

View File

@ -13,9 +13,9 @@ 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 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>
<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>
</li>
</ol>
</nav>
</template>
</template>

View File

@ -1,6 +1,5 @@
<script setup>
import DialogModal from './DialogModal.vue';
import PrimaryButton from './PrimaryButton.vue';
import DeleteDialog from './Dialogs/DeleteDialog.vue';
const props = defineProps({
show: { type: Boolean, default: false },
@ -9,6 +8,8 @@ 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']);
@ -18,21 +19,15 @@ const onConfirm = () => emit('confirm');
</script>
<template>
<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>
<DeleteDialog
:show="show"
:title="title"
:message="message"
:item-name="itemName"
:confirm-text="confirmText"
:cancel-text="cancelText"
:processing="processing"
@close="onClose"
@confirm="onConfirm"
/>
</template>

View File

@ -1,6 +1,8 @@
<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 },
@ -14,6 +16,7 @@ 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"]);
@ -81,7 +84,7 @@ onMounted(() => {
:placeholder="placeholder"
:disabled="disabled"
:required="required"
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"
: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)"
autocomplete="off"
@change="$emit('change', numberValue)"
/>

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 dark:text-gray-400 dark:border-gray-700">
<div class="text-sm font-medium text-center text-gray-500 border-b border-gray-200">
<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 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
'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
}"
>
{{ tab.title }}
@ -35,4 +35,4 @@ provide('selected', selected);
<div id="default-tab-content">
<slot />
</div>
</template>
</template>

View File

@ -0,0 +1,54 @@
<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>

View File

@ -0,0 +1,134 @@
<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>

View File

@ -0,0 +1,884 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { router } from '@inertiajs/vue3';
import DataTableToolbar from './DataTableToolbar.vue';
import SkeletonTable from '../Skeleton/SkeletonTable.vue';
import EmptyState from '../EmptyState.vue';
import Pagination from '../Pagination.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import {
faChevronLeft,
faChevronRight,
faSort,
faSortUp,
faSortDown,
} from '@fortawesome/free-solid-svg-icons';
const props = defineProps({
// Data
rows: { type: Array, default: () => [] },
columns: {
type: Array,
required: true,
validator: (cols) =>
cols.every(
(col) => col.key && col.label && typeof col.key === 'string' && typeof col.label === 'string'
),
},
// Pagination (for server-side)
meta: {
type: Object,
default: null,
validator: (meta) =>
!meta ||
(typeof meta.current_page === 'number' &&
typeof meta.per_page === 'number' &&
typeof meta.total === 'number' &&
typeof meta.last_page === 'number'),
},
// Sorting
sort: {
type: Object,
default: () => ({ key: null, direction: null }),
},
// Search
search: { type: String, default: '' },
// Loading state
loading: { type: Boolean, default: false },
// Client-side pagination (when meta is null)
pageSize: { type: Number, default: 10 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
// Routing (for server-side)
routeName: { type: String, default: null },
routeParams: { type: Object, default: () => ({}) },
pageParamName: { type: String, default: 'page' },
onlyProps: { type: Array, default: () => [] },
// Features
showToolbar: { type: Boolean, default: true },
showSearch: { type: Boolean, default: false },
showPageSize: { type: Boolean, default: false },
showPagination: { type: Boolean, default: true },
showFilters: { type: Boolean, default: false },
showExport: { type: Boolean, default: false },
showAdd: { type: Boolean, default: false }, // Show add buttons dropdown
showOptions: { type: Boolean, default: false }, // Show custom options slot
showSelectedCount: { type: Boolean, default: false }, // Show selected count badge
showOptionsMenu: { type: Boolean, default: false }, // Show options menu (three dots)
compactToolbar: { type: Boolean, default: false }, // Compact mode: move search/page size to menu
hasActiveFilters: { type: Boolean, default: false }, // External indicator for active filters
rowKey: { type: [String, Function], default: 'uuid' },
selectable: { type: Boolean, default: false },
striped: { type: Boolean, default: false },
hoverable: { type: Boolean, default: true },
// Empty state
emptyText: { type: String, default: 'Ni podatkov' },
emptyIcon: { type: [String, Object, Array], default: null },
emptyDescription: { type: String, default: null },
// Actions
showActions: { type: Boolean, default: false },
actionsPosition: { type: String, default: 'right', validator: (v) => ['left', 'right'].includes(v) },
// Mobile
mobileCardView: { type: Boolean, default: true },
mobileBreakpoint: { type: Number, default: 768 }, // Tailwind md breakpoint
// State preservation
preserveState: { type: Boolean, default: true },
preserveScroll: { type: Boolean, default: true },
});
const emit = defineEmits([
'update:search',
'update:sort',
'update:page',
'update:pageSize',
'row:click',
'row:select',
'selection:change',
]);
// Determine if this is server-side (has meta and routeName)
const isServerSide = computed(() => !!(props.meta && props.routeName));
const isClientSide = computed(() => !isServerSide.value);
// Row key helper
function keyOf(row) {
if (typeof props.rowKey === 'function') return props.rowKey(row);
if (typeof props.rowKey === 'string' && row && row[props.rowKey] != null)
return row[props.rowKey];
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
}
// Client-side sorting
const sortedRows = computed(() => {
if (isServerSide.value || !props.sort?.key || !props.sort?.direction) {
return props.rows;
}
const key = props.sort.key;
const direction = props.sort.direction;
const sorted = [...props.rows];
sorted.sort((a, b) => {
let aVal = a?.[key];
let bVal = b?.[key];
// Handle nulls/undefined
if (aVal == null) return 1;
if (bVal == null) return -1;
// Handle dates
if (aVal instanceof Date || (typeof aVal === 'string' && aVal.match(/\d{4}-\d{2}-\d{2}/))) {
aVal = new Date(aVal);
bVal = new Date(bVal);
}
// Handle numbers
if (typeof aVal === 'number' && typeof bVal === 'number') {
return direction === 'asc' ? aVal - bVal : bVal - aVal;
}
// Handle strings
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (direction === 'asc') {
return aStr.localeCompare(bStr);
}
return bStr.localeCompare(aStr);
});
return sorted;
});
// Client-side pagination
const currentPage = computed(() => {
if (isServerSide.value) return props.meta?.current_page ?? 1;
return internalPage.value;
});
const internalPage = ref(1);
// Use computed for pageSize to always reflect the correct value
// For server-side: use meta.per_page, for client-side: use internal state or props.pageSize
const internalPageSize = computed({
get: () => {
if (isServerSide.value && props.meta?.per_page) {
return props.meta.per_page;
}
return internalPageSizeState.value ?? props.pageSize;
},
set: (value) => {
internalPageSizeState.value = value;
}
});
// Internal state for client-side or when user changes page size before server responds
const internalPageSizeState = ref(null);
const totalPages = computed(() => {
if (isServerSide.value) return props.meta?.last_page ?? 1;
return Math.ceil(sortedRows.value.length / internalPageSize.value);
});
const paginatedRows = computed(() => {
if (isServerSide.value) return props.rows;
const start = (currentPage.value - 1) * internalPageSize.value;
const end = start + internalPageSize.value;
return sortedRows.value.slice(start, end);
});
// Client-side search
const filteredRows = computed(() => {
if (isServerSide.value || !internalSearch.value) {
return paginatedRows.value;
}
const searchTerm = internalSearch.value.toLowerCase();
return paginatedRows.value.filter((row) => {
return props.columns.some((col) => {
const value = row?.[col.key];
if (value == null) return false;
return String(value).toLowerCase().includes(searchTerm);
});
});
});
// Search handling
const internalSearch = ref(props.search);
watch(
() => props.search,
(newVal) => {
internalSearch.value = newVal;
}
);
// Selection
const selectedRows = ref(new Set());
const isAllSelected = computed(() => {
if (filteredRows.value.length === 0) return false;
return filteredRows.value.every((row) => selectedRows.value.has(keyOf(row)));
});
const isSomeSelected = computed(() => {
return (
selectedRows.value.size > 0 &&
filteredRows.value.some((row) => selectedRows.value.has(keyOf(row)))
);
});
function toggleSelectAll() {
if (isAllSelected.value) {
filteredRows.value.forEach((row) => {
selectedRows.value.delete(keyOf(row));
});
} else {
filteredRows.value.forEach((row) => {
selectedRows.value.add(keyOf(row));
});
}
emit('selection:change', Array.from(selectedRows.value));
}
function toggleSelectRow(row) {
const key = keyOf(row);
if (selectedRows.value.has(key)) {
selectedRows.value.delete(key);
} else {
selectedRows.value.add(key);
}
emit('row:select', row, selectedRows.value.has(key));
emit('selection:change', Array.from(selectedRows.value));
}
// Sorting
function toggleSort(col) {
if (!col.sortable) return;
if (isServerSide.value) {
const current = props.sort || { key: null, direction: null };
let direction = 'asc';
if (current.key === col.key) {
direction =
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
}
emit('update:sort', { key: direction ? col.key : null, direction });
doServerRequest({ sort: direction ? col.key : null, direction, page: 1 });
} else {
const current = props.sort || { key: null, direction: null };
let direction = 'asc';
if (current.key === col.key) {
direction =
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
}
emit('update:sort', { key: direction ? col.key : null, direction });
}
}
function getSortIcon(col) {
if (props.sort?.key !== col.key) return faSort;
if (props.sort?.direction === 'asc') return faSortUp;
if (props.sort?.direction === 'desc') return faSortDown;
return faSort;
}
// Server-side request
function doServerRequest(overrides = {}) {
// Preserve existing query parameters from URL
const existingParams = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
const q = {
...existingParams, // Preserve all existing query parameters
per_page: overrides.perPage ?? props.meta?.per_page ?? internalPageSizeState.value ?? internalPageSize.value,
sort: overrides.sort ?? props.sort?.key ?? existingParams.sort ?? null,
direction: overrides.direction ?? props.sort?.direction ?? existingParams.direction ?? null,
search: overrides.search ?? internalSearch.value ?? existingParams.search ?? '',
};
const pageParam = props.pageParamName || 'page';
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
if (pageParam !== 'page') {
delete q.page;
}
// Clean nulls and empty strings
Object.keys(q).forEach((k) => {
if (q[k] === null || q[k] === undefined || q[k] === '') delete q[k];
});
const url = route(props.routeName, props.routeParams || {});
router.get(
url,
q,
{
preserveScroll: props.preserveScroll,
preserveState: props.preserveState,
replace: true,
only: props.onlyProps.length ? props.onlyProps : undefined,
onSuccess: () => {
// Scroll to top of table after server request completes
setTimeout(() => {
const tableElement = document.querySelector('[data-table-container]');
if (tableElement) {
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, 100);
},
}
);
}
function handleSearchChange(value) {
internalSearch.value = value;
emit('update:search', value);
if (isServerSide.value) {
clearTimeout(searchTimer.value);
searchTimer.value = setTimeout(() => {
doServerRequest({ search: value, page: 1 });
}, 300);
}
}
function handlePageSizeChange(size) {
const newSize = Number(size);
internalPageSizeState.value = newSize;
emit('update:pageSize', newSize);
if (isServerSide.value) {
// Reset to page 1 when changing page size to avoid being on a non-existent page
doServerRequest({ perPage: newSize, page: 1 });
} else {
// Calculate total pages with new size
const newTotalPages = Math.ceil(sortedRows.value.length / newSize);
// If current page exceeds new total, reset to last page or page 1
const targetPage = currentPage.value > newTotalPages && newTotalPages > 0 ? newTotalPages : 1;
internalPage.value = targetPage;
emit('update:page', targetPage);
}
}
function handlePageChange(page) {
if (isServerSide.value) {
doServerRequest({ page });
} else {
internalPage.value = page;
emit('update:page', page);
}
// Scroll to top of table after page change
setTimeout(() => {
const tableElement = document.querySelector('[data-table-container]');
if (tableElement) {
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, isServerSide.value ? 100 : 50);
}
const searchTimer = ref(null);
// Mobile detection
const isMobile = ref(false);
if (typeof window !== 'undefined') {
const checkMobile = () => {
isMobile.value = window.innerWidth < props.mobileBreakpoint;
};
checkMobile();
window.addEventListener('resize', checkMobile);
}
// Display rows
const displayRows = computed(() => {
if (isServerSide.value || !internalSearch.value) {
return paginatedRows.value;
}
return filteredRows.value;
});
const total = computed(() => {
if (isServerSide.value) return props.meta?.total ?? 0;
return internalSearch.value ? filteredRows.value.length : sortedRows.value.length;
});
const from = computed(() => {
if (isServerSide.value) return props.meta?.from ?? 0;
return total.value === 0 ? 0 : (currentPage.value - 1) * internalPageSize.value + 1;
});
const to = computed(() => {
if (isServerSide.value) return props.meta?.to ?? 0;
return Math.min(currentPage.value * internalPageSize.value, total.value);
});
// Export functionality
function handleExport(format) {
const data = displayRows.value.map((row) => {
const exported = {};
props.columns.forEach((col) => {
exported[col.label] = row?.[col.key] ?? '';
});
return exported;
});
if (format === 'csv') {
exportToCSV(data);
} else if (format === 'xlsx') {
exportToXLSX(data);
}
}
function exportToCSV(data) {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csvContent = [
headers.join(','),
...data.map((row) =>
headers
.map((header) => {
const value = row[header];
if (value == null) return '';
const stringValue = String(value).replace(/"/g, '""');
return `"${stringValue}"`;
})
.join(',')
),
].join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `export_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportToXLSX(data) {
// For XLSX, we'll use a CSV-like format or alert user to install xlsx library
// Simple implementation: use CSV format with .xlsx extension
exportToCSV(data);
// In production, you might want to use a library like 'xlsx' or 'exceljs'
}
// Generate visible page numbers with ellipsis
function getVisiblePages() {
const pages = [];
const total = totalPages.value;
const current = currentPage.value;
const maxVisible = 7;
if (total <= maxVisible) {
for (let i = 1; i <= total; i++) {
pages.push(i);
}
return pages;
}
// Always show first page
pages.push(1);
// Calculate start and end
let start = Math.max(2, current - 1);
let end = Math.min(total - 1, current + 1);
// Adjust if near start
if (current <= 3) {
end = Math.min(4, total - 1);
}
// Adjust if near end
if (current >= total - 2) {
start = Math.max(2, total - 3);
}
// Add ellipsis after first page if needed
if (start > 2) {
pages.push('...');
}
// Add middle pages
for (let i = start; i <= end; i++) {
pages.push(i);
}
// Add ellipsis before last page if needed
if (end < total - 1) {
pages.push('...');
}
// Always show last page
if (total > 1) {
pages.push(total);
}
return pages;
}
</script>
<template>
<div class="w-full space-y-4">
<!-- Toolbar -->
<DataTableToolbar
v-if="showToolbar"
:search="internalSearch"
:show-search="showSearch"
:show-page-size="showPageSize"
:page-size="internalPageSize"
:page-size-options="pageSizeOptions"
:selected-count="selectedRows.size"
:show-selected-count="showSelectedCount"
:show-export="showExport"
:show-add="showAdd"
:show-options="showOptions"
:show-filters="showFilters"
:show-options-menu="showOptionsMenu"
:has-active-filters="hasActiveFilters"
:compact="compactToolbar"
@update:search="handleSearchChange"
@update:page-size="handlePageSizeChange"
@export="handleExport"
>
<template #add>
<slot name="toolbar-add" />
</template>
<template #options>
<slot name="toolbar-options" />
</template>
<template #filters>
<slot name="toolbar-filters" />
</template>
<template #actions>
<slot name="toolbar-actions" />
</template>
</DataTableToolbar>
<!-- Table Container -->
<div
data-table-container
class="relative overflow-hidden"
>
<!-- Desktop Table View -->
<div v-if="!isMobile || !mobileCardView" class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<!-- Header -->
<thead>
<tr>
<!-- Select All Checkbox -->
<th
v-if="selectable"
class="w-12 px-6 py-3 text-left"
scope="col"
>
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isSomeSelected && !isAllSelected"
@change="toggleSelectAll"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</th>
<!-- Column Headers -->
<th
v-for="col in columns"
:key="col.key"
scope="col"
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
:class="[
col.class,
col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : 'text-left',
]"
>
<button
v-if="col.sortable"
type="button"
class="group inline-flex items-center gap-1.5 hover:text-gray-700 transition-colors"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
<span>{{ col.label }}</span>
<FontAwesomeIcon
:icon="getSortIcon(col)"
class="w-3 h-3 transition-colors"
:class="{
'text-gray-700': sort?.key === col.key,
'text-gray-400 group-hover:text-gray-500': sort?.key !== col.key,
}"
/>
</button>
<span v-else>{{ col.label }}</span>
</th>
<!-- Actions Column -->
<th
v-if="showActions || $slots.actions"
scope="col"
class="relative w-px px-6 py-3"
>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<!-- Body -->
<tbody class="divide-y divide-gray-200 bg-white">
<!-- Loading State -->
<template v-if="loading">
<tr>
<td :colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)" class="px-6 py-4">
<SkeletonTable :rows="5" :cols="columns.length" />
</td>
</tr>
</template>
<!-- Empty State -->
<template v-else-if="!loading && displayRows.length === 0">
<tr>
<td
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
class="px-6 py-12 text-center"
>
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</td>
</tr>
</template>
<!-- Rows -->
<template v-else>
<tr
v-for="(row, idx) in displayRows"
:key="keyOf(row)"
@click="(e) => { const interactive = e.target.closest('button, a, [role=button], [data-dropdown], .relative'); if (interactive) return; $emit('row:click', row, idx); }"
class="transition-colors"
:class="{
'cursor-pointer': !!$attrs.onRowClick,
'bg-gray-50': striped && idx % 2 === 1,
'hover:bg-gray-50': hoverable && !selectedRows.has(keyOf(row)),
'bg-primary-50': selectedRows.has(keyOf(row)),
}"
>
<!-- Select Checkbox -->
<td v-if="selectable" class="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
:checked="selectedRows.has(keyOf(row))"
@click.stop="toggleSelectRow(row)"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</td>
<!-- Data Cells -->
<td
v-for="col in columns"
:key="col.key"
class="px-6 py-4 whitespace-nowrap text-sm"
:class="[
col.class,
col.align === 'right'
? 'text-right text-gray-900'
: col.align === 'center'
? 'text-center text-gray-900'
: 'text-left text-gray-900',
]"
>
<slot
:name="`cell-${col.key}`"
:row="row"
:column="col"
:value="row?.[col.key]"
:index="idx"
>
<slot name="cell" :row="row" :column="col" :value="row?.[col.key]" :index="idx">
<span>{{ row?.[col.key] ?? '—' }}</span>
</slot>
</slot>
</td>
<!-- Actions Cell -->
<td
v-if="showActions || $slots.actions"
class="relative whitespace-nowrap py-4 pl-3 pr-6 text-right text-sm font-medium"
>
<slot name="actions" :row="row" :index="idx">
<slot name="row-actions" :row="row" :index="idx" />
</slot>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div v-else-if="isMobile && mobileCardView" class="divide-y divide-gray-200">
<template v-if="loading">
<div class="p-4">
<SkeletonTable :rows="3" :cols="1" />
</div>
</template>
<template v-else-if="!loading && displayRows.length === 0">
<div class="p-6">
<EmptyState
:icon="emptyIcon"
:title="emptyText"
:description="emptyDescription"
size="sm"
/>
</div>
</template>
<template v-else>
<div
v-for="(row, idx) in displayRows"
:key="keyOf(row)"
@click="$emit('row:click', row, idx)"
class="p-4 space-y-2 hover:bg-gray-50 transition-colors"
:class="{ 'cursor-pointer': !!$attrs.onRowClick }"
>
<slot name="mobile-card" :row="row" :index="idx">
<!-- Default mobile card layout -->
<div
v-for="col in columns.slice(0, 3)"
:key="col.key"
class="flex justify-between items-start"
>
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">
{{ col.label }}
</span>
<span class="text-sm text-gray-900 text-right">
<slot
:name="`cell-${col.key}`"
:row="row"
:column="col"
:value="row?.[col.key]"
:index="idx"
>
{{ row?.[col.key] ?? '—' }}
</slot>
</span>
</div>
<div v-if="showActions || $slots.actions" class="pt-2 border-t border-gray-100">
<slot name="actions" :row="row" :index="idx">
<slot name="row-actions" :row="row" :index="idx" />
</slot>
</div>
</slot>
</div>
</template>
</div>
</div>
<!-- Pagination -->
<div v-if="showPagination && totalPages > 1">
<!-- Use existing Pagination component for server-side -->
<template v-if="isServerSide && meta?.links">
<Pagination
:links="meta.links"
:from="from"
:to="to"
:total="total"
/>
</template>
<!-- Custom pagination for client-side -->
<template v-else>
<div class="flex flex-1 justify-between sm:hidden">
<button
@click="handlePageChange(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Prejšnja
</button>
<button
@click="handlePageChange(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Naslednja
</button>
</div>
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Prikazano
<span class="font-medium">{{ from }}</span>
do
<span class="font-medium">{{ to }}</span>
od
<span class="font-medium">{{ total }}</span>
rezultatov
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<button
@click="handlePageChange(currentPage - 1)"
:disabled="currentPage <= 1"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Prejšnja</span>
<FontAwesomeIcon :icon="faChevronLeft" class="h-5 w-5" />
</button>
<template v-for="page in getVisiblePages()" :key="page">
<button
v-if="page !== '...'"
@click="handlePageChange(page)"
:aria-current="page === currentPage ? 'page' : undefined"
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20"
:class="
page === currentPage
? 'z-10 bg-primary-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600'
: 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0'
"
>
{{ page }}
</button>
<span
v-else
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300"
>
...
</span>
</template>
<button
@click="handlePageChange(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="sr-only">Naslednja</span>
<FontAwesomeIcon :icon="faChevronRight" class="h-5 w-5" />
</button>
</nav>
</div>
</div>
</template>
</div>
</div>
</template>

View File

@ -1,13 +1,15 @@
<script setup>
import { computed, ref, watch } from "vue";
import SkeletonTable from "@/Components/Skeleton/SkeletonTable.vue";
import EmptyState from "@/Components/EmptyState.vue";
import {
FwbTable,
FwbTableHead,
FwbTableHeadCell,
FwbTableBody,
FwbTableRow,
FwbTableCell,
} from "flowbite-vue";
Table,
TableHeader,
TableHead,
TableBody,
TableRow,
TableCell,
} from "@/Components/ui/table";
const props = defineProps({
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class?, formatter? }]
@ -197,42 +199,43 @@ function setPageSize(ps) {
<div
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
>
<FwbTable hoverable striped class="text-sm">
<FwbTableHead
<Table class="text-sm">
<TableHeader
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
>
<FwbTableHeadCell v-for="col in columns" :key="col.key" :class="col.class">
<button
v-if="col.sortable"
type="button"
class="inline-flex items-center gap-1 hover:text-indigo-600"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
<span class="uppercase">{{ col.label }}</span>
<span v-if="sort?.key === col.key && sort.direction === 'asc'"></span>
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
></span
<TableRow class="border-b">
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
<button
v-if="col.sortable"
type="button"
class="inline-flex items-center gap-1 hover:text-indigo-600"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
</button>
<span v-else>{{ col.label }}</span>
</FwbTableHeadCell>
<FwbTableHeadCell v-if="$slots.actions" class="w-px">&nbsp;</FwbTableHeadCell>
</FwbTableHead>
<span class="uppercase">{{ col.label }}</span>
<span v-if="sort?.key === col.key && sort.direction === 'asc'"></span>
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
></span
>
</button>
<span v-else>{{ col.label }}</span>
</TableHead>
<TableHead v-if="$slots.actions" class="w-px">&nbsp;</TableHead>
</TableRow>
</TableHeader>
<FwbTableBody>
<TableBody>
<template v-if="!loading && pageRows.length">
<FwbTableRow
<TableRow
v-for="(row, idx) in pageRows"
:key="keyOf(row)"
@click="$emit('row:click', row)"
class="cursor-default"
class="cursor-default hover:bg-gray-50/50"
>
<FwbTableCell
<TableCell
v-for="col in columns"
:key="col.key"
:class="col.class"
:align="col.align || 'left'"
>
<template v-if="$slots['cell-' + col.key]">
<slot
@ -255,33 +258,37 @@ function setPageSize(ps) {
<template v-else>
{{ col.formatter ? col.formatter(row) : row?.[col.key] ?? "" }}
</template>
</FwbTableCell>
<FwbTableCell v-if="$slots.actions" class="w-px text-right">
</TableCell>
<TableCell v-if="$slots.actions" class="w-px text-right">
<slot name="actions" :row="row" :index="idx" />
</FwbTableCell>
</FwbTableRow>
</TableCell>
</TableRow>
</template>
<template v-else-if="loading">
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<div class="p-6 text-center text-gray-500">Nalagam...</div>
</FwbTableCell>
</FwbTableRow>
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<SkeletonTable :rows="5" :cols="columns.length" />
</TableCell>
</TableRow>
</template>
<template v-else>
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<slot name="empty">
<div class="p-6 text-center text-gray-500">{{ emptyText }}</div>
<EmptyState
:title="emptyText"
size="sm"
/>
</slot>
</FwbTableCell>
</FwbTableRow>
</TableCell>
</TableRow>
</template>
</FwbTableBody>
</FwbTable>
</TableBody>
</Table>
</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"
>

View File

@ -1,14 +1,16 @@
<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 {
FwbTable,
FwbTableHead,
FwbTableHeadCell,
FwbTableBody,
FwbTableRow,
FwbTableCell,
} from "flowbite-vue";
Table,
TableHeader,
TableHead,
TableBody,
TableRow,
TableCell,
} from "@/Components/ui/table";
const props = defineProps({
columns: { type: Array, required: true }, // [{ key, label, sortable?, align?, class? }]
@ -94,7 +96,8 @@ function setPageSize(ps) {
function doRequest(overrides = {}) {
const q = {
...props.query,
perPage: overrides.perPage ?? props.meta?.per_page ?? props.pageSize ?? 10,
// Laravel expects snake_case per_page
per_page: 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 ?? "",
@ -197,42 +200,43 @@ function goToPageInput() {
<div
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
>
<FwbTable hoverable striped class="text-sm">
<FwbTableHead
<Table class="text-sm">
<TableHeader
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
>
<FwbTableHeadCell v-for="col in columns" :key="col.key" :class="col.class">
<button
v-if="col.sortable"
type="button"
class="inline-flex items-center gap-1 hover:text-indigo-600"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
<span class="uppercase">{{ col.label }}</span>
<span v-if="sort?.key === col.key && sort.direction === 'asc'"></span>
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
></span
<TableRow class="border-b">
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
<button
v-if="col.sortable"
type="button"
class="inline-flex items-center gap-1 hover:text-indigo-600"
@click="toggleSort(col)"
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
>
</button>
<span v-else>{{ col.label }}</span>
</FwbTableHeadCell>
<FwbTableHeadCell v-if="$slots.actions" class="w-px">&nbsp;</FwbTableHeadCell>
</FwbTableHead>
<span class="uppercase">{{ col.label }}</span>
<span v-if="sort?.key === col.key && sort.direction === 'asc'"></span>
<span v-else-if="sort?.key === col.key && sort.direction === 'desc'"
></span
>
</button>
<span v-else>{{ col.label }}</span>
</TableHead>
<TableHead v-if="$slots.actions" class="w-px">&nbsp;</TableHead>
</TableRow>
</TableHeader>
<FwbTableBody>
<TableBody>
<template v-if="!loading && rows.length">
<FwbTableRow
<TableRow
v-for="(row, idx) in rows"
:key="keyOf(row)"
@click="$emit('row:click', row)"
class="cursor-default"
class="cursor-default hover:bg-gray-50/50"
>
<FwbTableCell
<TableCell
v-for="col in columns"
:key="col.key"
:class="col.class"
:align="col.align || 'left'"
>
<template v-if="$slots['cell-' + col.key]">
<slot
@ -255,30 +259,30 @@ function goToPageInput() {
<template v-else>
{{ row?.[col.key] ?? "" }}
</template>
</FwbTableCell>
<FwbTableCell v-if="$slots.actions" class="w-px text-right">
</TableCell>
<TableCell v-if="$slots.actions" class="w-px text-right">
<slot name="actions" :row="row" :index="idx" />
</FwbTableCell>
</FwbTableRow>
</TableCell>
</TableRow>
</template>
<template v-else-if="loading">
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<div class="p-6 text-center text-gray-500">Nalagam...</div>
</FwbTableCell>
</FwbTableRow>
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<SkeletonTable :rows="5" :cols="columns.length" />
</TableCell>
</TableRow>
</template>
<template v-else>
<FwbTableRow>
<FwbTableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<TableRow>
<TableCell :colspan="columns.length + ($slots.actions ? 1 : 0)">
<slot name="empty">
<div class="p-6 text-center text-gray-500">{{ emptyText }}</div>
</slot>
</FwbTableCell>
</FwbTableRow>
</TableCell>
</TableRow>
</template>
</FwbTableBody>
</FwbTable>
</TableBody>
</Table>
</div>
<nav

View File

@ -0,0 +1,318 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faSearch, faTimes, faDownload, faEllipsisVertical, faGear, faPlus, faFilter } from '@fortawesome/free-solid-svg-icons';
import Dropdown from '../Dropdown.vue';
import { Input } from '@/Components/ui/input';
import { Button } from '@/Components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Label } from '@/Components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/Components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
const props = defineProps({
search: { type: String, default: '' },
showSearch: { type: Boolean, default: false },
showPageSize: { type: Boolean, default: false },
pageSize: { type: Number, default: 10 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
selectedCount: { type: Number, default: 0 },
showSelectedCount: { type: Boolean, default: false }, // Control visibility of selected count badge
showExport: { type: Boolean, default: false },
showAdd: { type: Boolean, default: false }, // Control visibility of add buttons dropdown
showOptions: { type: Boolean, default: false }, // Control visibility of custom options slot
showFilters: { type: Boolean, default: false }, // Control visibility of filters button
showOptionsMenu: { type: Boolean, default: false }, // Control visibility of options menu (three dots)
compact: { type: Boolean, default: false }, // New prop to toggle compact menu mode
hasActiveFilters: { type: Boolean, default: false }, // External indicator for active filters
});
const emit = defineEmits(['update:search', 'update:page-size', 'export']);
const internalSearch = ref(props.search);
const menuOpen = ref(false);
watch(
() => props.search,
(newVal) => {
internalSearch.value = newVal;
}
);
const hasActiveFilters = computed(() => {
return !!internalSearch.value || props.selectedCount > 0;
});
function clearSearch() {
internalSearch.value = '';
emit('update:search', '');
}
function handleSearchInput() {
emit('update:search', internalSearch.value);
}
function handlePageSizeChange(value) {
emit('update:page-size', Number(value));
}
function handleExport(format) {
emit('export', format);
menuOpen.value = false;
}
</script>
<template>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<!-- Left side: Search and Add buttons dropdown -->
<div class="flex items-center gap-3 flex-1">
<!-- Search (always visible if showSearch is true) -->
<div v-if="showSearch && !compact" class="flex-1 max-w-sm">
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 z-10">
<FontAwesomeIcon :icon="faSearch" class="h-4 w-4 text-gray-400" />
</div>
<Input
v-model="internalSearch"
@input="handleSearchInput"
type="text"
placeholder="Iskanje..."
class="pl-10"
:class="internalSearch ? 'pr-10' : ''"
/>
<Button
v-if="internalSearch"
@click="clearSearch"
type="button"
variant="ghost"
size="icon"
class="absolute inset-y-0 right-0 h-full w-auto px-3 text-gray-400 hover:text-gray-600"
>
<FontAwesomeIcon :icon="faTimes" class="h-4 w-4" />
</Button>
</div>
</div>
<!-- Add buttons dropdown (after search input) -->
<Dropdown v-if="$slots.add && showAdd && !compact" align="left">
<template #trigger>
<Button
type="button"
variant="outline"
size="icon"
>
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4" />
<span class="sr-only">Dodaj</span>
</Button>
</template>
<template #content>
<slot name="add" />
</template>
</Dropdown>
<!-- Custom options dropdown (after search input and add buttons) -->
<div v-if="$slots.options && showOptions && !compact" class="flex items-center">
<slot name="options" />
</div>
<!-- Filters button (after options, before right side) -->
<Popover v-if="showFilters && $slots.filters && !compact">
<PopoverTrigger as-child>
<Button variant="outline" size="icon" class="relative">
<FontAwesomeIcon :icon="faFilter" class="h-4 w-4" />
<span
v-if="hasActiveFilters"
class="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary-600"
></span>
<span class="sr-only">Filtri</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-4" align="start">
<slot name="filters" />
</PopoverContent>
</Popover>
</div>
<!-- Right side: Selected count, Page size, Menu & Actions -->
<div class="flex items-center gap-3">
<!-- Selected count badge -->
<div
v-if="selectedCount > 0 && showSelectedCount"
class="inline-flex items-center rounded-md bg-primary-50 px-3 py-1.5 text-sm font-medium text-primary-700"
>
{{ selectedCount }} izbran{{ selectedCount === 1 ? 'o' : 'ih' }}
</div>
<!-- Page size selector (visible when not in compact mode) -->
<div v-if="showPageSize && !compact" class="flex items-center gap-2">
<Label for="page-size" class="text-sm text-gray-600 whitespace-nowrap">Na stran:</Label>
<Select
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
<SelectTrigger id="page-size" class="w-[100px]">
<SelectValue :placeholder="String(pageSize)" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in pageSizeOptions"
:key="opt"
:value="String(opt)"
>
{{ opt }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Table Options Menu (compact mode or always as dropdown) -->
<DropdownMenu v-if="showOptionsMenu" v-model:open="menuOpen">
<DropdownMenuTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
:class="hasActiveFilters && !compact ? 'relative' : ''"
>
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4" />
<span
v-if="hasActiveFilters && !compact"
class="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary-600"
></span>
<span class="sr-only">Možnosti tabele</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<!-- Search in menu (only in compact mode) -->
<div v-if="compact && showSearch" class="p-2 border-b">
<Label for="menu-search" class="text-xs font-medium mb-1.5 block">Iskanje</Label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5 z-10">
<FontAwesomeIcon :icon="faSearch" class="h-3.5 w-3.5 text-gray-400" />
</div>
<Input
id="menu-search"
v-model="internalSearch"
@input="handleSearchInput"
type="text"
placeholder="Iskanje..."
class="pl-8 h-8 text-sm"
:class="internalSearch ? 'pr-8' : ''"
/>
<Button
v-if="internalSearch"
@click="clearSearch"
type="button"
variant="ghost"
size="icon"
class="absolute inset-y-0 right-0 h-full w-auto px-2 text-gray-400 hover:text-gray-600"
>
<FontAwesomeIcon :icon="faTimes" class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- Page size in menu (only in compact mode) -->
<div v-if="compact && showPageSize" class="p-2 border-b">
<Label for="menu-page-size" class="text-xs font-medium mb-1.5 block">Elementov na stran</Label>
<Select
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
<SelectTrigger id="menu-page-size" class="w-full h-8 text-sm">
<SelectValue :placeholder="String(pageSize)" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in pageSizeOptions"
:key="opt"
:value="String(opt)"
>
{{ opt }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Export options -->
<template v-if="showExport">
<DropdownMenuLabel>Izvozi</DropdownMenuLabel>
<DropdownMenuItem @select="handleExport('csv')">
<FontAwesomeIcon :icon="faDownload" class="mr-2 h-4 w-4" />
CSV
</DropdownMenuItem>
<DropdownMenuItem @select="handleExport('xlsx')">
<FontAwesomeIcon :icon="faDownload" class="mr-2 h-4 w-4" />
Excel
</DropdownMenuItem>
</template>
<!-- Custom actions slot in menu -->
<template v-if="$slots.actions">
<DropdownMenuSeparator />
<slot name="actions" />
</template>
</DropdownMenuContent>
</DropdownMenu>
<template v-else>
<!-- If options menu is hidden but we have content to show, render it inline -->
<div v-if="showExport && !compact" class="flex items-center gap-2">
<Dropdown v-if="showExport" align="right">
<template #trigger>
<Button
type="button"
variant="outline"
class="gap-2"
>
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4" />
Izvozi
</Button>
</template>
<template #content>
<div class="py-1">
<button
type="button"
@click="handleExport('csv')"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
CSV
</button>
<button
type="button"
@click="handleExport('xlsx')"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
Excel
</button>
</div>
</template>
</Dropdown>
</div>
</template>
<!-- Custom actions slot (visible when not in compact mode) -->
<div v-if="$slots.actions && !compact" class="flex items-center gap-2">
<slot name="actions" />
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,170 @@
<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>

View File

@ -0,0 +1,55 @@
<script setup>
import { ref } from 'vue';
import Dropdown from '../Dropdown.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faEllipsisVertical } from '@fortawesome/free-solid-svg-icons';
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>
<Dropdown :align="align" :content-classes="['py-1']">
<template #trigger>
<button
type="button"
:class="[
'inline-flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 transition-colors',
sizeClasses[size],
]"
aria-label="Actions"
>
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4" />
</button>
</template>
<template #content>
<slot :handle-action="handleAction" />
</template>
</Dropdown>
</template>

View File

@ -0,0 +1,141 @@
<script setup>
import { computed, ref } 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";
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"]);
// 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
:id="id"
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'
)
"
: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>

View File

@ -0,0 +1,231 @@
<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>

View File

@ -1,47 +1,76 @@
<script setup>
import Modal from './Modal.vue';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/Components/ui/dialog';
import { computed, ref, watch } from 'vue';
const emit = defineEmits(['close']);
defineProps({
show: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: '2xl',
},
closeable: {
type: Boolean,
default: true,
},
const props = defineProps({
show: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: '2xl',
},
closeable: {
type: Boolean,
default: true,
},
});
const close = () => {
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) {
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>
<Modal
:show="show"
:max-width="maxWidth"
:closeable="closeable"
@close="close"
>
<div class="px-6 py-4">
<div class="text-lg font-medium text-gray-900">
<slot name="title" />
</div>
<Dialog v-model:open="open" :modal="true">
<DialogContent :class="maxWidthClass">
<DialogHeader>
<DialogTitle>
<slot name="title" />
</DialogTitle>
<DialogDescription>
<slot name="description" />
</DialogDescription>
</DialogHeader>
<div class="mt-4 text-sm text-gray-600">
<slot name="content" />
</div>
</div>
<div class="py-4">
<slot name="content" />
</div>
<div class="flex flex-row justify-end px-6 py-4 bg-gray-100 text-end">
<slot name="footer" />
</div>
</Modal>
<DialogFooter>
<slot name="footer" />
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,111 @@
<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>

View File

@ -0,0 +1,100 @@
<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>

View File

@ -0,0 +1,96 @@
<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>

View File

@ -0,0 +1,100 @@
<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>

View File

@ -0,0 +1,104 @@
<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>

View File

@ -1,5 +1,5 @@
<script setup>
import DialogModal from '@/Components/DialogModal.vue'
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue'
import InputLabel from '@/Components/InputLabel.vue'
import TextInput from '@/Components/TextInput.vue'
import { useForm } from '@inertiajs/vue3'
@ -52,15 +52,23 @@ const submit = () => {
)
}
const onConfirm = () => {
submit()
}
const contractOptions = computed(() => {
return props.contracts || []
})
</script>
<template>
<DialogModal :show="show" @close="$emit('close')">
<template #title>Uredi dokument</template>
<template #content>
<UpdateDialog
:show="show"
title="Uredi dokument"
:processing="form.processing"
@close="$emit('close')"
@confirm="onConfirm"
>
<div class="space-y-4">
<div>
<InputLabel for="docName" value="Ime" />
@ -85,12 +93,5 @@ const contractOptions = computed(() => {
<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>
</UpdateDialog>
</template>

View File

@ -1,5 +1,5 @@
<script setup>
import DialogModal from '@/Components/DialogModal.vue'
import CreateDialog from '@/Components/Dialogs/CreateDialog.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import SecondaryButton from '@/Components/SecondaryButton.vue'
import ActionMessage from '@/Components/ActionMessage.vue'
@ -83,12 +83,23 @@ const submit = () => {
}
const close = () => emit('close')
const onConfirm = () => {
submit()
}
</script>
<template>
<DialogModal :show="props.show" @close="close" maxWidth="lg">
<template #title>Dodaj dokument</template>
<template #content>
<CreateDialog
:show="props.show"
title="Dodaj dokument"
max-width="lg"
confirm-text="Naloži"
:processing="form.processing"
:disabled="!form.file"
@close="close"
@confirm="onConfirm"
>
<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" />
@ -115,13 +126,5 @@ const close = () => emit('close')
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>
</CreateDialog>
</template>

View File

@ -1,13 +1,4 @@
<script setup>
import {
FwbTable,
FwbTableHead,
FwbTableHeadCell,
FwbTableBody,
FwbTableRow,
FwbTableCell,
FwbBadge,
} from "flowbite-vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faFilePdf,
@ -20,13 +11,14 @@ import {
faEllipsisVertical,
faDownload,
faTrash,
faFileAlt,
} from "@fortawesome/free-solid-svg-icons";
import { ref } from "vue";
import { ref, computed } from "vue";
import { router } from "@inertiajs/vue3";
import DataTable from "./DataTable/DataTable.vue";
import Dropdown from "@/Components/Dropdown.vue";
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
import SecondaryButton from "./SecondaryButton.vue";
import DangerButton from "./DangerButton.vue";
import DeleteDialog from "./Dialogs/DeleteDialog.vue";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
documents: { type: Array, default: () => [] },
@ -37,6 +29,16 @@ const props = defineProps({
deleteUrlBuilder: { type: Function, default: null },
edit: { type: Boolean, default: false },
});
// Define columns for DataTable
const columns = [
{ key: 'name', label: 'Naziv' },
{ key: 'type', label: 'Vrsta' },
{ key: 'size', label: 'Velikost', align: 'right' },
{ key: 'created_at', label: 'Dodano' },
{ key: 'source', label: 'Vir' },
{ key: 'description', label: 'Opis', align: 'center' },
];
// 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
@ -132,12 +134,19 @@ const hasDesc = (doc) => {
};
const expandedDescKey = ref(null);
const rowKey = (doc, i) => doc?.uuid ?? i;
const toggleDesc = (doc, i) => {
const key = rowKey(doc, i);
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);
@ -223,189 +232,155 @@ const askDelete = (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="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">
<div>
<DataTable
:columns="columns"
:rows="documents"
:show-toolbar="false"
:show-pagination="false"
:striped="false"
:hoverable="true"
:show-actions="true"
row-key="uuid"
empty-text="No documents."
empty-icon="faFileAlt"
>
<!-- Name column -->
<template #cell-name="{ row }">
<div>
<div class="flex items-center gap-2">
<button
type="button"
class="text-indigo-600 hover:underline"
@click.stop="$emit('view', row)"
>
{{ row.name }}
</button>
<Badge v-if="row.is_public" variant="secondary" class="bg-green-100 text-green-700 hover:bg-green-200">Public</Badge>
</div>
<!-- Expanded description -->
<div
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 }">
{{ new Date(row.created_at).toLocaleString() }}
</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 #actions="{ row }">
<div @click.stop>
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none transition-colors"
title="Možnosti"
>
<FontAwesomeIcon
:icon="faEllipsisVertical"
class="h-4 w-4 text-gray-700"
/>
</button>
</template>
<template #content>
<div class="py-1">
<button
type="button"
class="text-indigo-600 hover:underline"
@click="$emit('view', doc)"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 transition-colors"
@click="emit('edit', row)"
v-if="edit"
>
{{ doc.name }}
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
<span>Uredi</span>
</button>
<button
type="button"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 transition-colors"
@click="handleDownload(row)"
>
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" />
<span>Prenos</span>
</button>
<button
type="button"
class="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 transition-colors"
@click="askDelete(row)"
v-if="edit"
>
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4" />
<span>Izbriši</span>
</button>
<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
</template>
</Dropdown>
</div>
</template>
</DataTable>
<!-- Delete confirmation dialog -->
<DeleteDialog
: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>
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>

View File

@ -1,5 +1,5 @@
<script setup>
import { FwbListGroup, FwbListGroupItem } from 'flowbite-vue';
// ListGroup components removed - using custom implementation
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
import draggable from 'vuedraggable';
@ -68,13 +68,13 @@ watch(
group="actions"
>
<template #item="{element, index}">
<fwb-list-group-item class="flex justify-between">
<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">
<span class="text">{{ element.name }} </span>
<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">
<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">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg></i>
</fwb-list-group-item>
</svg></button>
</li>
</template>
</draggable>
<draggable
@ -92,13 +92,13 @@ watch(
group="actions"
>
<template #item="{element, index}">
<fwb-list-group-item class="flex justify-between">
<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">
<span class="text">{{ element.name }} </span>
<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">
<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">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>
</svg></i>
</fwb-list-group-item>
</svg></button>
</li>
</template>
</draggable>
</div>
@ -132,4 +132,4 @@ watch(
.list-group-item i {
cursor: pointer;
}
</style>
</style>

View File

@ -1,5 +1,10 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
const props = defineProps({
align: {
@ -20,64 +25,64 @@ const props = defineProps({
},
});
let open = ref(false);
const triggerEl = ref(null);
const panelEl = ref(null);
const panelStyle = ref({ top: '0px', left: '0px' });
const open = ref(false);
const closeOnEscape = (e) => {
if (open.value && e.key === 'Escape') {
open.value = false;
}
// Expose close method for parent components
const close = () => {
open.value = false;
};
const updatePosition = () => {
const t = triggerEl.value;
const p = panelEl.value;
if (!t || !p) return;
const rect = t.getBoundingClientRect();
// Ensure we have updated width
const pw = p.offsetWidth || 0;
const ph = p.offsetHeight || 0;
const margin = 8; // small spacing from trigger
let left = rect.left;
if (props.align === 'right') {
left = rect.right - pw;
} else if (props.align === 'left') {
left = rect.left;
}
// Clamp within viewport
const maxLeft = Math.max(0, window.innerWidth - pw - margin);
left = Math.min(Math.max(margin, left), maxLeft);
let top = rect.bottom + margin;
// If not enough space below, place above the trigger
if (top + ph > window.innerHeight) {
top = Math.max(margin, rect.top - ph - margin);
}
panelStyle.value = { top: `${top}px`, left: `${left}px` };
};
const onWindowChange = () => {
updatePosition();
};
watch(open, async (val) => {
if (val) {
await nextTick();
updatePosition();
window.addEventListener('resize', onWindowChange);
window.addEventListener('scroll', onWindowChange, true);
} else {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
}
defineExpose({
close,
open,
});
// Close dropdown when dialog opens
const handleDialogOpen = () => {
if (open.value) {
open.value = false;
}
};
// Watch for dialog opens using MutationObserver
let observer = null;
onMounted(() => {
// Listen for custom dialog open events
window.addEventListener('dialog:open', handleDialogOpen);
// Watch for dialog state changes in the DOM
observer = new MutationObserver((mutations) => {
// Check if any dialog has data-state="open"
const openDialogs = document.querySelectorAll('[data-state="open"]');
const hasOpenDialog = Array.from(openDialogs).some((dialog) => {
// Check if it's a dialog element (has role="dialog" or is DialogContent)
const role = dialog.getAttribute('role');
const isDialogContent = dialog.classList?.contains('DialogContent') ||
dialog.querySelector('[role="dialog"]') ||
dialog.closest('[role="dialog"]');
return role === 'dialog' || isDialogContent;
});
if (hasOpenDialog && open.value) {
handleDialogOpen();
}
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['data-state'],
subtree: true,
childList: true,
});
});
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape);
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
window.removeEventListener('dialog:open', handleDialogOpen);
if (observer) {
observer.disconnect();
}
});
const widthClass = computed(() => {
@ -90,57 +95,38 @@ const widthClass = computed(() => {
'wide': 'w-[34rem] max-w-[90vw]',
'auto': '',
};
return map[props.width.toString()] ?? '';
return map[props.width.toString()] || '';
});
const alignmentClasses = computed(() => {
if (props.align === 'left') {
return 'ltr:origin-top-left rtl:origin-top-right start-0';
}
if (props.align === 'right') {
return 'ltr:origin-top-right rtl:origin-top-left end-0';
}
return 'origin-top';
// 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 onContentClick = () => {
if (props.closeOnContentClick) {
open.value = false;
const combinedContentClasses = computed(() => {
// Merge width class with custom content classes
// Note: shadcn-vue already provides base styling, so we append custom classes
const classes = [widthClass.value];
if (props.contentClasses && props.contentClasses.length) {
classes.push(...props.contentClasses);
}
};
return classes.filter(Boolean).join(' ');
});
</script>
<template>
<div class="relative" ref="triggerEl">
<div @click="open = ! open">
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child>
<slot name="trigger" />
</div>
<teleport to="body">
<!-- Full Screen Dropdown Overlay at body level -->
<div v-show="open" class="fixed inset-0 z-[2147483646]" @click="open = false" />
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-show="open"
ref="panelEl"
class="fixed z-[2147483647] rounded-md shadow-lg"
:class="[widthClass]"
:style="[panelStyle]"
>
<div class="rounded-md ring-1 ring-black ring-opacity-5" :class="contentClasses" @click="onContentClick">
<slot name="content" />
</div>
</div>
</transition>
</teleport>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
:align="alignProp"
:class="combinedContentClasses"
>
<slot name="content" />
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@ -1,168 +0,0 @@
<script setup>
import { computed, watch } from "vue";
import DialogModal from "./DialogModal.vue";
import InputLabel from "./InputLabel.vue";
import SectionTitle from "./SectionTitle.vue";
import TextInput from "./TextInput.vue";
import InputError from "./InputError.vue";
import PrimaryButton from "./PrimaryButton.vue";
import { useForm } from "@inertiajs/vue3";
/*
EmailCreateForm / Email editor
- Props mirror Phone/Address forms for consistency
- Routes assumed: person.email.create, person.email.update
- Adjust route names/fields to match your backend if different
*/
const props = defineProps({
show: { type: Boolean, default: false },
person: { type: Object, required: true },
// kept for parity with other *CreateForm components; not used directly here
types: { type: Array, default: () => [] },
edit: { type: Boolean, default: false },
id: { type: Number, default: 0 },
// When true, force-show the auto mail opt-in even if person.client wasn't eager loaded
isClientContext: { type: Boolean, default: false },
});
// Inertia useForm handles processing and errors for us
const emit = defineEmits(["close"]);
const close = () => {
emit("close");
// Clear validation errors and reset minimal fields after closing so the form reopens cleanly
setTimeout(() => {
form.clearErrors();
form.reset("value", "label", "receive_auto_mails");
}, 0);
};
const form = useForm({
value: "",
label: "",
receive_auto_mails: false,
});
const resetForm = () => {
form.reset("value", "label", "receive_auto_mails");
};
const create = async () => {
form.post(route("person.email.create", props.person), {
preserveScroll: true,
onSuccess: () => {
close();
resetForm();
},
});
};
const update = async () => {
form.put(route("person.email.update", { person: props.person, email_id: props.id }), {
preserveScroll: true,
onSuccess: () => {
close();
resetForm();
},
});
};
watch(
() => props.show,
(newVal) => {
if (!newVal) {
return;
}
if (props.edit && props.id) {
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
const email = list.find((e) => e.id === props.id);
if (email) {
form.value = email.value ?? email.email ?? email.address ?? "";
form.label = email.label ?? "";
form.receive_auto_mails = !!email.receive_auto_mails;
} else {
form.reset("value", "label", "receive_auto_mails");
}
} else {
form.reset("value", "label", "receive_auto_mails");
}
}
);
const submit = () => (props.edit ? update() : create());
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>
<span v-if="edit">Spremeni email</span>
<span v-else>Dodaj email</span>
</template>
<template #content>
<form @submit.prevent="submit">
<SectionTitle class="border-b mb-4">
<template #title>Email</template>
</SectionTitle>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="em_value" value="E-pošta" />
<TextInput
id="em_value"
v-model="form.value"
type="email"
class="mt-1 block w-full"
autocomplete="email"
/>
<InputError
v-if="form.errors.value"
v-for="err in [].concat(form.errors.value || [])"
:key="err"
:message="err"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="em_label" value="Oznaka (neobvezno)" />
<TextInput
id="em_label"
v-model="form.label"
type="text"
class="mt-1 block w-full"
autocomplete="off"
/>
<InputError
v-if="form.errors.label"
v-for="err in [].concat(form.errors.label || [])"
:key="err"
:message="err"
/>
</div>
<div
v-if="props.person?.client || isClientContext"
class="mt-3 flex items-center gap-2"
>
<input
id="em_receive_auto_mails"
type="checkbox"
v-model="form.receive_auto_mails"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
<label for="em_receive_auto_mails" class="text-sm"
>Prejemaj samodejna e-sporočila</label
>
</div>
<div class="flex justify-end mt-4">
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>Shrani</PrimaryButton
>
</div>
</form>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,124 @@
<script setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { computed } from 'vue';
const props = defineProps({
icon: {
type: [String, Object, Array],
default: null,
},
title: {
type: String,
default: 'Ni podatkov',
},
description: {
type: String,
default: null,
},
action: {
type: Object,
default: null,
},
size: {
type: String,
default: 'md', // sm, md, lg
validator: (value) => ['sm', 'md', 'lg'].includes(value),
},
});
const sizeClasses = computed(() => {
const sizes = {
sm: {
icon: 'text-4xl',
title: 'text-base',
description: 'text-sm',
container: 'py-8',
},
md: {
icon: 'text-5xl',
title: 'text-lg',
description: 'text-sm',
container: 'py-12',
},
lg: {
icon: 'text-6xl',
title: 'text-xl',
description: 'text-base',
container: 'py-16',
},
};
return sizes[props.size];
});
</script>
<template>
<div
class="flex flex-col items-center justify-center text-center"
:class="sizeClasses.container"
>
<!-- Icon -->
<div
v-if="icon"
class="mb-4 text-gray-400"
:class="sizeClasses.icon"
>
<FontAwesomeIcon :icon="icon" />
</div>
<!-- Default icon if none provided -->
<div
v-else
class="mb-4 text-gray-400"
:class="sizeClasses.icon"
>
<svg
class="mx-auto"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
</div>
<!-- Title -->
<h3
class="font-medium text-gray-900 mb-2"
:class="sizeClasses.title"
>
{{ title }}
</h3>
<!-- Description -->
<p
v-if="description"
class="text-gray-500 max-w-sm mb-6"
:class="sizeClasses.description"
>
{{ description }}
</p>
<!-- Action button -->
<div v-if="action">
<component
:is="action.to ? 'Link' : 'button'"
:href="action.to"
@click="action.onClick"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
<FontAwesomeIcon
v-if="action.icon"
:icon="action.icon"
class="w-4 h-4"
/>
{{ action.label }}
</component>
</div>
</div>
</template>

View File

@ -22,19 +22,27 @@ const showSlot = ref(props.show);
watch(
() => props.show,
() => {
(newVal) => {
if (props.show) {
document.body.style.overflow = "hidden";
showSlot.value = true;
dialog.value?.showModal();
// Use nextTick to ensure dialog ref is available
setTimeout(() => {
if (dialog.value) {
dialog.value.showModal();
}
}, 0);
} else {
document.body.style.overflow = null;
setTimeout(() => {
dialog.value?.close();
if (dialog.value) {
dialog.value.close();
}
showSlot.value = false;
}, 200);
}
}
},
{ immediate: true }
);
const close = () => {

View File

@ -1,6 +1,8 @@
<script setup>
import { Link } from "@inertiajs/vue3";
import { computed } from "vue";
import { Link, router } from "@inertiajs/vue3";
import { computed, ref } from "vue";
import { Input } from '@/Components/ui/input';
import { Button } from '@/Components/ui/button';
const props = defineProps({
links: { type: Array, default: () => [] },
@ -11,190 +13,361 @@ const props = defineProps({
const num = props.links?.length || 0;
const prevLink = computed(() => (num > 0 ? props.links[0] : null));
const nextLink = computed(() => (num > 1 ? props.links[num - 1] : null));
const numericLinks = computed(() => {
if (num < 3) return [];
return props.links
.slice(1, num - 1)
.map((l) => ({
...l,
page: Number.parseInt(String(l.label).replace(/[^0-9]/g, ""), 10),
}))
.filter((l) => !Number.isNaN(l.page));
});
const currentPage = computed(() => numericLinks.value.find((l) => l.active)?.page || 1);
const lastPage = computed(() =>
numericLinks.value.length ? Math.max(...numericLinks.value.map((l) => l.page)) : 1
);
const linkByPage = computed(() => {
const m = new Map();
for (const l of numericLinks.value) m.set(l.page, l);
return m;
});
const windowItems = computed(() => {
const items = [];
const cur = currentPage.value;
const last = lastPage.value;
const show = new Set([1, last, cur - 1, cur, cur + 1]);
if (cur <= 3) {
show.add(2);
show.add(3);
const prevLink = computed(() => {
if (num > 0 && props.links && Array.isArray(props.links) && props.links[0]) {
return props.links[0];
}
if (cur >= last - 2) {
show.add(last - 1);
show.add(last - 2);
return null;
});
const nextLink = computed(() => {
if (num > 1 && props.links && Array.isArray(props.links) && props.links[num - 1]) {
return props.links[num - 1];
}
// Prev
items.push({ kind: "prev", link: prevLink.value });
// Pages with ellipses
let inGap = false;
for (let p = 1; p <= last; p++) {
if (show.has(p)) {
items.push({
kind: "page",
link: linkByPage.value.get(p) || {
url: null,
label: String(p),
active: p === cur,
},
});
inGap = false;
} else if (!inGap) {
items.push({ kind: "ellipsis" });
inGap = true;
return null;
});
const firstLink = computed(() => {
if (num < 3 || !props.links || !Array.isArray(props.links)) return null;
// Find the first numeric link (page 1)
for (let i = 1; i < num - 1; i++) {
const link = props.links[i];
if (!link) continue;
const page = Number.parseInt(String(link?.label || "").replace(/[^0-9]/g, ""), 10);
if (page === 1) return link;
}
return null;
});
const lastLink = computed(() => {
if (num < 3 || !props.links || !Array.isArray(props.links)) return null;
// Find the last numeric link
let maxPage = 0;
let maxLink = null;
for (let i = 1; i < num - 1; i++) {
const link = props.links[i];
if (!link) continue;
const page = Number.parseInt(String(link?.label || "").replace(/[^0-9]/g, ""), 10);
if (!Number.isNaN(page) && page > maxPage) {
maxPage = page;
maxLink = link;
}
}
// Next
items.push({ kind: "next", link: nextLink.value });
return items;
return maxLink;
});
const numericLinks = computed(() => {
if (num < 3 || !props.links || !Array.isArray(props.links)) return [];
return props.links
.slice(1, num - 1)
.filter((l) => l != null)
.map((l) => ({
...l,
page: Number.parseInt(String(l?.label || "").replace(/[^0-9]/g, ""), 10),
}))
.filter((l) => !Number.isNaN(l.page) && l.page != null);
});
const currentPage = computed(() => {
const active = numericLinks.value.find((l) => l?.active);
return active?.page || 1;
});
const lastPage = computed(() => {
if (!numericLinks.value.length) return 1;
const pages = numericLinks.value.map((l) => l?.page).filter(p => p != null);
return pages.length ? Math.max(...pages) : 1;
});
const linkByPage = computed(() => {
const m = new Map();
for (const l of numericLinks.value) {
if (l?.page != null) {
m.set(l.page, l);
}
}
return m;
});
// Generate visible page numbers with ellipsis (similar to DataTableClient)
const visiblePages = computed(() => {
const pages = [];
const total = lastPage.value;
const current = currentPage.value;
const maxVisible = 5; // Match DataTableClient default
if (total <= maxVisible) {
for (let i = 1; i <= total; i++) {
pages.push(i);
}
return pages;
}
// Calculate window around current page
const half = Math.floor(maxVisible / 2);
let start = Math.max(1, current - half);
let end = Math.min(total, start + maxVisible - 1);
start = Math.max(1, Math.min(start, end - maxVisible + 1));
// Add pages in window
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
});
const gotoInput = ref("");
// Handle scroll on navigation
function handleLinkClick(event) {
// Prevent default scroll behavior
event.preventDefault();
const href = event.currentTarget.getAttribute('href');
if (href) {
router.visit(href, {
preserveScroll: false,
onSuccess: () => {
// Scroll to top of table after navigation completes
setTimeout(() => {
const tableElement = document.querySelector('[data-table-container]');
if (tableElement) {
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, 100);
},
});
}
}
function goToPage() {
const raw = String(gotoInput.value || "").trim();
const n = Number(raw);
if (!Number.isFinite(n) || n < 1 || n > lastPage.value) {
gotoInput.value = "";
return;
}
const targetLink = linkByPage.value.get(Math.floor(n));
if (targetLink?.url) {
router.visit(targetLink.url, {
preserveScroll: false,
onSuccess: () => {
// Scroll to top of table when page changes
const tableElement = document.querySelector('[data-table-container]');
if (tableElement) {
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
},
});
} else {
// If link not found, try to construct URL manually
gotoInput.value = "";
}
}
function handleKeyPress(event) {
if (event.key === "Enter") {
goToPage();
}
}
</script>
<template>
<div class="flex items-center justify-between bg-white px-4 py-3 sm:px-6">
<!-- Mobile: Prev / Next -->
<nav
class="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 bg-white px-4 py-3 text-sm text-gray-700 sm:px-6"
aria-label="Pagination"
>
<!-- Mobile: Simple prev/next -->
<div class="flex flex-1 justify-between sm:hidden">
<component
:is="links?.[0]?.url ? Link : 'span'"
:href="links?.[0]?.url"
:aria-disabled="!links?.[0]?.url"
:tabindex="!links?.[0]?.url ? -1 : 0"
class="relative inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium"
:class="
links?.[0]?.url
? 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
: 'border-gray-200 bg-gray-100 text-gray-400'
"
<Link
v-if="prevLink?.url"
:href="prevLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Prejšnja
</component>
<component
:is="links?.[num - 1]?.url ? Link : 'span'"
:href="links?.[num - 1]?.url"
:aria-disabled="!links?.[num - 1]?.url"
:tabindex="!links?.[num - 1]?.url ? -1 : 0"
class="relative ml-3 inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium"
:class="
links?.[num - 1]?.url
? 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
: 'border-gray-200 bg-gray-100 text-gray-400'
"
</Link>
<span
v-else
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-400 cursor-not-allowed opacity-50"
>
Prejšnja
</span>
<Link
v-if="nextLink?.url"
:href="nextLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Naslednja
</component>
</Link>
<span
v-else
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-400 cursor-not-allowed opacity-50"
>
Naslednja
</span>
</div>
<!-- Desktop: Full pagination -->
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Showing
<span class="font-medium">{{ from }}</span>
to
<span class="font-medium">{{ to }}</span>
of
<span class="font-medium">{{ total }}</span>
results
</p>
<!-- Page stats -->
<div v-if="total > 0">
<span class="text-sm text-gray-700">
Prikazano: <span class="font-medium">{{ from || 0 }}</span><span class="font-medium">{{ to || 0 }}</span> od
<span class="font-medium">{{ total || 0 }}</span>
</span>
</div>
<div>
<nav
class="isolate inline-flex -space-x-px rounded-md shadow-sm"
aria-label="Pagination"
<div v-else>
<span class="text-sm text-gray-700">Ni zadetkov</span>
</div>
<!-- Pagination controls -->
<div class="flex items-center gap-1">
<!-- First -->
<Link
v-if="firstLink?.url && currentPage > 1"
:href="firstLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
aria-label="Prva stran"
>
<template v-for="(item, idx) in windowItems" :key="idx">
<!-- Prev / Next -->
<component
v-if="item.kind === 'prev' || item.kind === 'next'"
:is="item.link?.url ? Link : 'span'"
:href="item.link?.url"
class="relative inline-flex items-center px-2 py-2 ring-1 ring-inset ring-gray-300 focus:z-20 focus:outline-offset-0"
:class="{
'rounded-l-md': item.kind === 'prev',
'rounded-r-md': item.kind === 'next',
'text-gray-900 hover:bg-gray-50': item.link?.url,
'text-gray-400 bg-gray-100': !item.link?.url,
}"
>
<span class="sr-only">{{
item.kind === "prev" ? "Prejšnja" : "Naslednja"
}}</span>
<svg
v-if="item.kind === 'prev'"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
<svg
v-else
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</component>
««
</Link>
<span
v-else
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
aria-label="Prva stran"
>
««
</span>
<!-- Ellipsis -->
<span
v-else-if="item.kind === 'ellipsis'"
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-500 ring-1 ring-inset ring-gray-200 select-none"
></span
>
<!-- Prev -->
<Link
v-if="prevLink?.url"
:href="prevLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
aria-label="Prejšnja stran"
>
«
</Link>
<span
v-else
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
aria-label="Prejšnja stran"
>
«
</span>
<!-- Page number -->
<component
v-else-if="item.kind === 'page'"
:is="item.link?.url ? Link : 'span'"
:href="item.link?.url"
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:outline-offset-0"
:class="{
'text-gray-700 ring-1 ring-inset ring-gray-300': !item.link?.url,
'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20':
item.link?.url && !item.link?.active,
'z-10 bg-blue-600 text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600':
item.link?.active,
}"
>
{{ item.link?.label || "" }}
</component>
</template>
</nav>
<!-- Leading ellipsis / first page when window doesn't include 1 -->
<Link
v-if="visiblePages[0] > 1"
:href="firstLink?.url || '#'"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-3 py-1 rounded border border-gray-300 bg-white text-gray-900 hover:bg-gray-50 transition-colors"
>
1
</Link>
<span v-if="visiblePages[0] > 2" class="px-1 text-gray-700"></span>
<!-- Page numbers -->
<template v-for="p in visiblePages" :key="p">
<Link
v-if="linkByPage.get(p)?.url"
:href="linkByPage.get(p).url"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-3 py-1 rounded border transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
:class="
p === currentPage
? 'border-primary-600 bg-primary-600 text-white'
: 'border-gray-300 bg-white text-gray-900 hover:bg-gray-50'
"
:aria-current="p === currentPage ? 'page' : undefined"
>
{{ p }}
</Link>
<span
v-else
class="px-3 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
>
{{ p }}
</span>
</template>
<!-- Trailing ellipsis / last page when window doesn't include last -->
<span v-if="visiblePages[visiblePages.length - 1] < lastPage - 1" class="px-1 text-gray-700"></span>
<Link
v-if="visiblePages[visiblePages.length - 1] < lastPage && lastLink?.url"
:href="lastLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-3 py-1 rounded border border-gray-300 bg-white text-gray-900 hover:bg-gray-50 transition-colors"
>
{{ lastPage }}
</Link>
<!-- Next -->
<Link
v-if="nextLink?.url"
:href="nextLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
aria-label="Naslednja stran"
>
»
</Link>
<span
v-else
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
aria-label="Naslednja stran"
>
»
</span>
<!-- Last -->
<Link
v-if="lastLink?.url && currentPage < lastPage"
:href="lastLink.url"
:preserve-scroll="false"
@click="handleLinkClick"
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
aria-label="Zadnja stran"
>
»»
</Link>
<span
v-else
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
aria-label="Zadnja stran"
>
»»
</span>
<!-- Goto page input -->
<div class="ms-2 flex items-center gap-1">
<Input
v-model="gotoInput"
type="number"
min="1"
:max="lastPage"
inputmode="numeric"
class="w-16 text-sm"
:placeholder="String(currentPage)"
aria-label="Pojdi na stran"
@keyup.enter="goToPage"
@blur="goToPage"
/>
<span class="text-sm text-gray-500">/ {{ lastPage }}</span>
</div>
</div>
</div>
</div>
</nav>
</template>

View File

@ -0,0 +1,283 @@
<script setup>
import { ref, watch } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
const props = defineProps({
show: {
type: Boolean,
default: false,
},
person: Object,
types: Array,
edit: {
type: Boolean,
default: false,
},
id: {
type: Number,
default: 0,
},
});
const formSchema = toTypedSchema(
z.object({
address: z.string().min(1, "Naslov je obvezen."),
country: z.string().optional(),
post_code: z.string().optional(),
city: z.string().optional(),
type_id: z.number().nullable(),
description: z.string().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
address: "",
country: "",
post_code: "",
city: "",
type_id: props.types?.[0]?.id ?? null,
description: "",
},
});
const processing = ref(false);
const emit = defineEmits(["close"]);
const close = () => {
emit("close");
setTimeout(() => {
form.resetForm();
processing.value = false;
}, 300);
};
const resetForm = () => {
form.resetForm({
values: {
address: "",
country: "",
post_code: "",
city: "",
type_id: props.types?.[0]?.id ?? null,
description: "",
},
});
};
watch(
() => props.id,
(id) => {
if (props.edit && id !== 0) {
const a = props.person.addresses?.find((x) => x.id === id);
if (a) {
form.setValues({
address: a.address || "",
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
description: a.description || "",
});
return;
}
}
resetForm();
},
{ immediate: true }
);
watch(() => props.show, (val) => {
if (val && props.edit && props.id) {
const a = props.person.addresses?.find((x) => x.id === props.id);
if (a) {
form.setValues({
address: a.address || "",
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
description: a.description || "",
});
}
} else if (val && !props.edit) {
resetForm();
}
});
const create = async () => {
processing.value = true;
const { values } = form;
router.post(
route("person.address.create", props.person),
values,
{
preserveScroll: true,
onSuccess: () => {
processing.value = false;
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
const update = async () => {
processing.value = true;
const { values } = form;
router.put(
route("person.address.update", { person: props.person, address_id: props.id }),
values,
{
preserveScroll: true,
onSuccess: () => {
processing.value = false;
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
const callSubmit = () => {
if (props.edit) {
update();
} else {
create();
}
};
const onSubmit = form.handleSubmit(() => {
callSubmit();
});
const onConfirm = () => {
onSubmit();
};
</script>
<template>
<component
:is="edit ? UpdateDialog : CreateDialog"
:show="show"
:title="edit ? 'Spremeni naslov' : 'Dodaj novi naslov'"
confirm-text="Shrani"
:processing="processing"
@close="close"
@confirm="onConfirm"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title> Naslov </template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="address">
<FormItem>
<FormLabel>Naslov</FormLabel>
<FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="country">
<FormItem>
<FormLabel>Država</FormLabel>
<FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="post_code">
<FormItem>
<FormLabel>Poštna številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="city">
<FormItem>
<FormLabel>Mesto</FormLabel>
<FormControl>
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="type_id">
<FormItem>
<FormLabel>Tip</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi tip" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="type in types" :key="type.id" :value="type.id">
{{ type.name }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>
</component>
</template>

View File

@ -0,0 +1,217 @@
<script setup>
import { ref, watch } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
const props = defineProps({
show: { type: Boolean, default: false },
person: Object,
types: Array,
id: { type: Number, default: 0 },
});
const processing = ref(false);
const emit = defineEmits(["close"]);
const formSchema = toTypedSchema(
z.object({
address: z.string().min(1, "Naslov je obvezen."),
country: z.string().optional(),
post_code: z.string().optional(),
city: z.string().optional(),
type_id: z.number().nullable(),
description: z.string().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
address: "",
country: "",
post_code: "",
city: "",
type_id: props.types?.[0]?.id ?? null,
description: "",
},
});
const close = () => {
emit("close");
setTimeout(() => {
form.resetForm();
processing.value = false;
}, 300);
};
const resetForm = () => {
form.resetForm({
values: {
address: "",
country: "",
post_code: "",
city: "",
type_id: props.types?.[0]?.id ?? null,
description: "",
},
});
};
const hydrate = () => {
const id = props.id;
if (id) {
const a = (props.person.addresses || []).find((x) => x.id === id);
if (a) {
form.setValues({
address: a.address || "",
country: a.country || "",
post_code: a.post_code || a.postal_code || "",
city: a.city || "",
type_id: a.type_id ?? (props.types?.[0]?.id ?? null),
description: a.description || "",
});
return;
}
}
resetForm();
};
watch(() => props.id, () => hydrate(), { immediate: true });
watch(() => props.show, (v) => {
if (v) hydrate();
});
const update = async () => {
processing.value = true;
const { values } = form;
router.put(
route("person.address.update", { person: props.person, address_id: props.id }),
values,
{
preserveScroll: true,
onSuccess: () => {
processing.value = false;
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
const onSubmit = form.handleSubmit(() => {
update();
});
const onConfirm = () => {
onSubmit();
};
</script>
<template>
<UpdateDialog
:show="show"
title="Spremeni naslov"
confirm-text="Shrani"
:processing="processing"
@close="close"
@confirm="onConfirm"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title>Naslov</template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="address">
<FormItem>
<FormLabel>Naslov</FormLabel>
<FormControl>
<Input type="text" placeholder="Naslov" autocomplete="street-address" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="country">
<FormItem>
<FormLabel>Država</FormLabel>
<FormControl>
<Input type="text" placeholder="Država" autocomplete="country" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="post_code">
<FormItem>
<FormLabel>Poštna številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Poštna številka" autocomplete="postal-code" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="city">
<FormItem>
<FormLabel>Mesto</FormLabel>
<FormControl>
<Input type="text" placeholder="Mesto" autocomplete="address-level2" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="type_id">
<FormItem>
<FormLabel>Tip</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi tip" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="type in types" :key="type.id" :value="type.id">
{{ type.name }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>
</UpdateDialog>
</template>

View File

@ -0,0 +1,234 @@
<script setup>
import { computed, ref, watch } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Checkbox } from "@/Components/ui/checkbox";
const props = defineProps({
show: { type: Boolean, default: false },
person: { type: Object, required: true },
types: { type: Array, default: () => [] },
edit: { type: Boolean, default: false },
id: { type: Number, default: 0 },
isClientContext: { type: Boolean, default: false },
});
const emit = defineEmits(["close"]);
// Zod schema for form validation
const formSchema = toTypedSchema(
z.object({
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
label: z.string().optional(),
receive_auto_mails: z.boolean().optional(),
})
);
// VeeValidate form
const form = useForm({
validationSchema: formSchema,
initialValues: {
value: "",
label: "",
receive_auto_mails: false,
},
});
const processing = ref(false);
const close = () => {
emit("close");
setTimeout(() => {
form.resetForm();
processing.value = false;
}, 0);
};
const resetForm = () => {
form.resetForm({
values: {
value: "",
label: "",
receive_auto_mails: false,
},
});
};
const create = async () => {
processing.value = true;
const { values } = form;
router.post(
route("person.email.create", props.person),
values,
{
preserveScroll: true,
onSuccess: () => {
close();
resetForm();
},
onError: (errors) => {
// Map Inertia errors to VeeValidate field errors
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
const update = async () => {
processing.value = true;
const { values } = form;
router.put(
route("person.email.update", { person: props.person, email_id: props.id }),
values,
{
preserveScroll: true,
onSuccess: () => {
close();
resetForm();
},
onError: (errors) => {
// Map Inertia errors to VeeValidate field errors
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
watch(
() => props.show,
(newVal) => {
if (!newVal) {
return;
}
if (props.edit && props.id) {
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
const email = list.find((e) => e.id === props.id);
if (email) {
form.setValues({
value: email.value ?? email.email ?? email.address ?? "",
label: email.label ?? "",
receive_auto_mails: !!email.receive_auto_mails,
});
} else {
resetForm();
}
} else {
resetForm();
}
}
);
const onSubmit = form.handleSubmit((values) => {
if (props.edit) {
update();
} else {
create();
}
});
const onConfirm = () => {
onSubmit();
};
</script>
<template>
<component
:is="edit ? UpdateDialog : CreateDialog"
:show="show"
:title="edit ? 'Spremeni email' : 'Dodaj email'"
confirm-text="Shrani"
:processing="processing"
@close="close"
@confirm="onConfirm"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title>Email</template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="value">
<FormItem>
<FormLabel>E-pošta</FormLabel>
<FormControl>
<Input
type="email"
placeholder="example@example.com"
autocomplete="email"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="label">
<FormItem>
<FormLabel>Oznaka (neobvezno)</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Oznaka"
autocomplete="off"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-if="props.person?.client || isClientContext"
v-slot="{ value, handleChange }"
name="receive_auto_mails"
>
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
:checked="value"
@update:checked="handleChange"
/>
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">
Prejemaj samodejna e-sporočila
</FormLabel>
</div>
</FormItem>
</FormField>
</div>
</form>
</component>
</template>

View File

@ -0,0 +1,85 @@
<script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
});
const emit = defineEmits(['add', 'edit', 'delete']);
const handleAdd = () => emit('add');
const handleEdit = (id) => emit('edit', id);
const handleDelete = (id, label) => emit('delete', id, label);
</script>
<template>
<div class="flex justify-end mb-3" v-if="edit">
<button
@click="handleAdd"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all"
title="Dodaj naslov"
>
<PlusIcon size="sm" />
<span>Dodaj naslov</span>
</button>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="address in person.addresses"
:key="address.id"
>
<div class="flex items-start justify-between mb-2">
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{{ address.country }}
</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ address.type.name }}
</span>
</div>
<div v-if="edit">
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
title="Možnosti"
>
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<button
@click="handleEdit(address.id)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<EditIcon size="sm" />
<span>Uredi</span>
</button>
<button
@click="handleDelete(address.id, address.address)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<TrashBinIcon size="sm" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed">
{{
address.post_code && address.city
? `${address.address}, ${address.post_code} ${address.city}`
: address.address
}}
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,97 @@
<script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
});
const emit = defineEmits(['add', 'edit', 'delete']);
const getEmails = (p) => (Array.isArray(p?.emails) ? p.emails : []);
const handleAdd = () => emit('add');
const handleEdit = (id) => emit('edit', id);
const handleDelete = (id, label) => emit('delete', id, label);
</script>
<template>
<div class="flex justify-end mb-3" v-if="edit">
<button
@click="handleAdd"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all"
title="Dodaj email"
>
<PlusIcon size="sm" />
<span>Dodaj email</span>
</button>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<template v-if="getEmails(person).length">
<div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="(email, idx) in getEmails(person)"
:key="idx"
>
<div class="flex items-start justify-between mb-2" v-if="edit">
<div class="flex flex-wrap gap-2">
<span
v-if="email?.label"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
>
{{ email.label }}
</span>
<span
v-else
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800"
>
Email
</span>
</div>
<div v-if="edit">
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
title="Možnosti"
>
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<button
@click="handleEdit(email.id)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<EditIcon size="sm" />
<span>Uredi</span>
</button>
<button
@click="handleDelete(email.id, email?.value || email?.email || email?.address)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<TrashBinIcon size="sm" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed">
{{ email?.value || email?.email || email?.address || "-" }}
</p>
<p v-if="email?.note" class="mt-2 text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
{{ email.note }}
</p>
</div>
</template>
<p v-else class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200">
Ni e-poštnih naslovov.
</p>
</div>
</template>

View File

@ -0,0 +1,455 @@
<script setup>
import { ref, computed } from "vue";
import axios from "axios";
import { router } from "@inertiajs/vue3";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
import PersonUpdateForm from "./PersonUpdateForm.vue";
import AddressCreateForm from "./AddressCreateForm.vue";
import AddressUpdateForm from "./AddressUpdateForm.vue";
import PhoneCreateForm from "./PhoneCreateForm.vue";
import PhoneUpdateForm from "./PhoneUpdateForm.vue";
import EmailCreateForm from "./EmailCreateForm.vue";
import EmailUpdateForm from "./EmailUpdateForm.vue";
import TrrCreateForm from "./TrrCreateForm.vue";
import TrrUpdateForm from "./TrrUpdateForm.vue";
import ConfirmDialog from "../ConfirmDialog.vue";
// Tab components
import PersonInfoPersonTab from "./PersonInfoPersonTab.vue";
import PersonInfoAddressesTab from "./PersonInfoAddressesTab.vue";
import PersonInfoPhonesTab from "./PersonInfoPhonesTab.vue";
import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue";
import PersonInfoTrrTab from "./PersonInfoTrrTab.vue";
import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue";
import Separator from "../ui/separator/Separator.vue";
const props = defineProps({
person: Object,
personEdit: {
type: Boolean,
default: true,
},
edit: {
type: Boolean,
default: true,
},
tabColor: {
type: String,
default: "blue-600",
},
types: {
type: Object,
default: {
address_types: [],
phone_types: [],
},
},
enableSms: { type: Boolean, default: false },
clientCaseUuid: { type: String, default: null },
smsProfiles: { type: Array, default: () => [] },
smsSenders: { type: Array, default: () => [] },
smsTemplates: { type: Array, default: () => [] },
});
// Dialog states
const drawerUpdatePerson = ref(false);
const drawerAddAddress = ref(false);
const drawerAddPhone = ref(false);
const drawerAddEmail = ref(false);
const drawerAddTrr = ref(false);
// Edit states
const editAddress = ref(false);
const editAddressId = ref(0);
const editPhone = ref(false);
const editPhoneId = ref(0);
const editEmail = ref(false);
const editEmailId = ref(0);
const editTrr = ref(false);
const editTrrId = ref(0);
// Confirm dialog state
const confirm = ref({
show: false,
title: "Potrditev brisanja",
message: "",
type: "",
id: 0,
itemName: null,
});
// SMS dialog state
const showSmsDialog = ref(false);
const smsTargetPhone = ref(null);
// Person handlers
const openDrawerUpdateClient = () => {
drawerUpdatePerson.value = true;
};
// Address handlers
const openDrawerAddAddress = (edit = false, id = 0) => {
drawerAddAddress.value = true;
editAddress.value = edit;
editAddressId.value = id;
};
const closeDrawerAddAddress = () => {
drawerAddAddress.value = false;
editAddress.value = false;
editAddressId.value = 0;
};
// Phone handlers
const openDrawerAddPhone = (edit = false, id = 0) => {
editPhone.value = edit;
editPhoneId.value = id;
drawerAddPhone.value = true;
};
// Keep the old name for backward compatibility if needed, but use the correct name
const operDrawerAddPhone = openDrawerAddPhone;
const closeDrawerAddPhone = () => {
drawerAddPhone.value = false;
editPhone.value = false;
editPhoneId.value = 0;
};
// Email handlers
const openDrawerAddEmail = (edit = false, id = 0) => {
drawerAddEmail.value = true;
editEmail.value = edit;
editEmailId.value = id;
};
// TRR handlers
const openDrawerAddTrr = (edit = false, id = 0) => {
drawerAddTrr.value = true;
editTrr.value = edit;
editTrrId.value = id;
};
// Confirm dialog handlers
const openConfirm = (type, id, label = "") => {
confirm.value = {
show: true,
title: "Potrditev brisanja",
message: label
? `Ali res želite izbrisati "${label}"?`
: "Ali res želite izbrisati izbran element?",
type,
id,
itemName: label || null,
};
};
const closeConfirm = () => {
confirm.value.show = false;
confirm.value.itemName = null;
};
const onConfirmDelete = async () => {
const { type, id } = confirm.value;
try {
if (type === "email") {
await axios.delete(
route("person.email.delete", { person: props.person, email_id: id })
);
const list = props.person.emails || [];
const idx = list.findIndex((e) => e.id === id);
if (idx !== -1) list.splice(idx, 1);
closeConfirm();
} else if (type === "trr") {
await axios.delete(
route("person.trr.delete", { person: props.person, trr_id: id })
);
let list =
props.person.trrs ||
props.person.bank_accounts ||
props.person.accounts ||
props.person.bankAccounts ||
[];
const idx = list.findIndex((a) => a.id === id);
if (idx !== -1) list.splice(idx, 1);
closeConfirm();
} else if (type === "address") {
await axios.delete(
route("person.address.delete", { person: props.person, address_id: id })
);
const list = props.person.addresses || [];
const idx = list.findIndex((a) => a.id === id);
if (idx !== -1) list.splice(idx, 1);
closeConfirm();
} else if (type === "phone") {
router.delete(
route("person.phone.delete", { person: props.person, phone_id: id }),
{
preserveScroll: true,
onSuccess: () => {
closeConfirm();
},
onError: (errors) => {
console.error("Delete failed", errors);
closeConfirm();
},
}
);
}
} catch (e) {
console.error("Delete failed", e?.response || e);
closeConfirm();
}
};
// SMS handlers
const openSmsDialog = (phone) => {
if (!props.enableSms || !props.clientCaseUuid) return;
smsTargetPhone.value = phone;
showSmsDialog.value = true;
};
const closeSmsDialog = () => {
showSmsDialog.value = false;
smsTargetPhone.value = null;
};
// Tab event handlers
const handlePersonEdit = () => openDrawerUpdateClient();
const handleAddressAdd = () => openDrawerAddAddress(false, 0);
const handleAddressEdit = (id) => openDrawerAddAddress(true, id);
const handleAddressDelete = (id, label) => openConfirm("address", id, label);
const handlePhoneAdd = () => openDrawerAddPhone(false, 0);
const handlePhoneEdit = (id) => openDrawerAddPhone(true, id);
const handlePhoneDelete = (id, label) => openConfirm("phone", id, label);
const handlePhoneSms = (phone) => openSmsDialog(phone);
const handleEmailAdd = () => openDrawerAddEmail(false, 0);
const handleEmailEdit = (id) => openDrawerAddEmail(true, id);
const handleEmailDelete = (id, label) => openConfirm("email", id, label);
const handleTrrAdd = () => openDrawerAddTrr(false, 0);
const handleTrrEdit = (id) => openDrawerAddTrr(true, id);
const handleTrrDelete = (id, label) => openConfirm("trr", id, label);
// Computed counts for badges
const addressesCount = computed(() => (props.person?.addresses || []).length);
const phonesCount = computed(() => (props.person?.phones || []).length);
const emailsCount = computed(() => (props.person?.emails || []).length);
const trrsCount = computed(() => {
const list = props.person?.trrs ||
props.person?.bank_accounts ||
props.person?.accounts ||
props.person?.bankAccounts || [];
return list.length;
});
// Format badge count (show 999+ if >= 999)
const formatBadgeCount = (count) => {
return count >= 999 ? '999+' : String(count);
};
</script>
<template>
<Tabs default-value="person" class="mt-2">
<TabsList class="flex w-full bg-white gap-2 p-1">
<TabsTrigger value="person" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2">Oseba</TabsTrigger>
<TabsTrigger value="addresses" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<div class="flex items-center justify-between gap-2 w-full">
<span>Naslovi</span>
<span
v-if="addressesCount > 0"
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(addressesCount) }}
</span>
</div>
</TabsTrigger>
<TabsTrigger value="phones" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<div class="flex items-center justify-between gap-2 w-full">
<span>Telefonske</span>
<span
v-if="phonesCount > 0"
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(phonesCount) }}
</span>
</div>
</TabsTrigger>
<TabsTrigger value="emails" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<div class="flex items-center justify-between gap-2 w-full">
<span>Email</span>
<span
v-if="emailsCount > 0"
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(emailsCount) }}
</span>
</div>
</TabsTrigger>
<TabsTrigger value="trr" class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2 px-3">
<div class="flex items-center justify-between gap-2 w-full">
<span>TRR</span>
<span
v-if="trrsCount > 0"
class="h-5 min-w-[20px] px-1.5 flex items-center justify-center rounded-full bg-red-600 text-white text-xs font-semibold leading-tight shrink-0"
>
{{ formatBadgeCount(trrsCount) }}
</span>
</div>
</TabsTrigger>
</TabsList>
<TabsContent value="person" class="py-2">
<PersonInfoPersonTab
:person="person"
:edit="edit"
:person-edit="personEdit"
@edit="handlePersonEdit"
/>
</TabsContent>
<TabsContent value="addresses" class="py-4">
<PersonInfoAddressesTab
:person="person"
:edit="edit"
@add="handleAddressAdd"
@edit="handleAddressEdit"
@delete="handleAddressDelete"
/>
</TabsContent>
<TabsContent value="phones" class="py-4">
<PersonInfoPhonesTab
:person="person"
:edit="edit"
:enable-sms="enableSms && !!clientCaseUuid"
@add="handlePhoneAdd"
@edit="handlePhoneEdit"
@delete="handlePhoneDelete"
@sms="handlePhoneSms"
/>
</TabsContent>
<TabsContent value="emails" class="py-4">
<PersonInfoEmailsTab
:person="person"
:edit="edit"
@add="handleEmailAdd"
@edit="handleEmailEdit"
@delete="handleEmailDelete"
/>
</TabsContent>
<TabsContent value="trr" class="py-4">
<PersonInfoTrrTab
:person="person"
:edit="edit"
@add="handleTrrAdd"
@edit="handleTrrEdit"
@delete="handleTrrDelete"
/>
</TabsContent>
</Tabs>
<!-- Person Update Dialog -->
<PersonUpdateForm
:show="drawerUpdatePerson"
@close="drawerUpdatePerson = false"
:person="person"
/>
<!-- Address Dialogs -->
<AddressCreateForm
:show="drawerAddAddress && !editAddress"
@close="closeDrawerAddAddress"
:person="person"
:types="types.address_types"
:id="editAddressId"
:edit="editAddress"
/>
<AddressUpdateForm
:show="drawerAddAddress && editAddress"
@close="closeDrawerAddAddress"
:person="person"
:types="types.address_types"
:id="editAddressId"
/>
<!-- Phone Dialogs -->
<PhoneCreateForm
:show="drawerAddPhone && !editPhone"
@close="closeDrawerAddPhone"
:person="person"
:types="types.phone_types"
/>
<PhoneUpdateForm
:show="drawerAddPhone && editPhone"
@close="closeDrawerAddPhone"
:person="person"
:types="types.phone_types"
:id="editPhoneId"
/>
<!-- Email Dialogs -->
<EmailCreateForm
:show="drawerAddEmail && !editEmail"
@close="drawerAddEmail = false"
:person="person"
:types="types.email_types ?? []"
:is-client-context="!!person?.client"
/>
<EmailUpdateForm
:show="drawerAddEmail && editEmail"
@close="drawerAddEmail = false"
:person="person"
:types="types.email_types ?? []"
:id="editEmailId"
:is-client-context="!!person?.client"
/>
<!-- TRR Dialogs -->
<TrrCreateForm
:show="drawerAddTrr && !editTrr"
@close="drawerAddTrr = false"
:person="person"
:types="types.trr_types ?? []"
:banks="types.banks ?? []"
:currencies="types.currencies ?? ['EUR']"
/>
<TrrUpdateForm
:show="drawerAddTrr && editTrr"
@close="drawerAddTrr = false"
:person="person"
:types="types.trr_types ?? []"
:banks="types.banks ?? []"
:currencies="types.currencies ?? ['EUR']"
:id="editTrrId"
/>
<!-- Confirm Deletion Dialog -->
<ConfirmDialog
:show="confirm.show"
:title="confirm.title"
:message="confirm.message"
:item-name="confirm.itemName"
confirm-text="Izbriši"
cancel-text="Prekliči"
:danger="true"
@close="closeConfirm"
@confirm="onConfirmDelete"
/>
<!-- SMS Dialog -->
<PersonInfoSmsDialog
v-if="clientCaseUuid"
:show="showSmsDialog"
:phone="smsTargetPhone"
:client-case-uuid="clientCaseUuid"
:sms-profiles="smsProfiles"
:sms-senders="smsSenders"
:sms-templates="smsTemplates"
@close="closeSmsDialog"
/>
</template>

View File

@ -0,0 +1,93 @@
<script setup>
import { UserEditIcon } from "@/Utilities/Icons";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
personEdit: { type: Boolean, default: true },
});
const emit = defineEmits(['edit']);
const getMainAddress = (adresses) => {
const addr = adresses.filter((a) => a.type.id === 1)[0] ?? "";
if (addr !== "") {
const tail = addr.post_code && addr.city ? `, ${addr.post_code} ${addr.city}` : "";
const country = addr.country !== "" ? ` - ${addr.country}` : "";
return addr.address !== "" ? addr.address + tail + country : "";
}
return "";
};
const getMainPhone = (phones) => {
const pho = phones.filter((a) => a.type.id === 1)[0] ?? "";
if (pho !== "") {
const countryCode = pho.country_code !== null ? `+${pho.country_code} ` : "";
return pho.nu !== "" ? countryCode + pho.nu : "";
}
return "";
};
const handleEdit = () => {
emit('edit');
};
</script>
<template>
<div class="flex justify-end mb-3">
<button
v-if="edit && personEdit"
@click="handleEdit"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all"
title="Uredi osebo"
>
<UserEditIcon size="md" />
<span>Uredi</span>
</button>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Nu.</p>
<p class="text-sm font-semibold text-gray-900">{{ person.nu }}</p>
</div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Name.</p>
<p class="text-sm font-semibold text-gray-900">
{{ person.full_name }}
</p>
</div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Tax NU.</p>
<p class="text-sm font-semibold text-gray-900">
{{ person.tax_number }}
</p>
</div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Social security NU.</p>
<p class="text-sm font-semibold text-gray-900">
{{ person.social_security_number }}
</p>
</div>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Address</p>
<p class="text-sm font-medium text-gray-900">
{{ getMainAddress(person.addresses) }}
</p>
</div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Phone</p>
<p class="text-sm font-medium text-gray-900">
{{ getMainPhone(person.phones) }}
</p>
</div>
<div class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Description</p>
<p class="text-sm font-medium text-gray-900">
{{ person.description }}
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,98 @@
<script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
enableSms: { type: Boolean, default: false },
});
const emit = defineEmits(['add', 'edit', 'delete', 'sms']);
const getPhones = (p) => (Array.isArray(p?.phones) ? p.phones : []);
const handleAdd = () => emit('add');
const handleEdit = (id) => emit('edit', id);
const handleDelete = (id, label) => emit('delete', id, label);
const handleSms = (phone) => emit('sms', phone);
</script>
<template>
<div class="flex justify-end mb-3" v-if="edit">
<button
type="button"
@click="handleAdd"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all"
title="Dodaj telefon"
>
<PlusIcon size="sm" />
<span>Dodaj telefon</span>
</button>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<template v-if="getPhones(person).length">
<div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="phone in getPhones(person)"
:key="phone.id"
>
<div class="flex items-start justify-between mb-2">
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
+{{ phone.country_code }}
</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ phone && phone.type && phone.type.name ? phone.type.name : "—" }}
</span>
</div>
<div class="flex items-center gap-1">
<!-- Send SMS only in ClientCase person context -->
<button
v-if="enableSms"
@click="handleSms(phone)"
title="Pošlji SMS"
class="px-2.5 py-1 text-xs font-medium text-indigo-700 bg-indigo-50 border border-indigo-200 hover:bg-indigo-100 rounded-lg transition-colors"
>
SMS
</button>
<Dropdown v-if="edit" align="right" width="48">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
title="Možnosti"
>
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<button
@click="handleEdit(phone.id)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<EditIcon size="sm" />
<span>Uredi</span>
</button>
<button
@click="handleDelete(phone.id, phone.nu)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<TrashBinIcon size="sm" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed">{{ phone.nu }}</p>
</div>
</template>
<p v-else class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200">
Ni telefonov.
</p>
</div>
</template>

View File

@ -0,0 +1,480 @@
<script setup>
import { ref, watch, computed } from "vue";
import DialogModal from "@/Components/DialogModal.vue";
import { router, usePage } from "@inertiajs/vue3";
const props = defineProps({
show: { type: Boolean, default: false },
phone: Object,
clientCaseUuid: { type: String, default: null },
smsProfiles: { type: Array, default: () => [] },
smsSenders: { type: Array, default: () => [] },
smsTemplates: { type: Array, default: () => [] },
});
const emit = defineEmits(['close']);
// SMS dialog state
const smsMessage = ref("");
const smsSending = ref(false);
// Page-level props fallback for SMS metadata
const page = usePage();
const pageProps = computed(() => page?.props ?? {});
const pageSmsProfiles = computed(() => {
const fromProps =
Array.isArray(props.smsProfiles) && props.smsProfiles.length
? props.smsProfiles
: null;
return fromProps ?? pageProps.value?.sms_profiles ?? [];
});
const pageSmsSenders = computed(() => {
const fromProps =
Array.isArray(props.smsSenders) && props.smsSenders.length ? props.smsSenders : null;
return fromProps ?? pageProps.value?.sms_senders ?? [];
});
const pageSmsTemplates = computed(() => {
const fromProps =
Array.isArray(props.smsTemplates) && props.smsTemplates.length
? props.smsTemplates
: null;
return fromProps ?? pageProps.value?.sms_templates ?? [];
});
// Helpers: EU formatter and token renderer
const formatEu = (value, decimals = 2) => {
if (value === null || value === undefined || value === "") {
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(0);
}
const num =
typeof value === "number"
? value
: parseFloat(String(value).replace(/\./g, "").replace(",", "."));
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(isNaN(num) ? 0 : num);
};
const renderTokens = (text, vars) => {
if (!text) return "";
const resolver = (obj, path) => {
if (!obj) return null;
if (Object.prototype.hasOwnProperty.call(obj, path)) return obj[path];
const segs = path.split(".");
let cur = obj;
for (const s of segs) {
if (cur && typeof cur === "object" && s in cur) {
cur = cur[s];
} else {
return null;
}
}
return cur;
};
return text.replace(/\{([a-zA-Z0-9_\.]+)\}/g, (_, key) => {
const val = resolver(vars, key);
return val !== null && val !== undefined ? String(val) : `{${key}}`;
});
};
// SMS length, encoding and credits
const GSM7_EXTENDED = new Set(["^", "{", "}", "\\", "[", "~", "]", "|"]);
const isGsm7 = (text) => {
for (const ch of text || "") {
if (ch === "€") continue;
const code = ch.charCodeAt(0);
if (code >= 0x80) return false;
}
return true;
};
const gsm7Length = (text) => {
let len = 0;
for (const ch of text || "") {
if (ch === "€" || GSM7_EXTENDED.has(ch)) {
len += 2;
} else {
len += 1;
}
}
return len;
};
const ucs2Length = (text) => (text ? text.length : 0);
const smsEncoding = computed(() => (isGsm7(smsMessage.value) ? "GSM-7" : "UCS-2"));
const charCount = computed(() =>
smsEncoding.value === "GSM-7"
? gsm7Length(smsMessage.value)
: ucs2Length(smsMessage.value)
);
const perSegment = computed(() => {
const count = charCount.value;
if (smsEncoding.value === "GSM-7") {
return count <= 160 ? 160 : 153;
}
return count <= 70 ? 70 : 67;
});
const segments = computed(() => {
const count = charCount.value;
const size = perSegment.value || 1;
return count > 0 ? Math.ceil(count / size) : 0;
});
const creditsNeeded = computed(() => segments.value);
const maxAllowed = computed(() => (smsEncoding.value === "GSM-7" ? 640 : 320));
const remaining = computed(() => Math.max(0, maxAllowed.value - charCount.value));
const truncateToLimit = (text, limit, encoding) => {
if (!text) return "";
if (limit <= 0) return "";
if (encoding === "UCS-2") {
return text.slice(0, limit);
}
let acc = 0;
let out = "";
for (const ch of text) {
const cost = ch === "€" || GSM7_EXTENDED.has(ch) ? 2 : 1;
if (acc + cost > limit) break;
out += ch;
acc += cost;
}
return out;
};
watch(smsMessage, (val) => {
const limit = maxAllowed.value;
if (charCount.value > limit) {
smsMessage.value = truncateToLimit(val, limit, smsEncoding.value);
}
});
const contractsForCase = ref([]);
const selectedContractUuid = ref(null);
const selectedProfileId = ref(null);
const selectedSenderId = ref(null);
const deliveryReport = ref(false);
const selectedTemplateId = ref(null);
const sendersForSelectedProfile = computed(() => {
if (!selectedProfileId.value) return pageSmsSenders.value;
return (pageSmsSenders.value || []).filter(
(s) => s.profile_id === selectedProfileId.value
);
});
watch(selectedProfileId, () => {
if (!selectedSenderId.value) return;
const ok = sendersForSelectedProfile.value.some((s) => s.id === selectedSenderId.value);
if (!ok) selectedSenderId.value = null;
});
watch(sendersForSelectedProfile, (list) => {
if (!Array.isArray(list)) return;
if (!selectedSenderId.value && list.length > 0) {
selectedSenderId.value = list[0].id;
}
});
const buildVarsFromSelectedContract = () => {
const uuid = selectedContractUuid.value;
if (!uuid) return {};
const c = (contractsForCase.value || []).find((x) => x.uuid === uuid);
if (!c) return {};
const vars = {
contract: {
uuid: c.uuid,
reference: c.reference,
start_date: c.start_date || "",
end_date: c.end_date || "",
},
};
if (c.account) {
vars.account = {
reference: c.account.reference,
type: c.account.type,
initial_amount:
c.account.initial_amount ??
(c.account.initial_amount_raw ? formatEu(c.account.initial_amount_raw) : null),
balance_amount:
c.account.balance_amount ??
(c.account.balance_amount_raw ? formatEu(c.account.balance_amount_raw) : null),
initial_amount_raw: c.account.initial_amount_raw ?? null,
balance_amount_raw: c.account.balance_amount_raw ?? null,
};
}
return vars;
};
const updateSmsFromSelection = async () => {
if (!selectedTemplateId.value) return;
try {
const url = route("clientCase.sms.preview", { client_case: props.clientCaseUuid });
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN":
document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ||
"",
},
body: JSON.stringify({
template_id: selectedTemplateId.value,
contract_uuid: selectedContractUuid.value || null,
}),
credentials: "same-origin",
});
if (res.ok) {
const data = await res.json();
if (typeof data?.content === "string" && data.content.trim() !== "") {
smsMessage.value = data.content;
return;
}
}
} catch (e) {
// ignore and fallback
}
const tpl = (pageSmsTemplates.value || []).find(
(t) => t.id === selectedTemplateId.value
);
if (tpl && typeof tpl.content === "string") {
smsMessage.value = renderTokens(tpl.content, buildVarsFromSelectedContract());
}
};
watch(selectedTemplateId, () => {
if (!selectedTemplateId.value) return;
updateSmsFromSelection();
});
watch(selectedContractUuid, () => {
if (!selectedTemplateId.value) return;
updateSmsFromSelection();
});
watch(pageSmsTemplates, (list) => {
if (!Array.isArray(list)) return;
if (!selectedTemplateId.value && list.length > 0) {
selectedTemplateId.value = list[0].id;
}
});
const loadContractsForCase = async () => {
try {
const url = route("clientCase.contracts.list", { client_case: props.clientCaseUuid });
const res = await fetch(url, {
headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin",
});
const json = await res.json();
contractsForCase.value = Array.isArray(json?.data) ? json.data : [];
} catch (e) {
contractsForCase.value = [];
}
};
watch(
() => props.show,
(newVal) => {
if (newVal) {
smsMessage.value = "";
selectedProfileId.value =
(pageSmsProfiles.value && pageSmsProfiles.value[0]?.id) || null;
if (selectedProfileId.value) {
const prof = (pageSmsProfiles.value || []).find(
(p) => p.id === selectedProfileId.value
);
if (prof && prof.default_sender_id) {
const inList = sendersForSelectedProfile.value.find(
(s) => s.id === prof.default_sender_id
);
selectedSenderId.value = inList ? prof.default_sender_id : null;
} else {
selectedSenderId.value = null;
}
} else {
selectedSenderId.value = null;
}
deliveryReport.value = false;
selectedTemplateId.value =
(pageSmsTemplates.value && pageSmsTemplates.value[0]?.id) || null;
loadContractsForCase();
}
}
);
const closeSmsDialog = () => {
emit('close');
};
const submitSms = () => {
if (!props.phone || !smsMessage.value || !props.clientCaseUuid) {
return;
}
smsSending.value = true;
router.post(
route("clientCase.phone.sms", {
client_case: props.clientCaseUuid,
phone_id: props.phone.id,
}),
{
message: smsMessage.value,
template_id: selectedTemplateId.value,
contract_uuid: selectedContractUuid.value,
profile_id: selectedProfileId.value,
sender_id: selectedSenderId.value,
delivery_report: !!deliveryReport.value,
},
{
preserveScroll: true,
onFinish: () => {
smsSending.value = false;
closeSmsDialog();
},
}
);
};
</script>
<template>
<DialogModal :show="show" @close="closeSmsDialog">
<template #title>Pošlji SMS</template>
<template #content>
<div class="space-y-2">
<p class="text-sm text-gray-600">
Prejemnik: <span class="font-mono">{{ phone?.nu }}</span>
<span v-if="phone?.country_code" class="ml-2 text-xs text-gray-500"
>CC +{{ phone.country_code }}</span
>
</p>
<!-- Profile & Sender selectors -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>
<label class="block text-sm font-medium text-gray-700">Profil</label>
<select
v-model="selectedProfileId"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="p in pageSmsProfiles" :key="p.id" :value="p.id">
{{ p.name || "Profil #" + p.id }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Pošiljatelj</label>
<select
v-model="selectedSenderId"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="s in sendersForSelectedProfile" :key="s.id" :value="s.id">
{{ s.name || s.phone || "Sender #" + s.id }}
</option>
</select>
</div>
</div>
<!-- Contract selector -->
<div>
<label class="block text-sm font-medium text-gray-700">Pogodba</label>
<select
v-model="selectedContractUuid"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="c in contractsForCase" :key="c.uuid" :value="c.uuid">
{{ c.reference || c.uuid }}
</option>
</select>
<p class="mt-1 text-xs text-gray-500">
Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in {account.*}
mest.
</p>
</div>
<!-- Template selector -->
<div>
<label class="block text-sm font-medium text-gray-700">Predloga</label>
<select
v-model="selectedTemplateId"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="t in pageSmsTemplates" :key="t.id" :value="t.id">
{{ t.name || "Predloga #" + t.id }}
</option>
</select>
</div>
<label class="block text-sm font-medium text-gray-700">Vsebina sporočila</label>
<textarea
v-model="smsMessage"
rows="4"
class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Vpišite SMS vsebino..."
></textarea>
<!-- Live counters -->
<div class="mt-1 text-xs text-gray-600 flex flex-col gap-1">
<div>
<span class="font-medium">Znakov:</span>
<span class="font-mono">{{ charCount }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Kodiranje:</span>
<span>{{ smsEncoding }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Deli SMS:</span>
<span class="font-mono">{{ segments }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Krediti:</span>
<span class="font-mono">{{ creditsNeeded }}</span>
</div>
<div>
<span class="font-medium">Omejitev:</span>
<span class="font-mono">{{ maxAllowed }}</span>
<span class="mx-2">|</span>
<span class="font-medium">Preostanek:</span>
<span class="font-mono" :class="{ 'text-red-600': remaining === 0 }">{{
remaining
}}</span>
</div>
<p class="text-[11px] text-gray-500 leading-snug">
Dolžina 160 znakov velja samo pri pošiljanju sporočil, ki vsebujejo znake, ki
ne zahtevajo enkodiranja. Če npr. želite pošiljati šumnike, ki niso del
7-bitne abecede GSM, morate uporabiti Unicode enkodiranje (UCS2). V tem
primeru je največja dolžina enega SMS sporočila 70 znakov (pri daljših
sporočilih 67 znakov na del), medtem ko je pri GSM7 160 znakov (pri daljših
sporočilih 153 znakov na del). Razširjeni znaki (^{{ "{" }}}}\\[]~| in )
štejejo dvojno. Največja dovoljena dolžina po ponudniku: 640 (GSM7) oziroma
320 (UCS2) znakov.
</p>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 mt-1">
<input
type="checkbox"
v-model="deliveryReport"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
Zahtevaj poročilo o dostavi
</label>
</div>
</template>
<template #footer>
<button class="px-3 py-1 rounded border mr-2" @click="closeSmsDialog">
Prekliči
</button>
<button
class="px-3 py-1 rounded bg-indigo-600 text-white disabled:opacity-50"
:disabled="smsSending || !smsMessage"
@click="submitSms"
>
Pošlji
</button>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,116 @@
<script setup>
import { PlusIcon, DottedMenu, EditIcon, TrashBinIcon } from "@/Utilities/Icons";
import Dropdown from "../Dropdown.vue";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
});
const emit = defineEmits(['add', 'edit', 'delete']);
const getTRRs = (p) => {
if (Array.isArray(p?.trrs)) return p.trrs;
if (Array.isArray(p?.bank_accounts)) return p.bank_accounts;
if (Array.isArray(p?.accounts)) return p.accounts;
if (Array.isArray(p?.bankAccounts)) return p.bankAccounts;
return [];
};
const handleAdd = () => emit('add');
const handleEdit = (id) => emit('edit', id);
const handleDelete = (id, label) => emit('delete', id, label);
</script>
<template>
<div class="flex justify-end mb-3" v-if="edit">
<button
@click="handleAdd"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all"
title="Dodaj TRR"
>
<PlusIcon size="sm" />
<span>Dodaj TRR</span>
</button>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<template v-if="getTRRs(person).length">
<div
class="rounded-lg p-4 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
v-for="(acc, idx) in getTRRs(person)"
:key="idx"
>
<div class="flex items-start justify-between mb-2" v-if="edit">
<div class="flex flex-wrap gap-2">
<span
v-if="acc?.bank_name"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
>
{{ acc.bank_name }}
</span>
<span
v-if="acc?.holder_name"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800"
>
{{ acc.holder_name }}
</span>
<span
v-if="acc?.currency"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
>
{{ acc.currency }}
</span>
</div>
<div v-if="edit">
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-colors"
title="Možnosti"
>
<DottedMenu size="sm" css="text-gray-600" />
</button>
</template>
<template #content>
<div class="py-1">
<button
@click="handleEdit(acc.id)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<EditIcon size="sm" />
<span>Uredi</span>
</button>
<button
@click="handleDelete(acc.id, acc?.iban || acc?.account_number)"
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<TrashBinIcon size="sm" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
</div>
</div>
<p class="text-sm font-medium text-gray-900 leading-relaxed font-mono">
{{
acc?.iban ||
acc?.account_number ||
acc?.account ||
acc?.nu ||
acc?.number ||
"-"
}}
</p>
<p v-if="acc?.notes" class="mt-2 text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
{{ acc.notes }}
</p>
</div>
</template>
<p v-else class="col-span-full p-4 text-sm text-gray-500 text-center bg-gray-50 rounded-lg border border-gray-200">
Ni TRR računov.
</p>
</div>
</template>

View File

@ -0,0 +1,183 @@
<script setup>
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import axios from 'axios';
import { ref } from 'vue';
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
const props = defineProps({
show: {
type: Boolean,
default: false
},
person: Object
});
const processingUpdate = ref(false);
const emit = defineEmits(['close']);
const formSchema = toTypedSchema(
z.object({
full_name: z.string().min(1, "Naziv je obvezen."),
tax_number: z.string().optional(),
social_security_number: z.string().optional(),
description: z.string().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
full_name: props.person?.full_name || '',
tax_number: props.person?.tax_number || '',
social_security_number: props.person?.social_security_number || '',
description: props.person?.description || ''
},
});
const close = () => {
emit('close');
setTimeout(() => {
form.resetForm({
values: {
full_name: props.person?.full_name || '',
tax_number: props.person?.tax_number || '',
social_security_number: props.person?.social_security_number || '',
description: props.person?.description || ''
}
});
}, 500);
}
const updatePerson = async () => {
processingUpdate.value = true;
const { values } = form;
try {
const response = await axios({
method: 'put',
url: route('person.update', props.person),
data: values
});
props.person.full_name = response.data.person.full_name;
props.person.tax_number = response.data.person.tax_number;
props.person.social_security_number = response.data.person.social_security_number;
props.person.description = response.data.person.description;
processingUpdate.value = false;
close();
} catch (reason) {
const errors = reason.response?.data?.errors || {};
// Map axios 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]);
});
processingUpdate.value = false;
}
}
const onSubmit = form.handleSubmit(() => {
updatePerson();
});
const onConfirm = () => {
onSubmit();
}
</script>
<template>
<UpdateDialog
:show="show"
:title="`Posodobi ${person.full_name}`"
confirm-text="Shrani"
:processing="processingUpdate"
@close="close"
@confirm="onConfirm"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title>
Oseba
</template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="full_name">
<FormItem>
<FormLabel>Naziv</FormLabel>
<FormControl>
<Input
id="cfullname"
type="text"
placeholder="Naziv"
autocomplete="full-name"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="tax_number">
<FormItem>
<FormLabel>Davčna</FormLabel>
<FormControl>
<Input
id="ctaxnumber"
type="text"
placeholder="Davčna številka"
autocomplete="tax-number"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="social_security_number">
<FormItem>
<FormLabel>Matična / Emšo</FormLabel>
<FormControl>
<Input
id="csocialSecurityNumber"
type="text"
placeholder="Matična / Emšo"
autocomplete="social-security-number"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Textarea
id="cdescription"
placeholder="Opis"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>
</UpdateDialog>
</template>

View File

@ -0,0 +1,229 @@
<script setup>
import { computed, ref } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from "@inertiajs/vue3";
import CreateDialog from "../Dialogs/CreateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
const props = defineProps({
show: {
type: Boolean,
default: false,
},
person: Object,
types: Array,
});
const emit = defineEmits(["close"]);
const formSchema = toTypedSchema(
z.object({
nu: z.string().min(1, "Številka je obvezna."),
country_code: z.number().default(386),
type_id: z.number().nullable(),
description: z.string().optional(),
validated: z.boolean().default(false),
phone_type: z.string().nullable().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
nu: "",
country_code: 386,
type_id: props.types?.[0]?.id ?? null,
description: "",
validated: false,
phone_type: null,
},
});
const processing = ref(false);
const close = () => {
emit("close");
setTimeout(() => {
form.resetForm();
processing.value = false;
}, 500);
};
const resetForm = () => {
form.resetForm({
values: {
nu: "",
country_code: 386,
type_id: props.types?.[0]?.id ?? null,
description: "",
validated: false,
phone_type: null,
},
});
};
const countryOptions = [
{ value: 386, label: "+386 (Slovenija)" },
{ value: 385, label: "+385 (Hrvaška)" },
{ value: 39, label: "+39 (Italija)" },
{ value: 36, label: "+36 (Madžarska)" },
{ value: 43, label: "+43 (Avstrija)" },
{ value: 381, label: "+381 (Srbija)" },
{ value: 387, label: "+387 (Bosna in Hercegovina)" },
{ value: 382, label: "+382 (Črna gora)" },
];
const phoneTypeOptions = [
{ value: null, label: "—" },
{ value: "mobile", label: "Mobilni" },
{ value: "landline", label: "Stacionarni" },
{ value: "voip", label: "VOIP" },
];
const create = async () => {
processing.value = true;
const { values } = form;
router.post(
route("person.phone.create", props.person),
values,
{
preserveScroll: true,
onSuccess: () => {
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
const onSubmit = form.handleSubmit(() => {
create();
});
</script>
<template>
<CreateDialog
:show="show"
title="Dodaj novi telefon"
confirm-text="Shrani"
:processing="processing"
@close="close"
@confirm="onSubmit"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title> Telefon </template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="nu">
<FormItem>
<FormLabel>Številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="country_code">
<FormItem>
<FormLabel>Koda države tel.</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi kodo države" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="type_id">
<FormItem>
<FormLabel>Tip</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi tip" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="type in types" :key="type.id" :value="type.id">
{{ type.name }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="phone_type">
<FormItem>
<FormLabel>Vrsta telefona (enum)</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi vrsto" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="validated">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox :checked="value" @update:checked="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Potrjeno</FormLabel>
</div>
</FormItem>
</FormField>
</div>
</form>
</CreateDialog>
</template>

View File

@ -0,0 +1,254 @@
<script setup>
import { ref, watch } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from "@inertiajs/vue3";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
const props = defineProps({
show: {
type: Boolean,
default: false,
},
person: Object,
types: Array,
id: {
type: Number,
default: 0,
},
});
const emit = defineEmits(["close"]);
const formSchema = toTypedSchema(
z.object({
nu: z.string().min(1, "Številka je obvezna."),
country_code: z.number().default(386),
type_id: z.number().nullable(),
description: z.string().optional(),
validated: z.boolean().default(false),
phone_type: z.string().nullable().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
nu: "",
country_code: 386,
type_id: props.types?.[0]?.id ?? null,
description: "",
validated: false,
phone_type: null,
},
});
const processing = ref(false);
const close = () => {
emit("close");
setTimeout(() => {
form.resetForm();
processing.value = false;
}, 500);
};
const resetForm = () => {
form.resetForm({
values: {
nu: "",
country_code: 386,
type_id: props.types?.[0]?.id ?? null,
description: "",
validated: false,
phone_type: null,
},
});
};
const countryOptions = [
{ value: 386, label: "+386 (Slovenija)" },
{ value: 385, label: "+385 (Hrvaška)" },
{ value: 39, label: "+39 (Italija)" },
{ value: 36, label: "+36 (Madžarska)" },
{ value: 43, label: "+43 (Avstrija)" },
{ value: 381, label: "+381 (Srbija)" },
{ value: 387, label: "+387 (Bosna in Hercegovina)" },
{ value: 382, label: "+382 (Črna gora)" },
];
const phoneTypeOptions = [
{ value: null, label: "—" },
{ value: "mobile", label: "Mobilni" },
{ value: "landline", label: "Stacionarni" },
{ value: "voip", label: "VOIP" },
];
function hydrateFromProps() {
if (props.id) {
const p = props.person?.phones?.find((x) => x.id === props.id);
if (p) {
form.setValues({
nu: p.nu || "",
country_code: p.country_code ?? 386,
type_id: p.type_id ?? (props.types?.[0]?.id ?? null),
description: p.description || "",
validated: !!p.validated,
phone_type: p.phone_type ?? null,
});
return;
}
}
resetForm();
}
watch(() => props.id, () => hydrateFromProps(), { immediate: true });
watch(() => props.show, (val) => { if (val) hydrateFromProps(); });
const update = async () => {
processing.value = true;
const { values } = form;
router.put(
route("person.phone.update", { person: props.person, phone_id: props.id }),
values,
{
preserveScroll: true,
onSuccess: () => {
close();
resetForm();
},
onError: (errors) => {
Object.keys(errors).forEach((field) => {
const errorMessages = Array.isArray(errors[field])
? errors[field]
: [errors[field]];
form.setFieldError(field, errorMessages[0]);
});
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
};
const onSubmit = form.handleSubmit(() => {
update();
});
</script>
<template>
<UpdateDialog
:show="show"
title="Spremeni telefon"
confirm-text="Shrani"
:processing="processing"
@close="close"
@confirm="onSubmit"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title> Telefon </template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="nu">
<FormItem>
<FormLabel>Številka</FormLabel>
<FormControl>
<Input type="text" placeholder="Številka telefona" autocomplete="tel" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="country_code">
<FormItem>
<FormLabel>Koda države tel.</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi kodo države" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="option in countryOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="type_id">
<FormItem>
<FormLabel>Tip</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi tip" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="type in types" :key="type.id" :value="type.id">
{{ type.name }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="phone_type">
<FormItem>
<FormLabel>Vrsta telefona (enum)</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi vrsto" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="option in phoneTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="validated">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox :checked="value" @update:checked="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Potrjeno</FormLabel>
</div>
</FormItem>
</FormField>
</div>
</form>
</UpdateDialog>
</template>

View File

@ -0,0 +1,324 @@
<script setup>
import { ref, watch } from 'vue';
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import axios from 'axios';
import CreateDialog from '../Dialogs/CreateDialog.vue';
import UpdateDialog from '../Dialogs/UpdateDialog.vue';
import SectionTitle from '../SectionTitle.vue';
import {
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
const props = defineProps({
show: { type: Boolean, default: false },
person: { type: Object, required: true },
currencies: { type: Array, default: () => ['EUR'] },
edit: { type: Boolean, default: false },
id: { type: Number, default: 0 },
});
const processing = ref(false);
const errors = ref({});
const emit = defineEmits(['close']);
const initialCurrency = () => (props.currencies && props.currencies.length ? props.currencies[0] : 'EUR');
const formSchema = toTypedSchema(
z.object({
iban: z.string().optional(),
bank_name: z.string().optional(),
bic_swift: z.string().optional(),
account_number: z.string().optional(),
routing_number: z.string().optional(),
currency: z.string().default(initialCurrency()),
country_code: z.string().optional(),
holder_name: z.string().optional(),
notes: z.string().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
iban: '',
bank_name: '',
bic_swift: '',
account_number: '',
routing_number: '',
currency: initialCurrency(),
country_code: '',
holder_name: '',
notes: ''
},
});
const close = () => {
emit('close');
setTimeout(() => {
errors.value = {};
form.resetForm();
}, 300);
};
const resetForm = () => {
form.resetForm({
values: {
iban: '',
bank_name: '',
bic_swift: '',
account_number: '',
routing_number: '',
currency: initialCurrency(),
country_code: '',
holder_name: '',
notes: ''
}
});
};
const create = async () => {
processing.value = true;
errors.value = {};
const { values } = form;
try {
const { data } = await axios.post(route('person.trr.create', props.person), values);
if (!Array.isArray(props.person.trrs)) props.person.trrs = (props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || []);
(props.person.trrs).push(data.trr);
processing.value = false;
close();
resetForm();
} catch (e) {
errors.value = e?.response?.data?.errors || {};
// Map axios errors to VeeValidate field errors
if (errors.value) {
Object.keys(errors.value).forEach((field) => {
const errorMessages = Array.isArray(errors.value[field])
? errors.value[field]
: [errors.value[field]];
form.setFieldError(field, errorMessages[0]);
});
}
processing.value = false;
}
};
const update = async () => {
processing.value = true;
errors.value = {};
const { values } = form;
try {
const { data } = await axios.put(route('person.trr.update', { person: props.person, trr_id: props.id }), values);
let list = props.person.trrs || props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || [];
const idx = list.findIndex(a => a.id === data.trr.id);
if (idx !== -1) list[idx] = data.trr;
processing.value = false;
close();
resetForm();
} catch (e) {
errors.value = e?.response?.data?.errors || {};
// Map axios errors to VeeValidate field errors
if (errors.value) {
Object.keys(errors.value).forEach((field) => {
const errorMessages = Array.isArray(errors.value[field])
? errors.value[field]
: [errors.value[field]];
form.setFieldError(field, errorMessages[0]);
});
}
processing.value = false;
}
};
watch(
() => props.id,
(id) => {
if (props.edit && id) {
const list = props.person.trrs || props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || [];
const current = list.find(a => a.id === id);
if (current) {
form.setValues({
iban: current.iban || current.account_number || current.number || '',
bank_name: current.bank_name || '',
bic_swift: current.bic_swift || '',
account_number: current.account_number || '',
routing_number: current.routing_number || '',
currency: current.currency || initialCurrency(),
country_code: current.country_code || '',
holder_name: current.holder_name || '',
notes: current.notes || ''
});
return;
}
}
resetForm();
},
{ immediate: true }
);
watch(() => props.show, (val) => {
if (val && props.edit && props.id) {
const list = props.person.trrs || props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || [];
const current = list.find(a => a.id === props.id);
if (current) {
form.setValues({
iban: current.iban || current.account_number || current.number || '',
bank_name: current.bank_name || '',
bic_swift: current.bic_swift || '',
account_number: current.account_number || '',
routing_number: current.routing_number || '',
currency: current.currency || initialCurrency(),
country_code: current.country_code || '',
holder_name: current.holder_name || '',
notes: current.notes || ''
});
}
} else if (val && !props.edit) {
resetForm();
}
});
const submit = () => (props.edit ? update() : create());
const onSubmit = form.handleSubmit(() => {
submit();
});
const onConfirm = () => {
onSubmit();
};
</script>
<template>
<component
:is="edit ? UpdateDialog : CreateDialog"
:show="show"
:title="edit ? 'Spremeni TRR' : 'Dodaj TRR'"
confirm-text="Shrani"
:processing="processing"
@close="close"
@confirm="onConfirm"
>
<form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4">
<template #title>TRR</template>
</SectionTitle>
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="iban">
<FormItem>
<FormLabel>IBAN</FormLabel>
<FormControl>
<Input id="trr_iban" placeholder="IBAN" autocomplete="off" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bank_name">
<FormItem>
<FormLabel>Banka</FormLabel>
<FormControl>
<Input id="trr_bank_name" placeholder="Banka" autocomplete="organization" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bic_swift">
<FormItem>
<FormLabel>BIC / SWIFT</FormLabel>
<FormControl>
<Input id="trr_bic" placeholder="BIC / SWIFT" autocomplete="off" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="account_number">
<FormItem>
<FormLabel>Številka računa</FormLabel>
<FormControl>
<Input id="trr_accnum" placeholder="Številka računa" autocomplete="off" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="routing_number">
<FormItem>
<FormLabel>Usmerjevalna številka (routing)</FormLabel>
<FormControl>
<Input id="trr_route" placeholder="Routing number" autocomplete="off" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-if="currencies && currencies.length" v-slot="{ value, handleChange }" name="currency">
<FormItem>
<FormLabel>Valuta</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Izberi valuto" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="c in currencies" :key="c" :value="c">
{{ c }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="country_code">
<FormItem>
<FormLabel>Koda države (2-znaki, npr. SI)</FormLabel>
<FormControl>
<Input id="trr_cc" placeholder="SI" autocomplete="country" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="holder_name">
<FormItem>
<FormLabel>Imetnik računa</FormLabel>
<FormControl>
<Input id="trr_holder" placeholder="Imetnik računa" autocomplete="name" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="notes">
<FormItem>
<FormLabel>Opombe</FormLabel>
<FormControl>
<Textarea id="trr_notes" placeholder="Opombe" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>
</component>
</template>

File diff suppressed because it is too large Load Diff

View File

@ -1,139 +0,0 @@
<script setup>
import DialogModal from '@/Components/DialogModal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
import TextInput from '@/Components/TextInput.vue';
import axios from 'axios';
import { inject, onMounted, ref } from 'vue';
import InputError from './InputError.vue';
const props = defineProps({
show: {
type: Boolean,
default: false
},
person: Object
});
const processingUpdate = ref(false);
const errors = ref({});
const emit = defineEmits(['close']);
const close = () => {
emit('close');
setTimeout(() => {
errors.value = {};
}, 500);
}
const form = ref({
full_name: props.person.full_name,
tax_number: props.person.tax_number,
social_security_number: props.person.social_security_number,
description: props.person.description
});
const updatePerson = () => {
processingUpdate.value = true;
errors.value = {};
axios({
method: 'put',
url: route('person.update', props.person ),
data: form.value
}).then((response) => {
props.person.full_name = response.data.person.full_name;
props.person.tax_number = response.data.person.tax_number;
props.person.social_security_number = response.data.person.social_security_number;
props.person.description = response.data.person.description;
processingUpdate.value = false;
close();
}).catch((reason) => {
console.log(reason.response.data);
errors.value = reason.response.data.errors;
processingUpdate.value = false;
});
}
</script>
<template>
<DialogModal
:show="show"
@close="close"
>
<template #title>Posodobi {{ person.full_name }}</template>
<template #content>
<form @submit.prevent="updatePerson">
<SectionTitle class="border-b mb-4">
<template #title>
Oseba
</template>
</SectionTitle>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="cfulname" value="Naziv" />
<TextInput
id="cfullname"
ref="cfullnameInput"
v-model="form.full_name"
type="text"
class="mt-1 block w-full"
autocomplete="full-name"
/>
<InputError v-if="errors.full_name !== undefined" v-for="err in errors.full_name" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="ctaxnumber" value="Davčna" />
<TextInput
id="ctaxnumber"
ref="ctaxnumberInput"
v-model="form.tax_number"
type="text"
class="mt-1 block w-full"
autocomplete="tax-number"
/>
<InputError v-if="errors.tax_number !== undefined" v-for="err in errors.tax_number" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="csocialSecurityNumber" value="Matična / Emšo" />
<TextInput
id="csocialSecurityNumber"
ref="csocialSecurityNumberInput"
v-model="form.social_security_number"
type="text"
class="mt-1 block w-full"
autocomplete="social-security-number"
/>
<InputError v-if="errors.social_security_number !== undefined" v-for="err in errors.social_security_number" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="cdescription" value="Opis"/>
<TextInput
id="cdescription"
ref="cdescriptionInput"
v-model="form.description"
type="text"
class="mt-1 block w-full"
autocomplete="description"
/>
<InputError v-if="errors.description !== undefined" v-for="err in errors.description" :message="err" />
</div>
<div class="flex justify-end mt-4">
<PrimaryButton :class="{ 'opacity-25': processingUpdate }" :disabled="processingUpdate">
Shrani
</PrimaryButton>
</div>
</form>
</template>
</DialogModal>
</template>

View File

@ -1,11 +1,9 @@
<script setup>
import { ref, watch } from "vue";
import DialogModal from "./DialogModal.vue";
import CreateDialog from "./Dialogs/CreateDialog.vue";
import InputLabel from "./InputLabel.vue";
import SectionTitle from "./SectionTitle.vue";
import TextInput from "./TextInput.vue";
import InputError from "./InputError.vue";
import PrimaryButton from "./PrimaryButton.vue";
import { useForm, router } from "@inertiajs/vue3";
const props = defineProps({
@ -15,14 +13,6 @@ const props = defineProps({
},
person: Object,
types: Array,
edit: {
type: Boolean,
default: false,
},
id: {
type: Number,
default: 0,
},
});
// Using Inertia useForm for state, errors and processing
@ -70,156 +60,110 @@ const create = async () => {
});
};
const update = async () => {
router.put(
route("person.phone.update", { person: props.person, phone_id: props.id }),
form,
{
preserveScroll: true,
onSuccess: () => {
close();
form.reset();
},
onError: (e) => {
// errors are available on form.errors
},
}
);
};
function hydrateFromProps() {
if (props.edit && props.id) {
const p = props.person?.phones?.find((x) => x.id === props.id);
if (p) {
form.nu = p.nu || "";
form.country_code = p.country_code ?? 386;
form.type_id = p.type_id ?? (props.types?.[0]?.id ?? null);
form.description = p.description || "";
form.validated = !!p.validated;
form.phone_type = p.phone_type ?? null;
return;
}
}
resetForm();
}
watch(() => props.id, () => hydrateFromProps());
watch(() => props.show, (val) => { if (val) hydrateFromProps(); });
const submit = () => {
if (props.edit) {
update();
} else {
create();
}
create();
};
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>
<span v-if="edit">Spremeni telefon</span>
<span v-else>Dodaj novi telefon</span>
</template>
<template #content>
<form @submit.prevent="submit">
<SectionTitle class="border-b mb-4">
<template #title> Telefon </template>
</SectionTitle>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="pp_nu" value="Številka" />
<TextInput
id="pp_nu"
ref="pp_nuInput"
v-model="form.nu"
type="text"
class="mt-1 block w-full"
autocomplete="nu"
/>
<CreateDialog
:show="show"
title="Dodaj novi telefon"
confirm-text="Shrani"
:processing="form.processing"
@close="close"
@confirm="submit"
>
<form @submit.prevent="submit">
<SectionTitle class="border-b mb-4">
<template #title> Telefon </template>
</SectionTitle>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="pp_nu" value="Številka" />
<TextInput
id="pp_nu"
ref="pp_nuInput"
v-model="form.nu"
type="text"
class="mt-1 block w-full"
autocomplete="nu"
/>
<InputError
v-if="form.errors.nu !== undefined"
v-for="err in form.errors.nu"
:message="err"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="pp_countrycode" value="Koda države tel." />
<select
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
id="pp_countrycode"
v-model="form.country_code"
>
<option value="386">+386 (Slovenija)</option>
<option value="385">+385 (Hrvaška)</option>
<option value="39">+39 (Italija)</option>
<option value="36">+39 (Madžarska)</option>
<option value="43">+43 (Avstrija)</option>
<option value="381">+381 (Srbija)</option>
<option value="387">+387 (Bosna in Hercegovina)</option>
<option value="382">+382 (Črna gora)</option>
<!-- ... -->
</select>
<InputError
v-if="form.errors.nu !== undefined"
v-for="err in form.errors.nu"
:message="err"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="pp_countrycode" value="Koda države tel." />
<select
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
id="pp_countrycode"
v-model="form.country_code"
>
<option value="386">+386 (Slovenija)</option>
<option value="385">+385 (Hrvaška)</option>
<option value="39">+39 (Italija)</option>
<option value="36">+39 (Madžarska)</option>
<option value="43">+43 (Avstrija)</option>
<option value="381">+381 (Srbija)</option>
<option value="387">+387 (Bosna in Hercegovina)</option>
<option value="382">+382 (Črna gora)</option>
<!-- ... -->
</select>
<InputError
v-if="form.errors.country_code !== undefined"
v-for="err in form.errors.country_code"
:message="err"
<InputError
v-if="form.errors.country_code !== undefined"
v-for="err in form.errors.country_code"
:message="err"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="pp_type" value="Tip" />
<select
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
id="pp_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="col-span-6 sm:col-span-4">
<InputLabel for="pp_phone_type" value="Vrsta telefona (enum)" />
<select
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
id="pp_phone_type"
v-model="form.phone_type"
>
<option :value="null"></option>
<option value="mobile">Mobilni</option>
<option value="landline">Stacionarni</option>
<option value="voip">VOIP</option>
</select>
<InputError
v-if="form.errors.phone_type !== undefined"
v-for="err in form.errors.phone_type"
:message="err"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<label class="inline-flex items-center mt-6">
<input
type="checkbox"
v-model="form.validated"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="pp_type" value="Tip" />
<select
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
id="pp_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="col-span-6 sm:col-span-4">
<InputLabel for="pp_phone_type" value="Vrsta telefona (enum)" />
<select
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
id="pp_phone_type"
v-model="form.phone_type"
>
<option :value="null"></option>
<option value="mobile">Mobilni</option>
<option value="landline">Stacionarni</option>
<option value="voip">VOIP</option>
</select>
<InputError
v-if="form.errors.phone_type !== undefined"
v-for="err in form.errors.phone_type"
:message="err"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<label class="inline-flex items-center mt-6">
<input
type="checkbox"
v-model="form.validated"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
<span class="ml-2">Potrjeno</span>
</label>
<InputError
v-if="form.errors.validated !== undefined"
v-for="err in form.errors.validated"
:message="err"
/>
</div>
<div class="flex justify-end mt-4">
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Shrani
</PrimaryButton>
</div>
</form>
</template>
</DialogModal>
<span class="ml-2">Potrjeno</span>
</label>
<InputError
v-if="form.errors.validated !== undefined"
v-for="err in form.errors.validated"
:message="err"
/>
</div>
</form>
</CreateDialog>
</template>

View File

@ -1,17 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {
name: "Test",
created() {},
data() {
return {};
},
props: {},
methods: {},
};
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,47 @@
<script setup>
const props = defineProps({
lines: { type: Number, default: 3 },
showAvatar: { type: Boolean, default: false },
showImage: { type: Boolean, default: false },
});
</script>
<template>
<div class="w-full animate-pulse">
<div class="rounded-lg border border-gray-200 bg-white shadow-sm p-6">
<!-- Header with avatar (optional) -->
<div v-if="showAvatar" class="flex items-center gap-3 mb-4">
<div class="h-10 w-10 rounded-full bg-gray-200"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-3 bg-gray-100 rounded w-1/2"></div>
</div>
</div>
<!-- Image (optional) -->
<div v-if="showImage" class="h-48 bg-gray-200 rounded-lg mb-4"></div>
<!-- Content lines -->
<div class="space-y-3">
<div
v-for="(line, index) in lines"
:key="index"
class="h-4 bg-gray-100 rounded"
:class="{
'w-full': index === lines - 1,
'w-5/6': index !== lines - 1 && index % 2 === 0,
'w-4/6': index !== lines - 1 && index % 2 === 1,
}"
></div>
</div>
<!-- Footer buttons (optional) -->
<div class="flex gap-2 mt-6">
<div class="h-9 bg-gray-100 rounded w-24"></div>
<div class="h-9 bg-gray-100 rounded w-24"></div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,49 @@
<script setup>
const props = defineProps({
width: { type: String, default: 'full' },
height: { type: String, default: '4' },
rounded: { type: String, default: 'md' },
});
const widthClasses = {
full: 'w-full',
'3/4': 'w-3/4',
'2/3': 'w-2/3',
'1/2': 'w-1/2',
'1/3': 'w-1/3',
'1/4': 'w-1/4',
'1/5': 'w-1/5',
auto: 'w-auto',
};
const heightClasses = {
2: 'h-2',
3: 'h-3',
4: 'h-4',
5: 'h-5',
6: 'h-6',
8: 'h-8',
12: 'h-12',
};
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
};
</script>
<template>
<div
class="animate-pulse bg-gray-200"
:class="[
widthClasses[width] || width,
heightClasses[height] || height,
roundedClasses[rounded] || rounded,
]"
></div>
</template>

View File

@ -0,0 +1,32 @@
<script setup>
const props = defineProps({
items: { type: Number, default: 5 },
showAvatar: { type: Boolean, default: true },
});
</script>
<template>
<div class="w-full animate-pulse">
<div class="space-y-3">
<div
v-for="item in items"
:key="item"
class="flex items-center gap-4 p-4 bg-white rounded-lg border border-gray-200"
>
<!-- Avatar -->
<div v-if="showAvatar" class="h-12 w-12 rounded-full bg-gray-200 flex-shrink-0"></div>
<!-- Content -->
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-3 bg-gray-100 rounded w-1/2"></div>
</div>
<!-- Action -->
<div class="h-8 w-8 rounded bg-gray-100 flex-shrink-0"></div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,44 @@
<script setup>
const props = defineProps({
rows: { type: Number, default: 5 },
cols: { type: Number, default: 4 },
});
</script>
<template>
<div class="w-full animate-pulse">
<div class="rounded-lg border border-gray-200 bg-white shadow-sm overflow-hidden">
<!-- Table header skeleton -->
<div class="bg-gray-50 border-b border-gray-200 px-6 py-3">
<div class="flex gap-4">
<div
v-for="i in cols"
:key="i"
class="h-4 bg-gray-200 rounded flex-1"
:class="{ 'max-w-[150px]': i === 1 }"
></div>
</div>
</div>
<!-- Table body skeleton -->
<div class="divide-y divide-gray-200">
<div
v-for="row in rows"
:key="row"
class="px-6 py-4 flex gap-4 items-center"
>
<div
v-for="i in cols"
:key="i"
class="h-4 bg-gray-100 rounded flex-1"
:class="{
'max-w-[120px]': i === 1,
'max-w-[100px]': i === 2,
}"
></div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,70 @@
<script setup>
import { watch, onMounted, onUnmounted } from 'vue';
import { usePage } from '@inertiajs/vue3';
import { Toaster } from '@/Components/ui/sonner';
import { toast } from 'vue-sonner';
const page = usePage();
// Watch for flash messages from Inertia
watch(
() => page.props.flash,
(flash) => {
if (!flash) return;
const flashTypes = ['success', 'error', 'warning', 'info'];
for (const type of flashTypes) {
if (flash[type]) {
switch (type) {
case 'success':
toast.success(flash[type]);
break;
case 'error':
toast.error(flash[type]);
break;
case 'warning':
toast.warning(flash[type]);
break;
case 'info':
toast.info(flash[type]);
break;
}
}
}
},
{ deep: true, immediate: true }
);
// Expose toast methods globally for backward compatibility
onMounted(() => {
window.$toast = {
success: (message, options = {}) => {
toast.success(message, options);
},
error: (message, options = {}) => {
toast.error(message, options);
},
warning: (message, options = {}) => {
toast.warning(message, options);
},
info: (message, options = {}) => {
toast.info(message, options);
},
remove: (id) => {
if (id) {
toast.dismiss(id);
}
},
};
});
onUnmounted(() => {
if (window.$toast) {
delete window.$toast;
}
});
</script>
<template>
<Toaster class="pointer-events-auto" position="bottom-right" />
</template>

View File

@ -1,177 +0,0 @@
<script setup>
import { ref, watch } from 'vue';
import DialogModal from './DialogModal.vue';
import InputLabel from './InputLabel.vue';
import SectionTitle from './SectionTitle.vue';
import TextInput from './TextInput.vue';
import InputError from './InputError.vue';
import PrimaryButton from './PrimaryButton.vue';
import axios from 'axios';
/*
TRR (bank account) create/update
Fields aligned to migration/model: iban, bank_name, bic_swift, account_number, routing_number, currency, country_code, holder_name, notes
Routes: person.trr.create / person.trr.update
*/
const props = defineProps({
show: { type: Boolean, default: false },
person: { type: Object, required: true },
currencies: { type: Array, default: () => ['EUR'] },
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 = {}; }, 300); };
const initialCurrency = () => (props.currencies && props.currencies.length ? props.currencies[0] : 'EUR');
const form = ref({
iban: '',
bank_name: '',
bic_swift: '',
account_number: '',
routing_number: '',
currency: initialCurrency(),
country_code: '',
holder_name: '',
notes: ''
});
const resetForm = () => {
form.value = { iban: '', bank_name: '', bic_swift: '', account_number: '', routing_number: '', currency: initialCurrency(), country_code: '', holder_name: '', notes: '' };
};
const create = async () => {
processing.value = true; errors.value = {};
try {
const { data } = await axios.post(route('person.trr.create', props.person), form.value);
if (!Array.isArray(props.person.trrs)) props.person.trrs = (props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || []);
(props.person.trrs).push(data.trr);
processing.value = false; close(); resetForm();
} catch (e) {
errors.value = e?.response?.data?.errors || {}; processing.value = false;
}
};
const update = async () => {
processing.value = true; errors.value = {};
try {
const { data } = await axios.put(route('person.trr.update', { person: props.person, trr_id: props.id }), form.value);
let list = props.person.trrs || props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || [];
const idx = list.findIndex(a => a.id === data.trr.id);
if (idx !== -1) list[idx] = data.trr;
processing.value = false; close(); resetForm();
} catch (e) {
errors.value = e?.response?.data?.errors || {}; processing.value = false;
}
};
watch(
() => props.id,
(id) => {
if (props.edit && id) {
const list = props.person.trrs || props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || [];
const current = list.find(a => a.id === id);
if (current) {
form.value = {
iban: current.iban || current.account_number || current.number || '',
bank_name: current.bank_name || '',
bic_swift: current.bic_swift || '',
account_number: current.account_number || '',
routing_number: current.routing_number || '',
currency: current.currency || initialCurrency(),
country_code: current.country_code || '',
holder_name: current.holder_name || '',
notes: current.notes || ''
};
return;
}
}
resetForm();
},
{ immediate: true }
);
const submit = () => (props.edit ? update() : create());
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>
<span v-if="edit">Spremeni TRR</span>
<span v-else>Dodaj TRR</span>
</template>
<template #content>
<form @submit.prevent="submit">
<SectionTitle class="border-b mb-4">
<template #title>TRR</template>
</SectionTitle>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="trr_iban" value="IBAN" />
<TextInput id="trr_iban" v-model="form.iban" type="text" class="mt-1 block w-full" autocomplete="off" />
<InputError v-if="errors.iban" v-for="err in errors.iban" :key="err" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="trr_bank_name" value="Banka" />
<TextInput id="trr_bank_name" v-model="form.bank_name" type="text" class="mt-1 block w-full" autocomplete="organization" />
<InputError v-if="errors.bank_name" v-for="err in errors.bank_name" :key="err" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="trr_bic" value="BIC / SWIFT" />
<TextInput id="trr_bic" v-model="form.bic_swift" type="text" class="mt-1 block w-full" autocomplete="off" />
<InputError v-if="errors.bic_swift" v-for="err in errors.bic_swift" :key="err" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="trr_accnum" value="Številka računa" />
<TextInput id="trr_accnum" v-model="form.account_number" type="text" class="mt-1 block w-full" autocomplete="off" />
<InputError v-if="errors.account_number" v-for="err in errors.account_number" :key="err" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="trr_route" value="Usmerjevalna številka (routing)" />
<TextInput id="trr_route" v-model="form.routing_number" type="text" class="mt-1 block w-full" autocomplete="off" />
<InputError v-if="errors.routing_number" v-for="err in errors.routing_number" :key="err" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4" v-if="currencies && currencies.length">
<InputLabel for="trr_currency" value="Valuta" />
<select id="trr_currency" v-model="form.currency" class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option v-for="c in currencies" :key="c">{{ c }}</option>
</select>
<InputError v-if="errors.currency" v-for="err in errors.currency" :key="err" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="trr_cc" value="Koda države (2-znaki, npr. SI)" />
<TextInput id="trr_cc" v-model="form.country_code" type="text" class="mt-1 block w-full" autocomplete="country" />
<InputError v-if="errors.country_code" v-for="err in errors.country_code" :key="err" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="trr_holder" value="Imetnik računa" />
<TextInput id="trr_holder" v-model="form.holder_name" type="text" class="mt-1 block w-full" autocomplete="name" />
<InputError v-if="errors.holder_name" v-for="err in errors.holder_name" :key="err" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="trr_notes" value="Opombe" />
<TextInput id="trr_notes" v-model="form.notes" type="text" class="mt-1 block w-full" autocomplete="off" />
<InputError v-if="errors.notes" v-for="err in errors.notes" :key="err" :message="err" />
</div>
<div class="flex justify-end mt-4">
<PrimaryButton :class="{ 'opacity-25': processing }" :disabled="processing">Shrani</PrimaryButton>
</div>
</form>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,15 @@
<script setup>
import { cn } from "@/lib/utils";
import { badgeVariants } from ".";
const props = defineProps({
variant: { type: null, required: false },
class: { type: null, required: false },
});
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,23 @@
import { cva } from "class-variance-authority";
export { default as Badge } from "./Badge.vue";
export const badgeVariants = cva(
"inline-flex gap-1 items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);

View File

@ -0,0 +1,22 @@
<script setup>
import { cn } from "@/lib/utils";
import { buttonGroupVariants } from ".";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
});
</script>
<template>
<div
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:class="
cn(buttonGroupVariants({ orientation: props.orientation }), props.class)
"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,28 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { cn } from "@/lib/utils";
import { Separator } from '@/components/ui/separator';
const props = defineProps({
orientation: { type: String, required: false, default: "vertical" },
decorative: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Separator
data-slot="button-group-separator"
v-bind="delegatedProps"
:orientation="props.orientation"
:class="
cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
props.class,
)
"
/>
</template>

View File

@ -0,0 +1,29 @@
<script setup>
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
orientation: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: "div" },
});
</script>
<template>
<Primitive
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:as="as"
:as-child="asChild"
:class="
cn(
'bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
</Primitive>
</template>

View File

@ -0,0 +1,22 @@
import { cva } from "class-variance-authority";
export { default as ButtonGroup } from "./ButtonGroup.vue";
export { default as ButtonGroupSeparator } from "./ButtonGroupSeparator.vue";
export { default as ButtonGroupText } from "./ButtonGroupText.vue";
export const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*:focus-visible]:z-10 [&>*:focus-visible]:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
);

View File

@ -0,0 +1,24 @@
<script setup>
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from ".";
const props = defineProps({
variant: { type: null, required: false },
size: { type: null, required: false },
class: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: "button" },
});
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View File

@ -0,0 +1,36 @@
import { cva } from "class-variance-authority";
export { default as Button } from "./Button.vue";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);

View File

@ -0,0 +1,95 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CalendarRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarHeader,
CalendarHeading,
CalendarNextButton,
CalendarPrevButton,
} from ".";
const props = defineProps({
defaultValue: { type: null, required: false },
defaultPlaceholder: { type: null, required: false },
placeholder: { type: null, required: false },
pagedNavigation: { type: Boolean, required: false },
preventDeselect: { type: Boolean, required: false },
weekStartsOn: { type: Number, required: false },
weekdayFormat: { type: String, required: false },
calendarLabel: { type: String, required: false },
fixedWeeks: { type: Boolean, required: false },
maxValue: { type: null, required: false },
minValue: { type: null, required: false },
locale: { type: String, required: false },
numberOfMonths: { type: Number, required: false },
disabled: { type: Boolean, required: false },
readonly: { type: Boolean, required: false },
initialFocus: { type: Boolean, required: false },
isDateDisabled: { type: Function, required: false },
isDateUnavailable: { type: Function, required: false },
dir: { type: String, required: false },
nextPage: { type: Function, required: false },
prevPage: { type: Function, required: false },
modelValue: { type: null, required: false },
multiple: { type: Boolean, required: false },
disableDaysOutsideCurrentView: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue", "update:placeholder"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<CalendarRoot
v-slot="{ grid, weekDays }"
:class="cn('p-3', props.class)"
v-bind="forwarded"
>
<CalendarHeader>
<CalendarPrevButton />
<CalendarHeading />
<CalendarNextButton />
</CalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell v-for="day in weekDays" :key="day">
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow
v-for="(weekDates, index) in month.rows"
:key="`weekDate-${index}`"
class="mt-2 w-full"
>
<CalendarCell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
>
<CalendarCellTrigger :day="weekDate" :month="month.value" />
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</div>
</CalendarRoot>
</template>

View File

@ -0,0 +1,30 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CalendarCell, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
date: { type: null, required: true },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<CalendarCell
:class="
cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50',
props.class,
)
"
v-bind="forwardedProps"
>
<slot />
</CalendarCell>
</template>

View File

@ -0,0 +1,42 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CalendarCellTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/Components/ui/button';
const props = defineProps({
day: { type: null, required: true },
month: { type: null, required: true },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<CalendarCellTrigger
:class="
cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
props.class,
)
"
v-bind="forwardedProps"
>
<slot />
</CalendarCellTrigger>
</template>

View File

@ -0,0 +1,24 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { CalendarGrid, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<CalendarGrid
:class="cn('w-full border-collapse space-y-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGrid>
</template>

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