Dev branch
This commit is contained in:
parent
5f879c9436
commit
63e0958b66
57
app/Console/Commands/RefreshMaterializedViews.php
Normal file
57
app/Console/Commands/RefreshMaterializedViews.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
379
app/Http/Controllers/ReportController.php
Normal file
379
app/Http/Controllers/ReportController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
36
app/Providers/ReportServiceProvider.php
Normal file
36
app/Providers/ReportServiceProvider.php
Normal 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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
53
app/Reports/ActionsDecisionsCountReport.php
Normal file
53
app/Reports/ActionsDecisionsCountReport.php
Normal 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");
|
||||
}
|
||||
}
|
||||
78
app/Reports/ActiveContractsReport.php
Normal file
78
app/Reports/ActiveContractsReport.php
Normal 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');
|
||||
}
|
||||
}
|
||||
95
app/Reports/ActivitiesPerPeriodReport.php
Normal file
95
app/Reports/ActivitiesPerPeriodReport.php
Normal 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');
|
||||
}
|
||||
}
|
||||
33
app/Reports/BaseEloquentReport.php
Normal file
33
app/Reports/BaseEloquentReport.php
Normal 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);
|
||||
}
|
||||
}
|
||||
54
app/Reports/Contracts/Report.php
Normal file
54
app/Reports/Contracts/Report.php
Normal 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;
|
||||
}
|
||||
51
app/Reports/DecisionsCountReport.php
Normal file
51
app/Reports/DecisionsCountReport.php
Normal 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");
|
||||
}
|
||||
}
|
||||
60
app/Reports/FieldJobsCompletedReport.php
Normal file
60
app/Reports/FieldJobsCompletedReport.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
29
app/Reports/ReportRegistry.php
Normal file
29
app/Reports/ReportRegistry.php
Normal 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;
|
||||
}
|
||||
}
|
||||
54
app/Reports/SegmentActivityCountsReport.php
Normal file
54
app/Reports/SegmentActivityCountsReport.php
Normal 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;
|
||||
}
|
||||
}
|
||||
221
app/Services/Documents/DocumentStreamService.php
Normal file
221
app/Services/Documents/DocumentStreamService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
68
app/Services/ReferenceDataCache.php
Normal file
68
app/Services/ReferenceDataCache.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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
21
components.json
Normal 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": {}
|
||||
}
|
||||
|
|
@ -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
958
composer.lock
generated
File diff suppressed because it is too large
Load Diff
10
config/reports.php
Normal file
10
config/reports.php
Normal 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',
|
||||
];
|
||||
|
|
@ -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
2292
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
|
|
@ -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",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"axios": "^1.7.4",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import tailwindcss from '@tailwindcss/postcss';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
autoprefixer(),
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<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 }}</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">
|
||||
>{{ 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>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ defineProps({
|
|||
<svg v-if="index !== 0" class="rtl:rotate-180 w-3 h-3 text-gray-400 mx-1" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
|
||||
</svg>
|
||||
<a v-if="page.current === undefined" :href="page.url" class="ms-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ms-2 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
54
resources/js/Components/DataTable/ActionMenuItem.vue
Normal file
54
resources/js/Components/DataTable/ActionMenuItem.vue
Normal 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>
|
||||
|
||||
|
||||
134
resources/js/Components/DataTable/ColumnFilter.vue
Normal file
134
resources/js/Components/DataTable/ColumnFilter.vue
Normal 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>
|
||||
|
||||
884
resources/js/Components/DataTable/DataTable.vue
Normal file
884
resources/js/Components/DataTable/DataTable.vue
Normal file
|
|
@ -0,0 +1,884 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import DataTableToolbar from './DataTableToolbar.vue';
|
||||
import SkeletonTable from '../Skeleton/SkeletonTable.vue';
|
||||
import EmptyState from '../EmptyState.vue';
|
||||
import Pagination from '../Pagination.vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import {
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faSort,
|
||||
faSortUp,
|
||||
faSortDown,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const props = defineProps({
|
||||
// Data
|
||||
rows: { type: Array, default: () => [] },
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: (cols) =>
|
||||
cols.every(
|
||||
(col) => col.key && col.label && typeof col.key === 'string' && typeof col.label === 'string'
|
||||
),
|
||||
},
|
||||
|
||||
// Pagination (for server-side)
|
||||
meta: {
|
||||
type: Object,
|
||||
default: null,
|
||||
validator: (meta) =>
|
||||
!meta ||
|
||||
(typeof meta.current_page === 'number' &&
|
||||
typeof meta.per_page === 'number' &&
|
||||
typeof meta.total === 'number' &&
|
||||
typeof meta.last_page === 'number'),
|
||||
},
|
||||
|
||||
// Sorting
|
||||
sort: {
|
||||
type: Object,
|
||||
default: () => ({ key: null, direction: null }),
|
||||
},
|
||||
|
||||
// Search
|
||||
search: { type: String, default: '' },
|
||||
|
||||
// Loading state
|
||||
loading: { type: Boolean, default: false },
|
||||
|
||||
// Client-side pagination (when meta is null)
|
||||
pageSize: { type: Number, default: 10 },
|
||||
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
|
||||
|
||||
// Routing (for server-side)
|
||||
routeName: { type: String, default: null },
|
||||
routeParams: { type: Object, default: () => ({}) },
|
||||
pageParamName: { type: String, default: 'page' },
|
||||
onlyProps: { type: Array, default: () => [] },
|
||||
|
||||
// Features
|
||||
showToolbar: { type: Boolean, default: true },
|
||||
showSearch: { type: Boolean, default: false },
|
||||
showPageSize: { type: Boolean, default: false },
|
||||
showPagination: { type: Boolean, default: true },
|
||||
showFilters: { type: Boolean, default: false },
|
||||
showExport: { type: Boolean, default: false },
|
||||
showAdd: { type: Boolean, default: false }, // Show add buttons dropdown
|
||||
showOptions: { type: Boolean, default: false }, // Show custom options slot
|
||||
showSelectedCount: { type: Boolean, default: false }, // Show selected count badge
|
||||
showOptionsMenu: { type: Boolean, default: false }, // Show options menu (three dots)
|
||||
compactToolbar: { type: Boolean, default: false }, // Compact mode: move search/page size to menu
|
||||
hasActiveFilters: { type: Boolean, default: false }, // External indicator for active filters
|
||||
rowKey: { type: [String, Function], default: 'uuid' },
|
||||
selectable: { type: Boolean, default: false },
|
||||
striped: { type: Boolean, default: false },
|
||||
hoverable: { type: Boolean, default: true },
|
||||
|
||||
// Empty state
|
||||
emptyText: { type: String, default: 'Ni podatkov' },
|
||||
emptyIcon: { type: [String, Object, Array], default: null },
|
||||
emptyDescription: { type: String, default: null },
|
||||
|
||||
// Actions
|
||||
showActions: { type: Boolean, default: false },
|
||||
actionsPosition: { type: String, default: 'right', validator: (v) => ['left', 'right'].includes(v) },
|
||||
|
||||
// Mobile
|
||||
mobileCardView: { type: Boolean, default: true },
|
||||
mobileBreakpoint: { type: Number, default: 768 }, // Tailwind md breakpoint
|
||||
|
||||
// State preservation
|
||||
preserveState: { type: Boolean, default: true },
|
||||
preserveScroll: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:search',
|
||||
'update:sort',
|
||||
'update:page',
|
||||
'update:pageSize',
|
||||
'row:click',
|
||||
'row:select',
|
||||
'selection:change',
|
||||
]);
|
||||
|
||||
// Determine if this is server-side (has meta and routeName)
|
||||
const isServerSide = computed(() => !!(props.meta && props.routeName));
|
||||
const isClientSide = computed(() => !isServerSide.value);
|
||||
|
||||
// Row key helper
|
||||
function keyOf(row) {
|
||||
if (typeof props.rowKey === 'function') return props.rowKey(row);
|
||||
if (typeof props.rowKey === 'string' && row && row[props.rowKey] != null)
|
||||
return row[props.rowKey];
|
||||
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
// Client-side sorting
|
||||
const sortedRows = computed(() => {
|
||||
if (isServerSide.value || !props.sort?.key || !props.sort?.direction) {
|
||||
return props.rows;
|
||||
}
|
||||
|
||||
const key = props.sort.key;
|
||||
const direction = props.sort.direction;
|
||||
const sorted = [...props.rows];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
let aVal = a?.[key];
|
||||
let bVal = b?.[key];
|
||||
|
||||
// Handle nulls/undefined
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
// Handle dates
|
||||
if (aVal instanceof Date || (typeof aVal === 'string' && aVal.match(/\d{4}-\d{2}-\d{2}/))) {
|
||||
aVal = new Date(aVal);
|
||||
bVal = new Date(bVal);
|
||||
}
|
||||
|
||||
// Handle numbers
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return direction === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
// Handle strings
|
||||
const aStr = String(aVal).toLowerCase();
|
||||
const bStr = String(bVal).toLowerCase();
|
||||
if (direction === 'asc') {
|
||||
return aStr.localeCompare(bStr);
|
||||
}
|
||||
return bStr.localeCompare(aStr);
|
||||
});
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
// Client-side pagination
|
||||
const currentPage = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.current_page ?? 1;
|
||||
return internalPage.value;
|
||||
});
|
||||
|
||||
const internalPage = ref(1);
|
||||
// Use computed for pageSize to always reflect the correct value
|
||||
// For server-side: use meta.per_page, for client-side: use internal state or props.pageSize
|
||||
const internalPageSize = computed({
|
||||
get: () => {
|
||||
if (isServerSide.value && props.meta?.per_page) {
|
||||
return props.meta.per_page;
|
||||
}
|
||||
return internalPageSizeState.value ?? props.pageSize;
|
||||
},
|
||||
set: (value) => {
|
||||
internalPageSizeState.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Internal state for client-side or when user changes page size before server responds
|
||||
const internalPageSizeState = ref(null);
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.last_page ?? 1;
|
||||
return Math.ceil(sortedRows.value.length / internalPageSize.value);
|
||||
});
|
||||
|
||||
const paginatedRows = computed(() => {
|
||||
if (isServerSide.value) return props.rows;
|
||||
|
||||
const start = (currentPage.value - 1) * internalPageSize.value;
|
||||
const end = start + internalPageSize.value;
|
||||
return sortedRows.value.slice(start, end);
|
||||
});
|
||||
|
||||
// Client-side search
|
||||
const filteredRows = computed(() => {
|
||||
if (isServerSide.value || !internalSearch.value) {
|
||||
return paginatedRows.value;
|
||||
}
|
||||
|
||||
const searchTerm = internalSearch.value.toLowerCase();
|
||||
return paginatedRows.value.filter((row) => {
|
||||
return props.columns.some((col) => {
|
||||
const value = row?.[col.key];
|
||||
if (value == null) return false;
|
||||
return String(value).toLowerCase().includes(searchTerm);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Search handling
|
||||
const internalSearch = ref(props.search);
|
||||
watch(
|
||||
() => props.search,
|
||||
(newVal) => {
|
||||
internalSearch.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
// Selection
|
||||
const selectedRows = ref(new Set());
|
||||
const isAllSelected = computed(() => {
|
||||
if (filteredRows.value.length === 0) return false;
|
||||
return filteredRows.value.every((row) => selectedRows.value.has(keyOf(row)));
|
||||
});
|
||||
const isSomeSelected = computed(() => {
|
||||
return (
|
||||
selectedRows.value.size > 0 &&
|
||||
filteredRows.value.some((row) => selectedRows.value.has(keyOf(row)))
|
||||
);
|
||||
});
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
filteredRows.value.forEach((row) => {
|
||||
selectedRows.value.delete(keyOf(row));
|
||||
});
|
||||
} else {
|
||||
filteredRows.value.forEach((row) => {
|
||||
selectedRows.value.add(keyOf(row));
|
||||
});
|
||||
}
|
||||
emit('selection:change', Array.from(selectedRows.value));
|
||||
}
|
||||
|
||||
function toggleSelectRow(row) {
|
||||
const key = keyOf(row);
|
||||
if (selectedRows.value.has(key)) {
|
||||
selectedRows.value.delete(key);
|
||||
} else {
|
||||
selectedRows.value.add(key);
|
||||
}
|
||||
emit('row:select', row, selectedRows.value.has(key));
|
||||
emit('selection:change', Array.from(selectedRows.value));
|
||||
}
|
||||
|
||||
// Sorting
|
||||
function toggleSort(col) {
|
||||
if (!col.sortable) return;
|
||||
|
||||
if (isServerSide.value) {
|
||||
const current = props.sort || { key: null, direction: null };
|
||||
let direction = 'asc';
|
||||
if (current.key === col.key) {
|
||||
direction =
|
||||
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
|
||||
}
|
||||
emit('update:sort', { key: direction ? col.key : null, direction });
|
||||
doServerRequest({ sort: direction ? col.key : null, direction, page: 1 });
|
||||
} else {
|
||||
const current = props.sort || { key: null, direction: null };
|
||||
let direction = 'asc';
|
||||
if (current.key === col.key) {
|
||||
direction =
|
||||
current.direction === 'asc' ? 'desc' : current.direction === 'desc' ? null : 'asc';
|
||||
}
|
||||
emit('update:sort', { key: direction ? col.key : null, direction });
|
||||
}
|
||||
}
|
||||
|
||||
function getSortIcon(col) {
|
||||
if (props.sort?.key !== col.key) return faSort;
|
||||
if (props.sort?.direction === 'asc') return faSortUp;
|
||||
if (props.sort?.direction === 'desc') return faSortDown;
|
||||
return faSort;
|
||||
}
|
||||
|
||||
// Server-side request
|
||||
function doServerRequest(overrides = {}) {
|
||||
// Preserve existing query parameters from URL
|
||||
const existingParams = Object.fromEntries(
|
||||
new URLSearchParams(window.location.search).entries()
|
||||
);
|
||||
|
||||
const q = {
|
||||
...existingParams, // Preserve all existing query parameters
|
||||
per_page: overrides.perPage ?? props.meta?.per_page ?? internalPageSizeState.value ?? internalPageSize.value,
|
||||
sort: overrides.sort ?? props.sort?.key ?? existingParams.sort ?? null,
|
||||
direction: overrides.direction ?? props.sort?.direction ?? existingParams.direction ?? null,
|
||||
search: overrides.search ?? internalSearch.value ?? existingParams.search ?? '',
|
||||
};
|
||||
|
||||
const pageParam = props.pageParamName || 'page';
|
||||
q[pageParam] = overrides.page ?? props.meta?.current_page ?? 1;
|
||||
if (pageParam !== 'page') {
|
||||
delete q.page;
|
||||
}
|
||||
|
||||
// Clean nulls and empty strings
|
||||
Object.keys(q).forEach((k) => {
|
||||
if (q[k] === null || q[k] === undefined || q[k] === '') delete q[k];
|
||||
});
|
||||
|
||||
const url = route(props.routeName, props.routeParams || {});
|
||||
router.get(
|
||||
url,
|
||||
q,
|
||||
{
|
||||
preserveScroll: props.preserveScroll,
|
||||
preserveState: props.preserveState,
|
||||
replace: true,
|
||||
only: props.onlyProps.length ? props.onlyProps : undefined,
|
||||
onSuccess: () => {
|
||||
// Scroll to top of table after server request completes
|
||||
setTimeout(() => {
|
||||
const tableElement = document.querySelector('[data-table-container]');
|
||||
if (tableElement) {
|
||||
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleSearchChange(value) {
|
||||
internalSearch.value = value;
|
||||
emit('update:search', value);
|
||||
|
||||
if (isServerSide.value) {
|
||||
clearTimeout(searchTimer.value);
|
||||
searchTimer.value = setTimeout(() => {
|
||||
doServerRequest({ search: value, page: 1 });
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageSizeChange(size) {
|
||||
const newSize = Number(size);
|
||||
internalPageSizeState.value = newSize;
|
||||
emit('update:pageSize', newSize);
|
||||
|
||||
if (isServerSide.value) {
|
||||
// Reset to page 1 when changing page size to avoid being on a non-existent page
|
||||
doServerRequest({ perPage: newSize, page: 1 });
|
||||
} else {
|
||||
// Calculate total pages with new size
|
||||
const newTotalPages = Math.ceil(sortedRows.value.length / newSize);
|
||||
// If current page exceeds new total, reset to last page or page 1
|
||||
const targetPage = currentPage.value > newTotalPages && newTotalPages > 0 ? newTotalPages : 1;
|
||||
internalPage.value = targetPage;
|
||||
emit('update:page', targetPage);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page) {
|
||||
if (isServerSide.value) {
|
||||
doServerRequest({ page });
|
||||
} else {
|
||||
internalPage.value = page;
|
||||
emit('update:page', page);
|
||||
}
|
||||
|
||||
// Scroll to top of table after page change
|
||||
setTimeout(() => {
|
||||
const tableElement = document.querySelector('[data-table-container]');
|
||||
if (tableElement) {
|
||||
tableElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, isServerSide.value ? 100 : 50);
|
||||
}
|
||||
|
||||
const searchTimer = ref(null);
|
||||
|
||||
// Mobile detection
|
||||
const isMobile = ref(false);
|
||||
if (typeof window !== 'undefined') {
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth < props.mobileBreakpoint;
|
||||
};
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
}
|
||||
|
||||
// Display rows
|
||||
const displayRows = computed(() => {
|
||||
if (isServerSide.value || !internalSearch.value) {
|
||||
return paginatedRows.value;
|
||||
}
|
||||
return filteredRows.value;
|
||||
});
|
||||
|
||||
const total = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.total ?? 0;
|
||||
return internalSearch.value ? filteredRows.value.length : sortedRows.value.length;
|
||||
});
|
||||
|
||||
const from = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.from ?? 0;
|
||||
return total.value === 0 ? 0 : (currentPage.value - 1) * internalPageSize.value + 1;
|
||||
});
|
||||
|
||||
const to = computed(() => {
|
||||
if (isServerSide.value) return props.meta?.to ?? 0;
|
||||
return Math.min(currentPage.value * internalPageSize.value, total.value);
|
||||
});
|
||||
|
||||
// Export functionality
|
||||
function handleExport(format) {
|
||||
const data = displayRows.value.map((row) => {
|
||||
const exported = {};
|
||||
props.columns.forEach((col) => {
|
||||
exported[col.label] = row?.[col.key] ?? '';
|
||||
});
|
||||
return exported;
|
||||
});
|
||||
|
||||
if (format === 'csv') {
|
||||
exportToCSV(data);
|
||||
} else if (format === 'xlsx') {
|
||||
exportToXLSX(data);
|
||||
}
|
||||
}
|
||||
|
||||
function exportToCSV(data) {
|
||||
if (data.length === 0) return;
|
||||
|
||||
const headers = Object.keys(data[0]);
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...data.map((row) =>
|
||||
headers
|
||||
.map((header) => {
|
||||
const value = row[header];
|
||||
if (value == null) return '';
|
||||
const stringValue = String(value).replace(/"/g, '""');
|
||||
return `"${stringValue}"`;
|
||||
})
|
||||
.join(',')
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function exportToXLSX(data) {
|
||||
// For XLSX, we'll use a CSV-like format or alert user to install xlsx library
|
||||
// Simple implementation: use CSV format with .xlsx extension
|
||||
exportToCSV(data);
|
||||
// In production, you might want to use a library like 'xlsx' or 'exceljs'
|
||||
}
|
||||
|
||||
// Generate visible page numbers with ellipsis
|
||||
function getVisiblePages() {
|
||||
const pages = [];
|
||||
const total = totalPages.value;
|
||||
const current = currentPage.value;
|
||||
const maxVisible = 7;
|
||||
|
||||
if (total <= maxVisible) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
// Calculate start and end
|
||||
let start = Math.max(2, current - 1);
|
||||
let end = Math.min(total - 1, current + 1);
|
||||
|
||||
// Adjust if near start
|
||||
if (current <= 3) {
|
||||
end = Math.min(4, total - 1);
|
||||
}
|
||||
|
||||
// Adjust if near end
|
||||
if (current >= total - 2) {
|
||||
start = Math.max(2, total - 3);
|
||||
}
|
||||
|
||||
// Add ellipsis after first page if needed
|
||||
if (start > 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Add middle pages
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
// Add ellipsis before last page if needed
|
||||
if (end < total - 1) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
if (total > 1) {
|
||||
pages.push(total);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-4">
|
||||
<!-- Toolbar -->
|
||||
<DataTableToolbar
|
||||
v-if="showToolbar"
|
||||
:search="internalSearch"
|
||||
:show-search="showSearch"
|
||||
:show-page-size="showPageSize"
|
||||
:page-size="internalPageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:selected-count="selectedRows.size"
|
||||
:show-selected-count="showSelectedCount"
|
||||
:show-export="showExport"
|
||||
:show-add="showAdd"
|
||||
:show-options="showOptions"
|
||||
:show-filters="showFilters"
|
||||
:show-options-menu="showOptionsMenu"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
:compact="compactToolbar"
|
||||
@update:search="handleSearchChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
@export="handleExport"
|
||||
>
|
||||
<template #add>
|
||||
<slot name="toolbar-add" />
|
||||
</template>
|
||||
<template #options>
|
||||
<slot name="toolbar-options" />
|
||||
</template>
|
||||
<template #filters>
|
||||
<slot name="toolbar-filters" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="toolbar-actions" />
|
||||
</template>
|
||||
</DataTableToolbar>
|
||||
|
||||
<!-- Table Container -->
|
||||
<div
|
||||
data-table-container
|
||||
class="relative overflow-hidden"
|
||||
>
|
||||
<!-- Desktop Table View -->
|
||||
<div v-if="!isMobile || !mobileCardView" class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<!-- Header -->
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- Select All Checkbox -->
|
||||
<th
|
||||
v-if="selectable"
|
||||
class="w-12 px-6 py-3 text-left"
|
||||
scope="col"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isAllSelected"
|
||||
:indeterminate="isSomeSelected && !isAllSelected"
|
||||
@change="toggleSelectAll"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</th>
|
||||
|
||||
<!-- Column Headers -->
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
:class="[
|
||||
col.class,
|
||||
col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : 'text-left',
|
||||
]"
|
||||
>
|
||||
<button
|
||||
v-if="col.sortable"
|
||||
type="button"
|
||||
class="group inline-flex items-center gap-1.5 hover:text-gray-700 transition-colors"
|
||||
@click="toggleSort(col)"
|
||||
:aria-sort="sort?.key === col.key ? sort.direction || 'none' : 'none'"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<FontAwesomeIcon
|
||||
:icon="getSortIcon(col)"
|
||||
class="w-3 h-3 transition-colors"
|
||||
:class="{
|
||||
'text-gray-700': sort?.key === col.key,
|
||||
'text-gray-400 group-hover:text-gray-500': sort?.key !== col.key,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<span v-else>{{ col.label }}</span>
|
||||
</th>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<th
|
||||
v-if="showActions || $slots.actions"
|
||||
scope="col"
|
||||
class="relative w-px px-6 py-3"
|
||||
>
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<!-- Body -->
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<tr>
|
||||
<td :colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)" class="px-6 py-4">
|
||||
<SkeletonTable :rows="5" :cols="columns.length" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template v-else-if="!loading && displayRows.length === 0">
|
||||
<tr>
|
||||
<td
|
||||
:colspan="columns.length + (selectable ? 1 : 0) + (showActions ? 1 : 0)"
|
||||
class="px-6 py-12 text-center"
|
||||
>
|
||||
<EmptyState
|
||||
:icon="emptyIcon"
|
||||
:title="emptyText"
|
||||
:description="emptyDescription"
|
||||
size="sm"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Rows -->
|
||||
<template v-else>
|
||||
<tr
|
||||
v-for="(row, idx) in displayRows"
|
||||
:key="keyOf(row)"
|
||||
@click="(e) => { const interactive = e.target.closest('button, a, [role=button], [data-dropdown], .relative'); if (interactive) return; $emit('row:click', row, idx); }"
|
||||
class="transition-colors"
|
||||
:class="{
|
||||
'cursor-pointer': !!$attrs.onRowClick,
|
||||
'bg-gray-50': striped && idx % 2 === 1,
|
||||
'hover:bg-gray-50': hoverable && !selectedRows.has(keyOf(row)),
|
||||
'bg-primary-50': selectedRows.has(keyOf(row)),
|
||||
}"
|
||||
>
|
||||
<!-- Select Checkbox -->
|
||||
<td v-if="selectable" class="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedRows.has(keyOf(row))"
|
||||
@click.stop="toggleSelectRow(row)"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<!-- Data Cells -->
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
class="px-6 py-4 whitespace-nowrap text-sm"
|
||||
:class="[
|
||||
col.class,
|
||||
col.align === 'right'
|
||||
? 'text-right text-gray-900'
|
||||
: col.align === 'center'
|
||||
? 'text-center text-gray-900'
|
||||
: 'text-left text-gray-900',
|
||||
]"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${col.key}`"
|
||||
:row="row"
|
||||
:column="col"
|
||||
:value="row?.[col.key]"
|
||||
:index="idx"
|
||||
>
|
||||
<slot name="cell" :row="row" :column="col" :value="row?.[col.key]" :index="idx">
|
||||
<span>{{ row?.[col.key] ?? '—' }}</span>
|
||||
</slot>
|
||||
</slot>
|
||||
</td>
|
||||
|
||||
<!-- Actions Cell -->
|
||||
<td
|
||||
v-if="showActions || $slots.actions"
|
||||
class="relative whitespace-nowrap py-4 pl-3 pr-6 text-right text-sm font-medium"
|
||||
>
|
||||
<slot name="actions" :row="row" :index="idx">
|
||||
<slot name="row-actions" :row="row" :index="idx" />
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Card View -->
|
||||
<div v-else-if="isMobile && mobileCardView" class="divide-y divide-gray-200">
|
||||
<template v-if="loading">
|
||||
<div class="p-4">
|
||||
<SkeletonTable :rows="3" :cols="1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!loading && displayRows.length === 0">
|
||||
<div class="p-6">
|
||||
<EmptyState
|
||||
:icon="emptyIcon"
|
||||
:title="emptyText"
|
||||
:description="emptyDescription"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(row, idx) in displayRows"
|
||||
:key="keyOf(row)"
|
||||
@click="$emit('row:click', row, idx)"
|
||||
class="p-4 space-y-2 hover:bg-gray-50 transition-colors"
|
||||
:class="{ 'cursor-pointer': !!$attrs.onRowClick }"
|
||||
>
|
||||
<slot name="mobile-card" :row="row" :index="idx">
|
||||
<!-- Default mobile card layout -->
|
||||
<div
|
||||
v-for="col in columns.slice(0, 3)"
|
||||
:key="col.key"
|
||||
class="flex justify-between items-start"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
{{ col.label }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-900 text-right">
|
||||
<slot
|
||||
:name="`cell-${col.key}`"
|
||||
:row="row"
|
||||
:column="col"
|
||||
:value="row?.[col.key]"
|
||||
:index="idx"
|
||||
>
|
||||
{{ row?.[col.key] ?? '—' }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showActions || $slots.actions" class="pt-2 border-t border-gray-100">
|
||||
<slot name="actions" :row="row" :index="idx">
|
||||
<slot name="row-actions" :row="row" :index="idx" />
|
||||
</slot>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="showPagination && totalPages > 1">
|
||||
<!-- Use existing Pagination component for server-side -->
|
||||
<template v-if="isServerSide && meta?.links">
|
||||
<Pagination
|
||||
:links="meta.links"
|
||||
:from="from"
|
||||
:to="to"
|
||||
:total="total"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Custom pagination for client-side -->
|
||||
<template v-else>
|
||||
<div class="flex flex-1 justify-between sm:hidden">
|
||||
<button
|
||||
@click="handlePageChange(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Prejšnja
|
||||
</button>
|
||||
<button
|
||||
@click="handlePageChange(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Naslednja
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Prikazano
|
||||
<span class="font-medium">{{ from }}</span>
|
||||
do
|
||||
<span class="font-medium">{{ to }}</span>
|
||||
od
|
||||
<span class="font-medium">{{ total }}</span>
|
||||
rezultatov
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||
<button
|
||||
@click="handlePageChange(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="sr-only">Prejšnja</span>
|
||||
<FontAwesomeIcon :icon="faChevronLeft" class="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<template v-for="page in getVisiblePages()" :key="page">
|
||||
<button
|
||||
v-if="page !== '...'"
|
||||
@click="handlePageChange(page)"
|
||||
:aria-current="page === currentPage ? 'page' : undefined"
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20"
|
||||
:class="
|
||||
page === currentPage
|
||||
? 'z-10 bg-primary-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600'
|
||||
: 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0'
|
||||
"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<button
|
||||
@click="handlePageChange(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="sr-only">Naslednja</span>
|
||||
<FontAwesomeIcon :icon="faChevronRight" class="h-5 w-5" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -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,11 +199,12 @@ 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">
|
||||
<TableRow class="border-b">
|
||||
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
|
||||
<button
|
||||
v-if="col.sortable"
|
||||
type="button"
|
||||
|
|
@ -216,23 +219,23 @@ function setPageSize(ps) {
|
|||
>
|
||||
</button>
|
||||
<span v-else>{{ col.label }}</span>
|
||||
</FwbTableHeadCell>
|
||||
<FwbTableHeadCell v-if="$slots.actions" class="w-px"> </FwbTableHeadCell>
|
||||
</FwbTableHead>
|
||||
</TableHead>
|
||||
<TableHead v-if="$slots.actions" class="w-px"> </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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,11 +200,12 @@ 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">
|
||||
<TableRow class="border-b">
|
||||
<TableHead v-for="col in columns" :key="col.key" :class="col.class">
|
||||
<button
|
||||
v-if="col.sortable"
|
||||
type="button"
|
||||
|
|
@ -216,23 +220,23 @@ function goToPageInput() {
|
|||
>
|
||||
</button>
|
||||
<span v-else>{{ col.label }}</span>
|
||||
</FwbTableHeadCell>
|
||||
<FwbTableHeadCell v-if="$slots.actions" class="w-px"> </FwbTableHeadCell>
|
||||
</FwbTableHead>
|
||||
</TableHead>
|
||||
<TableHead v-if="$slots.actions" class="w-px"> </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
|
||||
|
|
|
|||
318
resources/js/Components/DataTable/DataTableToolbar.vue
Normal file
318
resources/js/Components/DataTable/DataTableToolbar.vue
Normal 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>
|
||||
|
||||
|
||||
170
resources/js/Components/DataTable/StatusBadge.vue
Normal file
170
resources/js/Components/DataTable/StatusBadge.vue
Normal 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>
|
||||
|
||||
|
||||
55
resources/js/Components/DataTable/TableActions.vue
Normal file
55
resources/js/Components/DataTable/TableActions.vue
Normal 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>
|
||||
|
||||
|
||||
141
resources/js/Components/DatePicker.vue
Normal file
141
resources/js/Components/DatePicker.vue
Normal 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>
|
||||
|
||||
231
resources/js/Components/DateRangePicker.vue
Normal file
231
resources/js/Components/DateRangePicker.vue
Normal 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>
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
<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({
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
|
@ -18,30 +24,53 @@ defineProps({
|
|||
},
|
||||
});
|
||||
|
||||
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">
|
||||
<Dialog v-model:open="open" :modal="true">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<slot name="title" />
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<slot name="description" />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-600">
|
||||
<div class="py-4">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-end px-6 py-4 bg-gray-100 text-end">
|
||||
<DialogFooter>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
|
|||
111
resources/js/Components/Dialogs/ConfirmationDialog.vue
Normal file
111
resources/js/Components/Dialogs/ConfirmationDialog.vue
Normal 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>
|
||||
|
||||
100
resources/js/Components/Dialogs/CreateDialog.vue
Normal file
100
resources/js/Components/Dialogs/CreateDialog.vue
Normal 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>
|
||||
|
||||
96
resources/js/Components/Dialogs/DeleteDialog.vue
Normal file
96
resources/js/Components/Dialogs/DeleteDialog.vue
Normal 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>
|
||||
|
||||
100
resources/js/Components/Dialogs/UpdateDialog.vue
Normal file
100
resources/js/Components/Dialogs/UpdateDialog.vue
Normal 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>
|
||||
|
||||
104
resources/js/Components/Dialogs/WarningDialog.vue
Normal file
104
resources/js/Components/Dialogs/WarningDialog.vue
Normal 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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,94 +232,103 @@ 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"
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<!-- Name column -->
|
||||
<template #cell-name="{ row }">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-indigo-600 hover:underline"
|
||||
@click="$emit('view', doc)"
|
||||
@click.stop="$emit('view', row)"
|
||||
>
|
||||
{{ doc.name }}
|
||||
{{ row.name }}
|
||||
</button>
|
||||
<FwbBadge v-if="doc.is_public" type="green">Public</FwbBadge>
|
||||
<Badge v-if="row.is_public" variant="secondary" class="bg-green-100 text-green-700 hover:bg-green-200">Public</Badge>
|
||||
</div>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell>
|
||||
<!-- 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(doc).icon"
|
||||
:class="['h-5 w-5', fileTypeInfo(doc).color]"
|
||||
:icon="fileTypeInfo(row).icon"
|
||||
:class="['h-5 w-5', fileTypeInfo(row).color]"
|
||||
/>
|
||||
<span class="text-gray-700">{{ fileTypeInfo(doc).label }}</span>
|
||||
<span class="text-gray-700">{{ fileTypeInfo(row).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">
|
||||
</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"
|
||||
:disabled="!hasDesc(doc)"
|
||||
:title="hasDesc(doc) ? 'Pokaži opis' : 'Ni opisa'"
|
||||
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="toggleDesc(doc, i)"
|
||||
@click.stop="toggleDesc(row)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-700" />
|
||||
</button>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell class="text-right whitespace-nowrap">
|
||||
</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"
|
||||
:title="'Actions'"
|
||||
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"
|
||||
|
|
@ -319,10 +337,11 @@ function closeActions() {
|
|||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="py-1">
|
||||
<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)"
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 transition-colors"
|
||||
@click="emit('edit', row)"
|
||||
v-if="edit"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
|
||||
|
|
@ -330,82 +349,38 @@ function closeActions() {
|
|||
</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)"
|
||||
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-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
||||
@click="askDelete(doc)"
|
||||
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>
|
||||
<!-- future actions can be slotted here -->
|
||||
</div>
|
||||
</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
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
// Expose close method for parent components
|
||||
const close = () => {
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
close,
|
||||
open,
|
||||
});
|
||||
|
||||
// Close dropdown when dialog opens
|
||||
const handleDialogOpen = () => {
|
||||
if (open.value) {
|
||||
open.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
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` };
|
||||
};
|
||||
// Watch for dialog opens using MutationObserver
|
||||
let observer = null;
|
||||
|
||||
const onWindowChange = () => {
|
||||
updatePosition();
|
||||
};
|
||||
onMounted(() => {
|
||||
// Listen for custom dialog open events
|
||||
window.addEventListener('dialog:open', handleDialogOpen);
|
||||
|
||||
watch(open, async (val) => {
|
||||
if (val) {
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
window.addEventListener('resize', onWindowChange);
|
||||
window.addEventListener('scroll', onWindowChange, true);
|
||||
} else {
|
||||
window.removeEventListener('resize', onWindowChange);
|
||||
window.removeEventListener('scroll', onWindowChange, true);
|
||||
// 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"
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
:align="alignProp"
|
||||
:class="combinedContentClasses"
|
||||
>
|
||||
<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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
124
resources/js/Components/EmptyState.vue
Normal file
124
resources/js/Components/EmptyState.vue
Normal 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>
|
||||
|
||||
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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 prevLink = computed(() => {
|
||||
if (num > 0 && props.links && Array.isArray(props.links) && props.links[0]) {
|
||||
return props.links[0];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const nextLink = computed(() => {
|
||||
if (num > 1 && props.links && Array.isArray(props.links) && props.links[num - 1]) {
|
||||
return props.links[num - 1];
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
return maxLink;
|
||||
});
|
||||
|
||||
const numericLinks = computed(() => {
|
||||
if (num < 3) return [];
|
||||
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),
|
||||
page: Number.parseInt(String(l?.label || "").replace(/[^0-9]/g, ""), 10),
|
||||
}))
|
||||
.filter((l) => !Number.isNaN(l.page));
|
||||
.filter((l) => !Number.isNaN(l.page) && l.page != null);
|
||||
});
|
||||
const currentPage = computed(() => numericLinks.value.find((l) => l.active)?.page || 1);
|
||||
const lastPage = computed(() =>
|
||||
numericLinks.value.length ? Math.max(...numericLinks.value.map((l) => l.page)) : 1
|
||||
);
|
||||
|
||||
const 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) m.set(l.page, l);
|
||||
for (const l of numericLinks.value) {
|
||||
if (l?.page != null) {
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
if (cur >= last - 2) {
|
||||
show.add(last - 1);
|
||||
show.add(last - 2);
|
||||
return pages;
|
||||
}
|
||||
|
||||
// Prev
|
||||
items.push({ kind: "prev", link: prevLink.value });
|
||||
// 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));
|
||||
|
||||
// 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,
|
||||
// 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);
|
||||
},
|
||||
});
|
||||
inGap = false;
|
||||
} else if (!inGap) {
|
||||
items.push({ kind: "ellipsis" });
|
||||
inGap = true;
|
||||
}
|
||||
}
|
||||
|
||||
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 = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Next
|
||||
items.push({ kind: "next", link: nextLink.value });
|
||||
|
||||
return items;
|
||||
});
|
||||
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 -->
|
||||
<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'
|
||||
"
|
||||
>
|
||||
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'
|
||||
"
|
||||
>
|
||||
Naslednja
|
||||
</component>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<nav
|
||||
class="isolate inline-flex -space-x-px rounded-md shadow-sm"
|
||||
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"
|
||||
>
|
||||
<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,
|
||||
}"
|
||||
<!-- Mobile: Simple prev/next -->
|
||||
<div class="flex flex-1 justify-between sm:hidden">
|
||||
<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"
|
||||
>
|
||||
<span class="sr-only">{{
|
||||
item.kind === "prev" ? "Prejšnja" : "Naslednja"
|
||||
}}</span>
|
||||
<svg
|
||||
v-if="item.kind === 'prev'"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</component>
|
||||
|
||||
<!-- Ellipsis -->
|
||||
Prejšnja
|
||||
</Link>
|
||||
<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
|
||||
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
|
||||
</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>
|
||||
|
||||
<!-- 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,
|
||||
}"
|
||||
<!-- Desktop: Full pagination -->
|
||||
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<!-- Page stats -->
|
||||
<div v-if="total > 0">
|
||||
<span class="text-sm text-gray-700">
|
||||
Prikazano: <span class="font-medium">{{ from || 0 }}</span>–<span class="font-medium">{{ to || 0 }}</span> od
|
||||
<span class="font-medium">{{ total || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{{ item.link?.label || "" }}
|
||||
</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>
|
||||
|
||||
<!-- Prev -->
|
||||
<Link
|
||||
v-if="prevLink?.url"
|
||||
:href="prevLink.url"
|
||||
:preserve-scroll="false"
|
||||
@click="handleLinkClick"
|
||||
class="px-2 py-1 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
|
||||
aria-label="Prejšnja stran"
|
||||
>
|
||||
«
|
||||
</Link>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-1 rounded border border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed opacity-50"
|
||||
aria-label="Prejšnja stran"
|
||||
>
|
||||
«
|
||||
</span>
|
||||
|
||||
<!-- Leading ellipsis / first page when window doesn't include 1 -->
|
||||
<Link
|
||||
v-if="visiblePages[0] > 1"
|
||||
:href="firstLink?.url || '#'"
|
||||
:preserve-scroll="false"
|
||||
@click="handleLinkClick"
|
||||
class="px-3 py-1 rounded border border-gray-300 bg-white text-gray-900 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
1
|
||||
</Link>
|
||||
<span v-if="visiblePages[0] > 2" class="px-1 text-gray-700">…</span>
|
||||
|
||||
<!-- Page numbers -->
|
||||
<template v-for="p in visiblePages" :key="p">
|
||||
<Link
|
||||
v-if="linkByPage.get(p)?.url"
|
||||
:href="linkByPage.get(p).url"
|
||||
:preserve-scroll="false"
|
||||
@click="handleLinkClick"
|
||||
class="px-3 py-1 rounded border transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
|
||||
:class="
|
||||
p === currentPage
|
||||
? 'border-primary-600 bg-primary-600 text-white'
|
||||
: 'border-gray-300 bg-white text-gray-900 hover:bg-gray-50'
|
||||
"
|
||||
:aria-current="p === currentPage ? 'page' : undefined"
|
||||
>
|
||||
{{ 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>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
283
resources/js/Components/PersonInfo/AddressCreateForm.vue
Normal file
283
resources/js/Components/PersonInfo/AddressCreateForm.vue
Normal 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>
|
||||
217
resources/js/Components/PersonInfo/AddressUpdateForm.vue
Normal file
217
resources/js/Components/PersonInfo/AddressUpdateForm.vue
Normal 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>
|
||||
234
resources/js/Components/PersonInfo/EmailCreateForm.vue
Normal file
234
resources/js/Components/PersonInfo/EmailCreateForm.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
97
resources/js/Components/PersonInfo/PersonInfoEmailsTab.vue
Normal file
97
resources/js/Components/PersonInfo/PersonInfoEmailsTab.vue
Normal 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>
|
||||
|
||||
455
resources/js/Components/PersonInfo/PersonInfoGrid.vue
Normal file
455
resources/js/Components/PersonInfo/PersonInfoGrid.vue
Normal 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>
|
||||
93
resources/js/Components/PersonInfo/PersonInfoPersonTab.vue
Normal file
93
resources/js/Components/PersonInfo/PersonInfoPersonTab.vue
Normal 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>
|
||||
|
||||
98
resources/js/Components/PersonInfo/PersonInfoPhonesTab.vue
Normal file
98
resources/js/Components/PersonInfo/PersonInfoPhonesTab.vue
Normal 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>
|
||||
|
||||
480
resources/js/Components/PersonInfo/PersonInfoSmsDialog.vue
Normal file
480
resources/js/Components/PersonInfo/PersonInfoSmsDialog.vue
Normal 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 (UCS‑2). 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 GSM‑7 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 (GSM‑7) oziroma
|
||||
320 (UCS‑2) 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>
|
||||
|
||||
116
resources/js/Components/PersonInfo/PersonInfoTrrTab.vue
Normal file
116
resources/js/Components/PersonInfo/PersonInfoTrrTab.vue
Normal 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>
|
||||
|
||||
183
resources/js/Components/PersonInfo/PersonUpdateForm.vue
Normal file
183
resources/js/Components/PersonInfo/PersonUpdateForm.vue
Normal 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>
|
||||
229
resources/js/Components/PersonInfo/PhoneCreateForm.vue
Normal file
229
resources/js/Components/PersonInfo/PhoneCreateForm.vue
Normal 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>
|
||||
254
resources/js/Components/PersonInfo/PhoneUpdateForm.vue
Normal file
254
resources/js/Components/PersonInfo/PhoneUpdateForm.vue
Normal 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>
|
||||
324
resources/js/Components/PersonInfo/TrrCreateForm.vue
Normal file
324
resources/js/Components/PersonInfo/TrrCreateForm.vue
Normal 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
|
|
@ -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>
|
||||
|
|
@ -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,57 +60,20 @@ 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();
|
||||
}
|
||||
};
|
||||
</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>
|
||||
<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>
|
||||
|
|
@ -211,15 +164,6 @@ const submit = () => {
|
|||
: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>
|
||||
</CreateDialog>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Test",
|
||||
created() {},
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
props: {},
|
||||
methods: {},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
47
resources/js/Components/Skeleton/SkeletonCard.vue
Normal file
47
resources/js/Components/Skeleton/SkeletonCard.vue
Normal 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>
|
||||
|
||||
|
||||
49
resources/js/Components/Skeleton/SkeletonInline.vue
Normal file
49
resources/js/Components/Skeleton/SkeletonInline.vue
Normal 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>
|
||||
|
||||
|
||||
32
resources/js/Components/Skeleton/SkeletonList.vue
Normal file
32
resources/js/Components/Skeleton/SkeletonList.vue
Normal 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>
|
||||
|
||||
|
||||
44
resources/js/Components/Skeleton/SkeletonTable.vue
Normal file
44
resources/js/Components/Skeleton/SkeletonTable.vue
Normal 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>
|
||||
|
||||
|
||||
70
resources/js/Components/Toast/ToastContainer.vue
Normal file
70
resources/js/Components/Toast/ToastContainer.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
15
resources/js/Components/ui/badge/Badge.vue
Normal file
15
resources/js/Components/ui/badge/Badge.vue
Normal 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>
|
||||
23
resources/js/Components/ui/badge/index.js
Normal file
23
resources/js/Components/ui/badge/index.js
Normal 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",
|
||||
},
|
||||
},
|
||||
);
|
||||
22
resources/js/Components/ui/button-group/ButtonGroup.vue
Normal file
22
resources/js/Components/ui/button-group/ButtonGroup.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
29
resources/js/Components/ui/button-group/ButtonGroupText.vue
Normal file
29
resources/js/Components/ui/button-group/ButtonGroupText.vue
Normal 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>
|
||||
22
resources/js/Components/ui/button-group/index.js
Normal file
22
resources/js/Components/ui/button-group/index.js
Normal 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",
|
||||
},
|
||||
},
|
||||
);
|
||||
24
resources/js/Components/ui/button/Button.vue
Normal file
24
resources/js/Components/ui/button/Button.vue
Normal 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>
|
||||
36
resources/js/Components/ui/button/index.js
Normal file
36
resources/js/Components/ui/button/index.js
Normal 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",
|
||||
},
|
||||
},
|
||||
);
|
||||
95
resources/js/Components/ui/calendar/Calendar.vue
Normal file
95
resources/js/Components/ui/calendar/Calendar.vue
Normal 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>
|
||||
30
resources/js/Components/ui/calendar/CalendarCell.vue
Normal file
30
resources/js/Components/ui/calendar/CalendarCell.vue
Normal 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>
|
||||
42
resources/js/Components/ui/calendar/CalendarCellTrigger.vue
Normal file
42
resources/js/Components/ui/calendar/CalendarCellTrigger.vue
Normal 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>
|
||||
24
resources/js/Components/ui/calendar/CalendarGrid.vue
Normal file
24
resources/js/Components/ui/calendar/CalendarGrid.vue
Normal 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
Loading…
Reference in New Issue
Block a user