Dashboard final version, TODO: update main sidebar menu
This commit is contained in:
parent
c3de189e9d
commit
c1ac92efbf
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,3 +19,4 @@ yarn-error.log
|
|||
/.idea
|
||||
/.vscode
|
||||
/.zed
|
||||
/shadcn-vue
|
||||
|
|
@ -15,7 +15,7 @@ public function __construct(protected ReferenceDataCache $referenceCache) {}
|
|||
public function index(Client $client, Request $request)
|
||||
{
|
||||
$search = $request->input('search');
|
||||
|
||||
|
||||
$query = $client::query()
|
||||
->select('clients.*')
|
||||
->when($search, function ($que) use ($search) {
|
||||
|
|
@ -128,10 +128,9 @@ public function contracts(Client $client, Request $request)
|
|||
->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)
|
||||
->when($segmentIds, function ($q) use ($segmentIds) {
|
||||
$q->whereHas('segments', function ($s) use ($segmentIds) {
|
||||
$s->whereIn('segments.id', $segmentIds)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
})
|
||||
|
|
@ -152,9 +151,15 @@ public function contracts(Client $client, Request $request)
|
|||
'phone_types' => $this->referenceCache->getPhoneTypes(),
|
||||
];
|
||||
|
||||
// Support custom pagination parameter names used by DataTableNew2
|
||||
$perPage = $request->integer('contracts_per_page', $request->integer('per_page', 15));
|
||||
$pageNumber = $request->integer('contracts_page', $request->integer('page', 1));
|
||||
|
||||
return Inertia::render('Client/Contracts', [
|
||||
'client' => $data,
|
||||
'contracts' => $contractsQuery->paginate($request->integer('per_page', 20))->withQueryString(),
|
||||
'contracts' => $contractsQuery
|
||||
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['from', 'to', 'search', 'segment']),
|
||||
'segments' => $segments,
|
||||
'types' => $types,
|
||||
|
|
|
|||
|
|
@ -2,15 +2,17 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Client;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Document; // assuming model name Import
|
||||
// assuming model name Import
|
||||
use App\Models\FieldJob; // if this model exists
|
||||
use App\Models\Import;
|
||||
use App\Models\SmsLog;
|
||||
use App\Models\SmsProfile;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
|
|
@ -21,256 +23,188 @@ class DashboardController extends Controller
|
|||
public function __invoke(SmsService $sms): Response
|
||||
{
|
||||
$today = now()->startOfDay();
|
||||
$yesterday = now()->subDay()->startOfDay();
|
||||
$staleThreshold = now()->subDays(7); // assumption: stale if no activity in last 7 days
|
||||
$cacheMinutes = 5;
|
||||
|
||||
$clientsTotal = Client::count();
|
||||
$clientsNew7d = Client::where('created_at', '>=', now()->subDays(7))->count();
|
||||
// FieldJob table does not have a scheduled_at column (schema shows: assigned_at, completed_at, cancelled_at)
|
||||
// Temporary logic: if scheduled_at ever added we'll use it; otherwise fall back to assigned_at then created_at.
|
||||
if (Schema::hasColumn('field_jobs', 'scheduled_at')) {
|
||||
$fieldJobsToday = FieldJob::whereDate('scheduled_at', $today)->count();
|
||||
} else {
|
||||
// Prefer assigned_at when present, otherwise created_at
|
||||
$fieldJobsToday = FieldJob::whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)->count();
|
||||
}
|
||||
$documentsToday = Document::whereDate('created_at', $today)->count();
|
||||
$activeImports = Import::whereIn('status', ['queued', 'processing'])->count();
|
||||
$activeContracts = Contract::where('active', 1)->count();
|
||||
// Active clients count - cached
|
||||
$activeClientsCount = Cache::remember('dashboard:active_clients:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
return Client::where('active', true)->count();
|
||||
});
|
||||
|
||||
// Basic activities deferred list (limit 10)
|
||||
$activities = Activity::query()
|
||||
->with(['clientCase:id,uuid'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get(['id', 'note', 'created_at', 'client_case_id', 'contract_id', 'action_id', 'decision_id'])
|
||||
->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'note' => $a->note,
|
||||
'created_at' => $a->created_at,
|
||||
'client_case_id' => $a->client_case_id,
|
||||
'client_case_uuid' => $a->clientCase?->uuid,
|
||||
'contract_id' => $a->contract_id,
|
||||
'action_id' => $a->action_id,
|
||||
'decision_id' => $a->decision_id,
|
||||
]);
|
||||
// Active contracts count - cached
|
||||
$activeContractsCount = Cache::remember('dashboard:active_contracts:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
return Contract::whereNull('deleted_at')->count();
|
||||
});
|
||||
|
||||
// 7-day trends (including today)
|
||||
$start = now()->subDays(6)->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
// Sum of active contracts' account balance - cached
|
||||
$totalBalance = Cache::remember('dashboard:total_balance:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
return Account::whereHas('contract', function ($q) {
|
||||
$q->whereNull('deleted_at');
|
||||
})->sum('balance_amount') ?? 0;
|
||||
});
|
||||
|
||||
$dateKeys = collect(range(0, 6))
|
||||
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
||||
|
||||
$clientTrendRaw = Client::whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$documentTrendRaw = Document::whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
||||
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$importTrendRaw = Import::whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
// Completed field jobs last 7 days
|
||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||
->whereBetween('completed_at', [$start, $end])
|
||||
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
$trends = [
|
||||
'clients_new' => $dateKeys->map(fn ($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(),
|
||||
'documents_new' => $dateKeys->map(fn ($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(),
|
||||
'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
|
||||
'imports_new' => $dateKeys->map(fn ($d) => (int) ($importTrendRaw[$d] ?? 0))->values(),
|
||||
'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
|
||||
'labels' => $dateKeys,
|
||||
];
|
||||
|
||||
// Stale client cases (no activity in last 7 days)
|
||||
$staleCases = \App\Models\ClientCase::query()
|
||||
->leftJoin('activities', function ($join) {
|
||||
$join->on('activities.client_case_id', '=', 'client_cases.id')
|
||||
->whereNull('activities.deleted_at');
|
||||
// Active promises count (not expired or expires today) - cached
|
||||
$activePromisesCount = Cache::remember('dashboard:active_promises:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
||||
return Account::whereHas('contract', function ($q) {
|
||||
$q->whereNull('deleted_at');
|
||||
})
|
||||
->selectRaw('client_cases.id, client_cases.uuid, client_cases.client_ref, MAX(activities.created_at) as last_activity_at, client_cases.created_at')
|
||||
->groupBy('client_cases.id', 'client_cases.uuid', 'client_cases.client_ref', 'client_cases.created_at')
|
||||
->havingRaw('(MAX(activities.created_at) IS NULL OR MAX(activities.created_at) < ?) AND client_cases.created_at < ?', [$staleThreshold, $staleThreshold])
|
||||
->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(function ($c) {
|
||||
// Reference point: last activity if exists, else creation.
|
||||
$reference = $c->last_activity_at ? \Illuminate\Support\Carbon::parse($c->last_activity_at) : $c->created_at;
|
||||
// Use minute precision to avoid jumping to 1 too early (e.g. created just before midnight).
|
||||
$minutes = $reference ? max(0, $reference->diffInMinutes(now())) : 0;
|
||||
$daysFraction = $minutes / 1440; // 60 * 24
|
||||
// Provide both fractional and integer versions (integer preserved for backwards compatibility if needed)
|
||||
$daysInteger = (int) floor($daysFraction);
|
||||
->whereNotNull('promise_date')
|
||||
->whereDate('promise_date', '>=', $today)
|
||||
->count();
|
||||
});
|
||||
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'uuid' => $c->uuid,
|
||||
'client_ref' => $c->client_ref,
|
||||
'last_activity_at' => $c->last_activity_at,
|
||||
'created_at' => $c->created_at,
|
||||
'days_without_activity' => round($daysFraction, 4), // fractional for finer UI decision (<1 day)
|
||||
'days_stale' => $daysInteger, // legacy key (integer)
|
||||
'has_activity' => (bool) $c->last_activity_at,
|
||||
];
|
||||
});
|
||||
// Activities (limit 10) - cached
|
||||
$activities = Cache::remember('dashboard:activities', $cacheMinutes * 60, function () {
|
||||
return Activity::query()
|
||||
->with(['clientCase:id,uuid'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get(['id', 'note', 'created_at', 'client_case_id', 'contract_id', 'action_id', 'decision_id'])
|
||||
->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'note' => $a->note,
|
||||
'created_at' => $a->created_at,
|
||||
'client_case_id' => $a->client_case_id,
|
||||
'client_case_uuid' => $a->clientCase?->uuid,
|
||||
'contract_id' => $a->contract_id,
|
||||
'action_id' => $a->action_id,
|
||||
'decision_id' => $a->decision_id,
|
||||
]);
|
||||
});
|
||||
|
||||
// Field jobs assigned today
|
||||
$fieldJobsAssignedToday = FieldJob::query()
|
||||
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
||||
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
||||
->with(['contract' => function ($q) {
|
||||
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
||||
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
|
||||
}])
|
||||
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
|
||||
->limit(15)
|
||||
->get()
|
||||
->map(function ($fj) {
|
||||
$contract = $fj->contract;
|
||||
$segmentId = null;
|
||||
if ($contract && method_exists($contract, 'segments')) {
|
||||
// Determine active segment via pivot active flag if present
|
||||
$activeSeg = $contract->segments->first();
|
||||
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
|
||||
$segmentId = $activeSeg->id;
|
||||
// 7-day trends for field jobs - cached
|
||||
$trends = Cache::remember('dashboard:field_jobs_trends:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
$start = now()->subDays(6)->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
|
||||
$dateKeys = collect(range(0, 6))
|
||||
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
||||
|
||||
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
||||
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
// Completed field jobs last 7 days
|
||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||
->whereBetween('completed_at', [$start, $end])
|
||||
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
return [
|
||||
'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
|
||||
'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
|
||||
'labels' => $dateKeys,
|
||||
];
|
||||
});
|
||||
|
||||
// Field jobs assigned today - cached
|
||||
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
||||
return FieldJob::query()
|
||||
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
||||
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
||||
->with(['contract' => function ($q) {
|
||||
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
||||
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
|
||||
}])
|
||||
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
|
||||
->limit(15)
|
||||
->get()
|
||||
->map(function ($fj) {
|
||||
$contract = $fj->contract;
|
||||
$segmentId = null;
|
||||
if ($contract && method_exists($contract, 'segments')) {
|
||||
$activeSeg = $contract->segments->first();
|
||||
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
|
||||
$segmentId = $activeSeg->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $fj->id,
|
||||
'priority' => $fj->priority,
|
||||
// Normalize to ISO8601 strings so FE retains timezone & time component
|
||||
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
||||
'created_at' => $fj->created_at?->toIso8601String(),
|
||||
'contract' => $contract ? [
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
||||
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
||||
'segment_id' => $segmentId,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
return [
|
||||
'id' => $fj->id,
|
||||
'priority' => $fj->priority,
|
||||
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
||||
'created_at' => $fj->created_at?->toIso8601String(),
|
||||
'contract' => $contract ? [
|
||||
'uuid' => $contract->uuid,
|
||||
'reference' => $contract->reference,
|
||||
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
||||
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
||||
'segment_id' => $segmentId,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
// Imports in progress (queued / processing)
|
||||
$importsInProgress = Import::query()
|
||||
->whereIn('status', ['queued', 'processing'])
|
||||
->latest('created_at')
|
||||
->limit(10)
|
||||
->get(['id', 'uuid', 'file_name', 'status', 'total_rows', 'imported_rows', 'valid_rows', 'invalid_rows', 'started_at'])
|
||||
->map(fn ($i) => [
|
||||
'id' => $i->id,
|
||||
'uuid' => $i->uuid,
|
||||
'file_name' => $i->file_name,
|
||||
'status' => $i->status,
|
||||
'total_rows' => $i->total_rows,
|
||||
'imported_rows' => $i->imported_rows,
|
||||
'valid_rows' => $i->valid_rows,
|
||||
'invalid_rows' => $i->invalid_rows,
|
||||
'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1, $i->total_rows)) * 100, 1) : null,
|
||||
'started_at' => $i->started_at,
|
||||
]);
|
||||
|
||||
// Active document templates summary (active versions)
|
||||
$activeTemplates = \App\Models\DocumentTemplate::query()
|
||||
->where('active', true)
|
||||
->latest('updated_at')
|
||||
->limit(10)
|
||||
->get(['id', 'name', 'slug', 'version', 'updated_at']);
|
||||
|
||||
// System health (deferred)
|
||||
$queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null;
|
||||
$failedJobs = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : null;
|
||||
// System health for timestamp
|
||||
$recentActivity = Activity::query()->latest('created_at')->value('created_at');
|
||||
$lastActivityMinutes = null;
|
||||
if ($recentActivity) {
|
||||
// diffInMinutes is absolute (non-negative) but guard anyway & cast to int
|
||||
$lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity));
|
||||
}
|
||||
$systemHealth = [
|
||||
'queue_backlog' => $queueBacklog,
|
||||
'failed_jobs' => $failedJobs,
|
||||
'last_activity_minutes' => $lastActivityMinutes,
|
||||
'last_activity_iso' => $recentActivity?->toIso8601String(),
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
return Inertia::render('Dashboard/Index', [
|
||||
'kpis' => [
|
||||
'clients_total' => $clientsTotal,
|
||||
'clients_new_7d' => $clientsNew7d,
|
||||
'field_jobs_today' => $fieldJobsToday,
|
||||
'documents_today' => $documentsToday,
|
||||
'active_imports' => $activeImports,
|
||||
'active_contracts' => $activeContracts,
|
||||
'active_clients' => $activeClientsCount,
|
||||
'active_contracts' => $activeContractsCount,
|
||||
'total_balance' => $totalBalance,
|
||||
'active_promises' => $activePromisesCount,
|
||||
],
|
||||
'trends' => $trends,
|
||||
])->with([ // deferred props (Inertia v2 style)
|
||||
])->with([
|
||||
'activities' => fn () => $activities,
|
||||
'systemHealth' => fn () => $systemHealth,
|
||||
'staleCases' => fn () => $staleCases,
|
||||
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
|
||||
'importsInProgress' => fn () => $importsInProgress,
|
||||
'activeTemplates' => fn () => $activeTemplates,
|
||||
'smsStats' => function () use ($sms, $today) {
|
||||
// Aggregate counts per profile for today
|
||||
$counts = SmsLog::query()
|
||||
->whereDate('created_at', $today)
|
||||
->selectRaw('profile_id, status, COUNT(*) as c')
|
||||
->groupBy('profile_id', 'status')
|
||||
->get()
|
||||
->groupBy('profile_id')
|
||||
->map(function ($rows) {
|
||||
$map = [
|
||||
'queued' => 0,
|
||||
'sent' => 0,
|
||||
'delivered' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
foreach ($rows as $r) {
|
||||
$map[$r->status] = (int) $r->c;
|
||||
'smsStats' => function () use ($sms, $today, $cacheMinutes) {
|
||||
// SMS stats - cached
|
||||
return Cache::remember('dashboard:sms_stats:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($sms, $today) {
|
||||
$counts = SmsLog::query()
|
||||
->whereDate('created_at', $today)
|
||||
->selectRaw('profile_id, status, COUNT(*) as c')
|
||||
->groupBy('profile_id', 'status')
|
||||
->get()
|
||||
->groupBy('profile_id')
|
||||
->map(function ($rows) {
|
||||
$map = [
|
||||
'queued' => 0,
|
||||
'sent' => 0,
|
||||
'delivered' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
foreach ($rows as $r) {
|
||||
$map[$r->status] = (int) $r->c;
|
||||
}
|
||||
$map['total'] = array_sum($map);
|
||||
|
||||
return $map;
|
||||
});
|
||||
|
||||
$profiles = SmsProfile::query()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
|
||||
|
||||
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
|
||||
try {
|
||||
$balance = $sms->getCreditBalance($p);
|
||||
} catch (\Throwable $e) {
|
||||
$balance = '—';
|
||||
}
|
||||
$map['total'] = array_sum($map);
|
||||
$c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0];
|
||||
|
||||
return $map;
|
||||
});
|
||||
|
||||
// Important: include credential fields so provider calls have proper credentials
|
||||
$profiles = SmsProfile::query()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
|
||||
|
||||
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
|
||||
// Provider balance may fail; guard and present a placeholder.
|
||||
try {
|
||||
$balance = $sms->getCreditBalance($p);
|
||||
} catch (\Throwable $e) {
|
||||
$balance = '—';
|
||||
}
|
||||
$c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0];
|
||||
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'active' => (bool) $p->active,
|
||||
'balance' => $balance,
|
||||
'today' => $c,
|
||||
];
|
||||
})->values();
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'active' => (bool) $p->active,
|
||||
'balance' => $balance,
|
||||
'today' => $c,
|
||||
];
|
||||
})->values();
|
||||
});
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@
|
|||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"components": "@/Components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"ui": "@/Components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
|
|
|
|||
1878
package-lock.json
generated
1878
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -30,6 +30,8 @@
|
|||
"@heroicons/vue": "^2.1.5",
|
||||
"@internationalized/date": "^3.9.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@unovis/ts": "^1.6.2",
|
||||
"@unovis/vue": "^1.6.2",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vuepic/vue-datepicker": "^11.0.2",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
@import "tailwindcss";
|
||||
@import "./themes.css";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
|
|
@ -187,74 +188,72 @@ @theme inline {
|
|||
}
|
||||
|
||||
: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);
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.648 0.2 131.684);
|
||||
--primary-foreground: oklch(0.986 0.031 120.757);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.841 0.238 128.85);
|
||||
--chart-1: oklch(0.871 0.15 154.449);
|
||||
--chart-2: oklch(0.723 0.219 149.579);
|
||||
--chart-3: oklch(0.627 0.194 149.214);
|
||||
--chart-4: oklch(0.527 0.154 150.069);
|
||||
--chart-5: oklch(0.448 0.119 151.328);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.648 0.2 131.684);
|
||||
--sidebar-primary-foreground: oklch(0.986 0.031 120.757);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.841 0.238 128.85);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.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);
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.648 0.2 131.684);
|
||||
--primary-foreground: oklch(0.986 0.031 120.757);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.405 0.101 131.063);
|
||||
--chart-1: oklch(0.871 0.15 154.449);
|
||||
--chart-2: oklch(0.723 0.219 149.579);
|
||||
--chart-3: oklch(0.627 0.194 149.214);
|
||||
--chart-4: oklch(0.527 0.154 150.069);
|
||||
--chart-5: oklch(0.448 0.119 151.328);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.768 0.233 130.85);
|
||||
--sidebar-primary-foreground: oklch(0.986 0.031 120.757);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.405 0.101 131.063);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
@ -264,4 +263,9 @@ @layer base {
|
|||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
button:not([disabled]),
|
||||
[role="button"]:not([disabled]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
396
resources/css/themes.css
Normal file
396
resources/css/themes.css
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
.theme-default .theme-container,
|
||||
.theme-default [data-reka-popper-content-wrapper] {
|
||||
--chart-1: var(--color-blue-300);
|
||||
--chart-2: var(--color-blue-500);
|
||||
--chart-3: var(--color-blue-600);
|
||||
--chart-4: var(--color-blue-700);
|
||||
--chart-5: var(--color-blue-800);
|
||||
}
|
||||
|
||||
.theme-mono .theme-container,
|
||||
.theme-mono [data-reka-popper-content-wrapper] {
|
||||
--font-sans: var(--font-mono);
|
||||
--primary: var(--color-stone-600);
|
||||
--primary-foreground: var(--color-stone-50);
|
||||
--chart-1: var(--color-stone-300);
|
||||
--chart-2: var(--color-stone-500);
|
||||
--chart-3: var(--color-stone-600);
|
||||
--chart-4: var(--color-stone-700);
|
||||
--chart-5: var(--color-stone-800);
|
||||
--sidebar-primary: var(--color-stone-600);
|
||||
--sidebar-primary-foreground: var(--color-stone-50);
|
||||
--sidebar-ring: var(--color-stone-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-stone-500);
|
||||
--primary-foreground: var(--color-stone-50);
|
||||
--sidebar-primary: var(--color-stone-500);
|
||||
--sidebar-primary-foreground: var(--color-stone-50);
|
||||
--sidebar-ring: var(--color-stone-900);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
--font-sans: var(--font-mono);
|
||||
--radius: 0.45em;
|
||||
--text-lg: 1rem;
|
||||
--text-xl: 1.1rem;
|
||||
--text-2xl: 1.2rem;
|
||||
--text-3xl: 1.3rem;
|
||||
--text-4xl: 1.4rem;
|
||||
--text-5xl: 1.5rem;
|
||||
--text-6xl: 1.6rem;
|
||||
--text-7xl: 1.7rem;
|
||||
--text-8xl: 1.8rem;
|
||||
--text-base: 0.85rem;
|
||||
--text-sm: 0.8rem;
|
||||
--spacing: 0.222222rem;
|
||||
}
|
||||
|
||||
.rounded-xs,
|
||||
.rounded-sm,
|
||||
.rounded-md,
|
||||
.rounded-lg,
|
||||
.rounded-xl {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.shadow-xs,
|
||||
.shadow-sm,
|
||||
.shadow-md,
|
||||
.shadow-lg,
|
||||
.shadow-xl {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-slot="toggle-group"],
|
||||
[data-slot="toggle-group-item"],
|
||||
[data-slot="checkbox"],
|
||||
[data-slot="radio"],
|
||||
[data-slot="switch"],
|
||||
[data-slot="switch-thumb"] {
|
||||
@apply !rounded-none !shadow-none;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-scaled .theme-container,
|
||||
.theme-scaled [data-reka-popper-content-wrapper] {
|
||||
--chart-1: var(--color-blue-300);
|
||||
--chart-2: var(--color-blue-500);
|
||||
--chart-3: var(--color-blue-600);
|
||||
--chart-4: var(--color-blue-700);
|
||||
--chart-5: var(--color-blue-800);
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
--radius: 0.45em;
|
||||
--text-lg: 1rem;
|
||||
--text-xl: 1.1rem;
|
||||
--text-2xl: 1.2rem;
|
||||
--text-3xl: 1.3rem;
|
||||
--text-4xl: 1.4rem;
|
||||
--text-5xl: 1.5rem;
|
||||
--text-6xl: 1.6rem;
|
||||
--text-7xl: 1.7rem;
|
||||
--text-8xl: 1.8rem;
|
||||
--text-base: 0.85rem;
|
||||
--text-sm: 0.8rem;
|
||||
--spacing: 0.2rem;
|
||||
}
|
||||
|
||||
[data-slot="select-trigger"],
|
||||
[data-slot="toggle-group-item"] {
|
||||
--spacing: 0.2rem;
|
||||
}
|
||||
|
||||
[data-slot="card"] {
|
||||
border-radius: var(--radius);
|
||||
padding-block: calc(var(--spacing) * 4);
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
|
||||
[data-slot="card"].pb-0 {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-rounded-none .theme-container,
|
||||
.theme-rounded-none [data-reka-popper-content-wrapper] {
|
||||
--radius: 0;
|
||||
}
|
||||
|
||||
.theme-rounded-small .theme-container,
|
||||
.theme-rounded-small [data-reka-popper-content-wrapper] {
|
||||
--radius: 0.4rem;
|
||||
}
|
||||
|
||||
.theme-rounded-medium .theme-container,
|
||||
.theme-rounded-medium [data-reka-popper-content-wrapper] {
|
||||
--radius: 0.65rem;
|
||||
}
|
||||
|
||||
.theme-rounded-large .theme-container,
|
||||
.theme-rounded-large [data-reka-popper-content-wrapper] {
|
||||
--radius: 1rem;
|
||||
}
|
||||
|
||||
.theme-rounded-full .theme-container,
|
||||
.theme-rounded-full [data-reka-popper-content-wrapper] {
|
||||
--radius: 1.2rem;
|
||||
}
|
||||
|
||||
.theme-inter .theme-container,
|
||||
.theme-inter [data-reka-popper-content-wrapper] {
|
||||
--font-sans: var(--font-inter);
|
||||
}
|
||||
|
||||
.theme-noto-sans .theme-container,
|
||||
.theme-noto-sans [data-reka-popper-content-wrapper] {
|
||||
--font-sans: var(--font-noto-sans);
|
||||
}
|
||||
|
||||
.theme-nunito-sans .theme-container,
|
||||
.theme-nunito-sans [data-reka-popper-content-wrapper] {
|
||||
--font-sans: var(--font-nunito-sans);
|
||||
}
|
||||
|
||||
.theme-figtree .theme-container,
|
||||
.theme-figtree [data-reka-popper-content-wrapper] {
|
||||
--font-sans: var(--font-figtree);
|
||||
}
|
||||
|
||||
.theme-blue .theme-container,
|
||||
.theme-blue [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-blue-700);
|
||||
--primary-foreground: var(--color-blue-50);
|
||||
/* --ring: var(--color-blue-100); */
|
||||
--sidebar-primary: var(--color-blue-600);
|
||||
--sidebar-primary-foreground: var(--color-blue-50);
|
||||
/* --sidebar-ring: var(--color-blue-400); */
|
||||
--chart-1: var(--color-blue-300);
|
||||
--chart-2: var(--color-blue-500);
|
||||
--chart-3: var(--color-blue-600);
|
||||
--chart-4: var(--color-blue-700);
|
||||
--chart-5: var(--color-blue-800);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-blue-700);
|
||||
--primary-foreground: var(--color-blue-50);
|
||||
/* --ring: var(--color-blue-900); */
|
||||
--sidebar-primary: var(--color-blue-500);
|
||||
--sidebar-primary-foreground: var(--color-blue-50);
|
||||
/* --sidebar-ring: var(--color-blue-900); */
|
||||
}
|
||||
}
|
||||
|
||||
.theme-green .theme-container,
|
||||
.theme-green [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-lime-600);
|
||||
--primary-foreground: var(--color-lime-50);
|
||||
--ring: var(--color-lime-400);
|
||||
--chart-1: var(--color-green-300);
|
||||
--chart-2: var(--color-green-500);
|
||||
--chart-3: var(--color-green-600);
|
||||
--chart-4: var(--color-green-700);
|
||||
--chart-5: var(--color-green-800);
|
||||
--sidebar-primary: var(--color-lime-600);
|
||||
--sidebar-primary-foreground: var(--color-lime-50);
|
||||
--sidebar-ring: var(--color-lime-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-lime-600);
|
||||
--primary-foreground: var(--color-lime-50);
|
||||
--ring: var(--color-lime-900);
|
||||
--sidebar-primary: var(--color-lime-500);
|
||||
--sidebar-primary-foreground: var(--color-lime-50);
|
||||
--sidebar-ring: var(--color-lime-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-amber .theme-container,
|
||||
.theme-amber [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-amber-600);
|
||||
--primary-foreground: var(--color-amber-50);
|
||||
--ring: var(--color-amber-400);
|
||||
--chart-1: var(--color-amber-300);
|
||||
--chart-2: var(--color-amber-500);
|
||||
--chart-3: var(--color-amber-600);
|
||||
--chart-4: var(--color-amber-700);
|
||||
--chart-5: var(--color-amber-800);
|
||||
--sidebar-primary: var(--color-amber-600);
|
||||
--sidebar-primary-foreground: var(--color-amber-50);
|
||||
--sidebar-ring: var(--color-amber-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-amber-500);
|
||||
--primary-foreground: var(--color-amber-50);
|
||||
--ring: var(--color-amber-900);
|
||||
--sidebar-primary: var(--color-amber-500);
|
||||
--sidebar-primary-foreground: var(--color-amber-50);
|
||||
--sidebar-ring: var(--color-amber-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-rose .theme-container,
|
||||
.theme-rose [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-rose-600);
|
||||
--primary-foreground: var(--color-rose-50);
|
||||
--ring: var(--color-rose-400);
|
||||
--chart-1: var(--color-rose-300);
|
||||
--chart-2: var(--color-rose-500);
|
||||
--chart-3: var(--color-rose-600);
|
||||
--chart-4: var(--color-rose-700);
|
||||
--chart-5: var(--color-rose-800);
|
||||
--sidebar-primary: var(--color-rose-600);
|
||||
--sidebar-primary-foreground: var(--color-rose-50);
|
||||
--sidebar-ring: var(--color-rose-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-rose-500);
|
||||
--primary-foreground: var(--color-rose-50);
|
||||
--ring: var(--color-rose-900);
|
||||
--sidebar-primary: var(--color-rose-500);
|
||||
--sidebar-primary-foreground: var(--color-rose-50);
|
||||
--sidebar-ring: var(--color-rose-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-purple .theme-container,
|
||||
.theme-purple [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-purple-600);
|
||||
--primary-foreground: var(--color-purple-50);
|
||||
--ring: var(--color-purple-400);
|
||||
--chart-1: var(--color-purple-300);
|
||||
--chart-2: var(--color-purple-500);
|
||||
--chart-3: var(--color-purple-600);
|
||||
--chart-4: var(--color-purple-700);
|
||||
--chart-5: var(--color-purple-800);
|
||||
--sidebar-primary: var(--color-purple-600);
|
||||
--sidebar-primary-foreground: var(--color-purple-50);
|
||||
--sidebar-ring: var(--color-purple-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-purple-500);
|
||||
--primary-foreground: var(--color-purple-50);
|
||||
--ring: var(--color-purple-900);
|
||||
--sidebar-primary: var(--color-purple-500);
|
||||
--sidebar-primary-foreground: var(--color-purple-50);
|
||||
--sidebar-ring: var(--color-purple-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-orange .theme-container,
|
||||
.theme-orange [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-orange-600);
|
||||
--primary-foreground: var(--color-orange-50);
|
||||
--ring: var(--color-orange-400);
|
||||
--chart-1: var(--color-orange-300);
|
||||
--chart-2: var(--color-orange-500);
|
||||
--chart-3: var(--color-orange-600);
|
||||
--chart-4: var(--color-orange-700);
|
||||
--chart-5: var(--color-orange-800);
|
||||
--sidebar-primary: var(--color-orange-600);
|
||||
--sidebar-primary-foreground: var(--color-orange-50);
|
||||
--sidebar-ring: var(--color-orange-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-orange-500);
|
||||
--primary-foreground: var(--color-orange-50);
|
||||
--ring: var(--color-orange-900);
|
||||
--sidebar-primary: var(--color-orange-500);
|
||||
--sidebar-primary-foreground: var(--color-orange-50);
|
||||
--sidebar-ring: var(--color-orange-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-teal .theme-container,
|
||||
.theme-teal [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-teal-600);
|
||||
--primary-foreground: var(--color-teal-50);
|
||||
--chart-1: var(--color-teal-300);
|
||||
--chart-2: var(--color-teal-500);
|
||||
--chart-3: var(--color-teal-600);
|
||||
--chart-4: var(--color-teal-700);
|
||||
--chart-5: var(--color-teal-800);
|
||||
--sidebar-primary: var(--color-teal-600);
|
||||
--sidebar-primary-foreground: var(--color-teal-50);
|
||||
--sidebar-ring: var(--color-teal-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-teal-500);
|
||||
--primary-foreground: var(--color-teal-50);
|
||||
--sidebar-primary: var(--color-teal-500);
|
||||
--sidebar-primary-foreground: var(--color-teal-50);
|
||||
--sidebar-ring: var(--color-teal-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-red .theme-container,
|
||||
.theme-red [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-red-600);
|
||||
--primary-foreground: var(--color-red-50);
|
||||
--ring: var(--color-red-400);
|
||||
--chart-1: var(--color-red-300);
|
||||
--chart-2: var(--color-red-500);
|
||||
--chart-3: var(--color-red-600);
|
||||
--chart-4: var(--color-red-700);
|
||||
--chart-5: var(--color-red-800);
|
||||
--sidebar-primary: var(--color-red-600);
|
||||
--sidebar-primary-foreground: var(--color-red-50);
|
||||
--sidebar-ring: var(--color-red-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-red-500);
|
||||
--primary-foreground: var(--color-red-50);
|
||||
--ring: var(--color-red-900);
|
||||
--sidebar-primary: var(--color-red-500);
|
||||
--sidebar-primary-foreground: var(--color-red-50);
|
||||
--sidebar-ring: var(--color-red-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-yellow .theme-container,
|
||||
.theme-yellow [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-yellow-400);
|
||||
--primary-foreground: var(--color-yellow-900);
|
||||
--ring: var(--color-yellow-400);
|
||||
--chart-1: var(--color-yellow-300);
|
||||
--chart-2: var(--color-yellow-500);
|
||||
--chart-3: var(--color-yellow-600);
|
||||
--chart-4: var(--color-yellow-700);
|
||||
--chart-5: var(--color-yellow-800);
|
||||
--sidebar-primary: var(--color-yellow-600);
|
||||
--sidebar-primary-foreground: var(--color-yellow-50);
|
||||
--sidebar-ring: var(--color-yellow-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-yellow-500);
|
||||
--primary-foreground: var(--color-yellow-900);
|
||||
--ring: var(--color-yellow-900);
|
||||
--sidebar-primary: var(--color-yellow-500);
|
||||
--sidebar-primary-foreground: var(--color-yellow-50);
|
||||
--sidebar-ring: var(--color-yellow-900);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-violet .theme-container,
|
||||
.theme-violet [data-reka-popper-content-wrapper] {
|
||||
--primary: var(--color-violet-600);
|
||||
--primary-foreground: var(--color-violet-50);
|
||||
--ring: var(--color-violet-400);
|
||||
--chart-1: var(--color-violet-300);
|
||||
--chart-2: var(--color-violet-500);
|
||||
--chart-3: var(--color-violet-600);
|
||||
--chart-4: var(--color-violet-700);
|
||||
--chart-5: var(--color-violet-800);
|
||||
--sidebar-primary: var(--color-violet-600);
|
||||
--sidebar-primary-foreground: var(--color-violet-50);
|
||||
--sidebar-ring: var(--color-violet-400);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-violet-500);
|
||||
--primary-foreground: var(--color-violet-50);
|
||||
--ring: var(--color-violet-900);
|
||||
--sidebar-primary: var(--color-violet-500);
|
||||
--sidebar-primary-foreground: var(--color-violet-50);
|
||||
--sidebar-ring: var(--color-violet-900);
|
||||
}
|
||||
}
|
||||
|
|
@ -690,6 +690,11 @@ function exportToXLSX(data) {
|
|||
:from="from"
|
||||
:to="to"
|
||||
:total="total"
|
||||
:current-page="meta.current_page"
|
||||
:last-page="meta.last_page"
|
||||
:per-page="meta.per_page"
|
||||
:page-param="pageParamName"
|
||||
:per-page-param="'per_page'"<!-- legacy component may not have custom per-page name prop -->
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -275,10 +275,10 @@ const pagination = computed(() => {
|
|||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const perPageParam = props.perPageParamName || "per_page";
|
||||
const urlPerPage = urlParams.get(perPageParam);
|
||||
const pageSize = urlPerPage
|
||||
? Number(urlPerPage)
|
||||
: (props.meta?.per_page ?? props.pageSize);
|
||||
|
||||
const pageSize = urlPerPage
|
||||
? Number(urlPerPage)
|
||||
: props.meta?.per_page ?? props.pageSize;
|
||||
|
||||
return {
|
||||
pageIndex: (props.meta?.current_page ?? 1) - 1,
|
||||
pageSize: pageSize,
|
||||
|
|
@ -470,7 +470,7 @@ function keyOf(row) {
|
|||
:per-page="pagination.pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
@update:per-page="(value) => table.setPageSize(value)"
|
||||
class="px-4 py-2 border-t"
|
||||
class="p-2 border-t"
|
||||
>
|
||||
<template #filters="slotProps">
|
||||
<slot name="toolbar-filters" v-bind="slotProps" />
|
||||
|
|
@ -492,13 +492,14 @@ function keyOf(row) {
|
|||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
'py-4',
|
||||
'p-3',
|
||||
header.column.columnDef.meta?.class,
|
||||
header.column.columnDef.meta?.align === 'right'
|
||||
? 'text-right'
|
||||
: header.column.columnDef.meta?.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left',
|
||||
'bg-muted/50',
|
||||
]"
|
||||
>
|
||||
<FlexRender
|
||||
|
|
@ -557,6 +558,7 @@ function keyOf(row) {
|
|||
: cell.column.columnDef.meta?.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left',
|
||||
'p-3',
|
||||
]"
|
||||
>
|
||||
<!-- Use slot if provided -->
|
||||
|
|
@ -581,7 +583,7 @@ function keyOf(row) {
|
|||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="showPagination">
|
||||
<div v-if="showPagination" class="border-t border-gray-200 p-4">
|
||||
<!-- Server-side pagination -->
|
||||
<template v-if="isServerSide && meta?.links">
|
||||
<Pagination
|
||||
|
|
@ -589,6 +591,11 @@ function keyOf(row) {
|
|||
:from="meta.from"
|
||||
:to="meta.to"
|
||||
:total="meta.total"
|
||||
:current-page="meta.current_page"
|
||||
:last-page="meta.last_page"
|
||||
:per-page="meta.per_page"
|
||||
:page-param="pageParamName"
|
||||
:per-page-param="perPageParamName"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const props = defineProps({
|
|||
|
||||
// Define columns for DataTable
|
||||
const columns = [
|
||||
{ key: "key", label: "#", sortable: false, align: "center" },
|
||||
{ key: "name", label: "Naziv", sortable: false },
|
||||
{ key: "type", label: "Vrsta", sortable: false },
|
||||
{ key: "size", label: "Velikost", align: "right", sortable: false },
|
||||
|
|
@ -292,23 +293,27 @@ function closeActions() {
|
|||
<template #toolbar-actions>
|
||||
<slot name="add" />
|
||||
</template>
|
||||
<!-- Key column -->
|
||||
<template #cell-key="{ row }">
|
||||
<Badge
|
||||
v-if="row.is_public"
|
||||
variant="secondary"
|
||||
class="bg-green-100 text-green-700 hover:bg-green-200 shrink-0"
|
||||
>Public</Badge
|
||||
>
|
||||
</template>
|
||||
<!-- Name column -->
|
||||
<template #cell-name="{ row }">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="text-indigo-600 hover:underline max-w-xs truncate"
|
||||
:title="row.name"
|
||||
@click.stop="$emit('view', row)"
|
||||
>
|
||||
{{ row.name }}
|
||||
{{ row.name.length > 15 ? row.name.substring(0, 15) + "..." : row.name }}
|
||||
</button>
|
||||
<Badge
|
||||
v-if="row.is_public"
|
||||
variant="secondary"
|
||||
class="bg-green-100 text-green-700 hover:bg-green-200"
|
||||
>Public</Badge
|
||||
>
|
||||
</div>
|
||||
<!-- Expanded description -->
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/Components/ui/pagination";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-vue-next";
|
||||
import { toInteger } from "lodash";
|
||||
|
||||
|
|
@ -178,7 +178,7 @@ function handlePerPageChange(value) {
|
|||
|
||||
<template>
|
||||
<nav
|
||||
class="flex flex-wrap items-center justify-between gap-3 px-2 text-sm text-gray-700 sm:px-5"
|
||||
class="flex flex-wrap items-center justify-between gap-3 px-2 text-sm text-gray-700 sm:px-1"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<!-- Mobile: Simple prev/next -->
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ import { router } from "@inertiajs/vue3";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { PlusIcon } from "@/Utilities/Icons";
|
||||
import { faUser, faMapMarkerAlt, faPhone, faEnvelope, faUniversity } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faUser,
|
||||
faMapMarkerAlt,
|
||||
faPhone,
|
||||
faEnvelope,
|
||||
faUniversity,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import PersonUpdateForm from "./PersonUpdateForm.vue";
|
||||
import AddressCreateForm from "./AddressCreateForm.vue";
|
||||
|
|
@ -24,6 +30,7 @@ import PersonInfoPhonesTab from "./PersonInfoPhonesTab.vue";
|
|||
import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue";
|
||||
import PersonInfoTrrTab from "./PersonInfoTrrTab.vue";
|
||||
import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
person: Object,
|
||||
|
|
@ -102,7 +109,7 @@ const closeDrawerAddAddress = () => {
|
|||
editAddress.value = false;
|
||||
editAddressId.value = 0;
|
||||
if (!wasEdit) {
|
||||
switchToTab('addresses');
|
||||
switchToTab("addresses");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -122,7 +129,7 @@ const closeDrawerAddPhone = () => {
|
|||
editPhone.value = false;
|
||||
editPhoneId.value = 0;
|
||||
if (!wasEdit) {
|
||||
switchToTab('phones');
|
||||
switchToTab("phones");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -139,7 +146,7 @@ const closeDrawerAddEmail = () => {
|
|||
editEmail.value = false;
|
||||
editEmailId.value = 0;
|
||||
if (!wasEdit) {
|
||||
switchToTab('emails');
|
||||
switchToTab("emails");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -156,7 +163,7 @@ const closeDrawerAddTrr = () => {
|
|||
editTrr.value = false;
|
||||
editTrrId.value = 0;
|
||||
if (!wasEdit) {
|
||||
switchToTab('trr');
|
||||
switchToTab("trr");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -181,35 +188,29 @@ const closeConfirm = () => {
|
|||
|
||||
const onConfirmDelete = async () => {
|
||||
const { type, id } = confirm.value;
|
||||
|
||||
|
||||
if (type === "email") {
|
||||
router.delete(
|
||||
route("person.email.delete", { person: props.person, email_id: id }),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
closeConfirm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
console.error("Delete failed", errors);
|
||||
closeConfirm();
|
||||
},
|
||||
}
|
||||
);
|
||||
router.delete(route("person.email.delete", { person: props.person, email_id: id }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
closeConfirm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
console.error("Delete failed", errors);
|
||||
closeConfirm();
|
||||
},
|
||||
});
|
||||
} else if (type === "trr") {
|
||||
router.delete(
|
||||
route("person.trr.delete", { person: props.person, trr_id: id }),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
closeConfirm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
console.error("Delete failed", errors);
|
||||
closeConfirm();
|
||||
},
|
||||
}
|
||||
);
|
||||
router.delete(route("person.trr.delete", { person: props.person, trr_id: id }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
closeConfirm();
|
||||
},
|
||||
onError: (errors) => {
|
||||
console.error("Delete failed", errors);
|
||||
closeConfirm();
|
||||
},
|
||||
});
|
||||
} else if (type === "address") {
|
||||
router.delete(
|
||||
route("person.address.delete", { person: props.person, address_id: id }),
|
||||
|
|
@ -223,21 +224,18 @@ const onConfirmDelete = async () => {
|
|||
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();
|
||||
},
|
||||
}
|
||||
);
|
||||
} 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -278,20 +276,22 @@ 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 || [];
|
||||
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);
|
||||
return count >= 999 ? "999+" : String(count);
|
||||
};
|
||||
|
||||
// Tab switching
|
||||
const activeTab = ref('person');
|
||||
const activeTab = ref("person");
|
||||
const switchToTab = (tab) => {
|
||||
activeTab.value = tab;
|
||||
};
|
||||
|
|
@ -300,66 +300,85 @@ const switchToTab = (tab) => {
|
|||
<template>
|
||||
<Tabs v-model="activeTab" 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">
|
||||
<TabsTrigger
|
||||
value="person"
|
||||
class="border border-gray-200 data-[state=active]:bg-primary-50 data-[state=active]:text-primary-700 flex-1 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faUser" class="h-4 w-4" />
|
||||
<span>Oseba</span>
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faMapMarkerAlt" class="h-4 w-4" />
|
||||
<span>Naslovi</span>
|
||||
</div>
|
||||
<span
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-5 min-w-5 flex items-center justify-center rounded-full px-1 font-mono tabular-nums text-sm bg-primary-50 text-primary-700"
|
||||
v-if="addressesCount > 0"
|
||||
class="h-5 min-w-5 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>
|
||||
</Badge>
|
||||
</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">
|
||||
<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">
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faPhone" class="h-4 w-4" />
|
||||
<span>Telefonske</span>
|
||||
</div>
|
||||
<span
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-5 min-w-5 flex items-center justify-center rounded-full px-1 font-mono tabular-nums text-sm bg-primary-50 text-primary-700"
|
||||
v-if="phonesCount > 0"
|
||||
class="h-5 min-w-5 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>
|
||||
</Badge>
|
||||
</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">
|
||||
<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">
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faEnvelope" class="h-4 w-4" />
|
||||
<span>Email</span>
|
||||
</div>
|
||||
<span
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-5 min-w-5 flex items-center justify-center rounded-full px-1 font-mono tabular-nums text-sm bg-primary-50 text-primary-700"
|
||||
v-if="emailsCount > 0"
|
||||
class="h-5 min-w-5 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>
|
||||
</Badge>
|
||||
</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">
|
||||
<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">
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon :icon="faUniversity" class="h-4 w-4" />
|
||||
<span>TRR</span>
|
||||
</div>
|
||||
<span
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-5 min-w-5 flex items-center justify-center rounded-full px-1 font-mono tabular-nums text-sm bg-primary-50 text-primary-700"
|
||||
v-if="trrsCount > 0"
|
||||
class="h-5 min-w-5 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>
|
||||
</Badge>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
|
|
|||
100
resources/js/Components/app/ui/AppCombobox.vue
Normal file
100
resources/js/Components/app/ui/AppCombobox.vue
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<script setup>
|
||||
import { CheckIcon, ChevronsUpDownIcon } from "lucide-vue-next";
|
||||
import { computed, ref } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/Components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: "",
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "Select item...",
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: "Search...",
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: "No item found.",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
buttonClass: {
|
||||
type: String,
|
||||
default: "w-[200px]",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const selectedItem = computed(() =>
|
||||
props.items.find((item) => item.value === props.modelValue)
|
||||
);
|
||||
|
||||
function selectItem(selectedValue) {
|
||||
const newValue = selectedValue === props.modelValue ? "" : selectedValue;
|
||||
emit("update:modelValue", newValue);
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:disabled="disabled"
|
||||
:class="cn('justify-between', buttonClass)"
|
||||
>
|
||||
{{ selectedItem?.label || placeholder }}
|
||||
<ChevronsUpDownIcon class="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-0" :class="buttonClass">
|
||||
<Command>
|
||||
<CommandInput class="h-9" :placeholder="searchPlaceholder" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ emptyText }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
@select="selectItem"
|
||||
>
|
||||
{{ item.label }}
|
||||
<CheckIcon
|
||||
:class="
|
||||
cn('ml-auto', modelValue === item.value ? 'opacity-100' : 'opacity-0')
|
||||
"
|
||||
/>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
173
resources/js/Components/app/ui/AppMultiSelect.vue
Normal file
173
resources/js/Components/app/ui/AppMultiSelect.vue
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/Components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "@/Components/ui/command";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Check, ChevronsUpDown, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
items: { type: Array, default: () => [] }, // [{ value, label }]
|
||||
placeholder: { type: String, default: "Izberi..." },
|
||||
searchPlaceholder: { type: String, default: "Išči..." },
|
||||
emptyText: { type: String, default: "Ni zadetkov." },
|
||||
disabled: { type: Boolean, default: false },
|
||||
max: { type: Number, default: null },
|
||||
clearable: { type: Boolean, default: true },
|
||||
contentClass: { type: String, default: "p-0 w-[300px]" },
|
||||
showSelectedChips: { type: Boolean, default: true },
|
||||
chipVariant: { type: String, default: "secondary" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const open = ref(false);
|
||||
const query = ref("");
|
||||
const internal = ref([...props.modelValue]);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (!Array.isArray(val)) return;
|
||||
internal.value = [...val];
|
||||
}
|
||||
);
|
||||
|
||||
const valueSet = computed(() => new Set(internal.value.map(String)));
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return props.items;
|
||||
return props.items.filter((i) => i.label.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function toggle(value) {
|
||||
if (props.disabled) return;
|
||||
const v = String(value);
|
||||
const set = new Set(internal.value.map(String));
|
||||
if (set.has(v)) {
|
||||
set.delete(v);
|
||||
} else {
|
||||
if (props.max && set.size >= props.max) return;
|
||||
set.add(v);
|
||||
}
|
||||
internal.value = Array.from(set);
|
||||
emit("update:modelValue", internal.value);
|
||||
// Clear search so full list remains visible after selection
|
||||
query.value = "";
|
||||
}
|
||||
|
||||
function removeChip(value) {
|
||||
const v = String(value);
|
||||
internal.value = internal.value.filter((x) => String(x) !== v);
|
||||
emit("update:modelValue", internal.value);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!props.clearable || props.disabled) return;
|
||||
internal.value = [];
|
||||
emit("update:modelValue", internal.value);
|
||||
}
|
||||
|
||||
const summaryText = computed(() => {
|
||||
if (internal.value.length === 0) return props.placeholder;
|
||||
if (!props.showSelectedChips) return `${internal.value.length} izbranih`;
|
||||
const labels = internal.value.map((v) => {
|
||||
const found = props.items.find((i) => String(i.value) === String(v));
|
||||
return found?.label || v;
|
||||
});
|
||||
if (labels.length <= 3) return labels.join(', ');
|
||||
const firstThree = labels.slice(0, 3).join(', ');
|
||||
const remaining = labels.length - 3;
|
||||
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:disabled="disabled"
|
||||
class="w-full justify-between gap-2"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
:class="{ 'text-muted-foreground': internal.length === 0 }"
|
||||
>
|
||||
{{ summaryText }}
|
||||
</span>
|
||||
<ChevronsUpDown class="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent :class="contentClass">
|
||||
<Command>
|
||||
<CommandInput v-model="query" :placeholder="searchPlaceholder" />
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty class="px-3 py-2 text-sm text-muted-foreground">{{
|
||||
emptyText
|
||||
}}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="item in filteredItems"
|
||||
:key="item.value"
|
||||
:value="String(item.value)"
|
||||
@select="() => toggle(item.value)"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Check
|
||||
class="h-4 w-4"
|
||||
:class="valueSet.has(String(item.value)) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
<div class="border-t p-2 flex items-center justify-between gap-2">
|
||||
<Button
|
||||
v-if="clearable"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="internal.length === 0 || disabled"
|
||||
@click="clearAll"
|
||||
>Počisti</Button
|
||||
>
|
||||
<Button size="sm" :disabled="disabled" @click="open = false">Zapri</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="showSelectedChips && internal.length"
|
||||
class="border-t p-2 flex flex-wrap gap-1"
|
||||
>
|
||||
<Badge
|
||||
v-for="val in internal"
|
||||
:key="val"
|
||||
:variant="chipVariant"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span class="truncate max-w-[140px]">
|
||||
{{ items.find((i) => String(i.value) === String(val))?.label || val }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground/80"
|
||||
@click.stop="removeChip(val)"
|
||||
:title="'Odstrani'"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
63
resources/js/Components/app/ui/AppPopover.vue
Normal file
63
resources/js/Components/app/ui/AppPopover.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
default: "center",
|
||||
validator: (value) => ["start", "center", "end"].includes(value),
|
||||
},
|
||||
side: {
|
||||
type: String,
|
||||
default: "bottom",
|
||||
validator: (value) => ["top", "right", "bottom", "left"].includes(value),
|
||||
},
|
||||
sideOffset: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
contentClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:open"]);
|
||||
|
||||
const internalOpen = ref(false);
|
||||
|
||||
const isControlled = props.open !== undefined;
|
||||
|
||||
function handleOpenChange(value) {
|
||||
if (isControlled) {
|
||||
emit("update:open", value);
|
||||
} else {
|
||||
internalOpen.value = value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover :open="isControlled ? open : internalOpen" @update:open="handleOpenChange">
|
||||
<PopoverTrigger as-child :disabled="disabled">
|
||||
<slot name="trigger" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
:align="align"
|
||||
:side="side"
|
||||
:side-offset="sideOffset"
|
||||
:class="contentClass"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
92
resources/js/Components/app/ui/card/AppCard.vue
Normal file
92
resources/js/Components/app/ui/card/AppCard.vue
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<script lang="ts" setup>
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/Components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { computed, HTMLAttributes } from "vue";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
loading?: boolean;
|
||||
padding?: "default" | "none" | "tight";
|
||||
hover?: boolean; // subtle hover style
|
||||
clickable?: boolean; // adds cursor + focus ring
|
||||
disabled?: boolean;
|
||||
class?: HTMLAttributes["class"];
|
||||
headerClass?: HTMLAttributes["class"];
|
||||
bodyClass?: HTMLAttributes["class"];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// Emit click for consumers if clickable
|
||||
const emit = defineEmits<{ (e: "click", ev: MouseEvent): void }>();
|
||||
|
||||
const wrapperClasses = computed(() => {
|
||||
const base = "relative transition-colors";
|
||||
const hover = props.hover ? "hover:bg-muted/50" : "";
|
||||
const clickable =
|
||||
props.clickable && !props.disabled
|
||||
? "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
: "";
|
||||
const disabled = props.disabled ? "opacity-60 pointer-events-none" : "";
|
||||
return [base, hover, clickable, disabled].filter(Boolean).join(" ");
|
||||
});
|
||||
|
||||
const paddingClasses = computed(() => {
|
||||
switch (props.padding) {
|
||||
case "none":
|
||||
return "p-0";
|
||||
case "tight":
|
||||
return "p-3 sm:p-4";
|
||||
default:
|
||||
return "p-4 sm:p-6";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
:class="cn(wrapperClasses, props.class)"
|
||||
@click="props.clickable && emit('click', $event)"
|
||||
>
|
||||
<!-- Header Slot / Fallback -->
|
||||
<CardHeader
|
||||
v-if="title || description || $slots.header"
|
||||
:class="cn('space-y-1', headerClass)"
|
||||
>
|
||||
<template v-if="$slots.header">
|
||||
<slot name="header" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<CardTitle v-if="title">{{ title }}</CardTitle>
|
||||
<CardDescription v-if="description">{{ description }}</CardDescription>
|
||||
</template>
|
||||
</CardHeader>
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div v-if="loading" class="animate-pulse space-y-3 px-4 py-4">
|
||||
<div class="h-4 w-1/3 rounded bg-muted" />
|
||||
<div class="h-3 w-1/2 rounded bg-muted" />
|
||||
<div class="h-32 rounded bg-muted" />
|
||||
</div>
|
||||
|
||||
<!-- Content Slot -->
|
||||
<CardContent v-else :class="cn(paddingClasses, bodyClass)">
|
||||
<slot />
|
||||
</CardContent>
|
||||
|
||||
<!-- Footer Slot -->
|
||||
<CardFooter v-if="$slots.footer" class="border-t px-4 py-3 sm:px-6">
|
||||
<slot name="footer" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
<style></style>
|
||||
35
resources/js/Components/app/ui/charts/AppChartDisplay.vue
Normal file
35
resources/js/Components/app/ui/charts/AppChartDisplay.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import AppChartToolbar from "./AppChartToolbar.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group relative flex flex-col overflow-hidden rounded-xl border transition-all duration-200 ease-in-out hover:z-30',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<AppChartToolbar
|
||||
:name
|
||||
class="bg-card text-card-foreground relative z-20 flex justify-end border-b px-3 py-2.5"
|
||||
>
|
||||
<slot />
|
||||
</AppChartToolbar>
|
||||
<div
|
||||
class="relative z-10 [&>div]:rounded-none [&>div]:border-none [&>div]:shadow-none"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
57
resources/js/Components/app/ui/charts/AppChartToolbar.vue
Normal file
57
resources/js/Components/app/ui/charts/AppChartToolbar.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts" setup>
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
import TooltipProvider from "@/Components/ui/tooltip/TooltipProvider.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AreaChartIcon,
|
||||
BarChartBigIcon,
|
||||
HexagonIcon,
|
||||
LineChartIcon,
|
||||
MousePointer2Icon,
|
||||
PieChartIcon,
|
||||
RadarIcon,
|
||||
} from "lucide-vue-next";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
//code: string;
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-2', props.class)">
|
||||
<div
|
||||
class="text-muted-foreground flex items-center gap-1.5 pl-1 text-[13px] [&>svg]:h-[0.9rem] [&>svg]:w-[0.9rem]"
|
||||
>
|
||||
<template v-if="name.includes('ChartLine')">
|
||||
<LineChartIcon /> Line Chart
|
||||
</template>
|
||||
<template v-else-if="name.includes('ChartBar')">
|
||||
<BarChartBigIcon /> Bar Chart
|
||||
</template>
|
||||
<template v-else-if="name.includes('ChartPie')">
|
||||
<PieChartIcon /> Pie Chart
|
||||
</template>
|
||||
<template v-else-if="name.includes('ChartArea')">
|
||||
<AreaChartIcon /> Area Chart
|
||||
</template>
|
||||
<template v-else-if="name.includes('ChartRadar')">
|
||||
<HexagonIcon /> Radar Chart
|
||||
</template>
|
||||
<template v-else-if="name.includes('ChartRadial')">
|
||||
<RadarIcon /> Radial Chart
|
||||
</template>
|
||||
<template v-else-if="name.includes('ChartTooltip')">
|
||||
<MousePointer2Icon /> Tooltip
|
||||
</template>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2 [&>form]:flex">
|
||||
<Separator orientation="vertical" class="mx-0 hidden h-4! md:flex" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
|
||||
const props = defineProps({
|
||||
orientation: { type: String, required: false, default: "vertical" },
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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 cursor-pointer",
|
||||
"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: {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ const props = defineProps({
|
|||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn('rounded-xl border bg-card text-card-foreground shadow', props.class)
|
||||
cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
|
|
|||
21
resources/js/Components/ui/card/CardAction.vue
Normal file
21
resources/js/Components/ui/card/CardAction.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-action"
|
||||
:class="
|
||||
cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -7,7 +7,7 @@ const props = defineProps({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('p-6 pt-0', props.class)">
|
||||
<div data-slot="card-content" :class="cn('px-6', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ const props = defineProps({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<p :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<p
|
||||
data-slot="card-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ const props = defineProps({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center p-6 pt-0', props.class)">
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,15 @@ const props = defineProps({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
|
||||
<div
|
||||
data-slot="card-header"
|
||||
:class="
|
||||
cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ const props = defineProps({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<h3 :class="cn('font-semibold leading-none tracking-tight', props.class)">
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
:class="cn('leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export { default as Card } from "./Card.vue";
|
||||
export { default as CardAction } from "./CardAction.vue";
|
||||
export { default as CardContent } from "./CardContent.vue";
|
||||
export { default as CardDescription } from "./CardDescription.vue";
|
||||
export { default as CardFooter } from "./CardFooter.vue";
|
||||
|
|
|
|||
80
resources/js/Components/ui/chart/ChartAutoLegend.vue
Normal file
80
resources/js/Components/ui/chart/ChartAutoLegend.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useChartContext } from './interface'
|
||||
|
||||
const props = defineProps({
|
||||
order: { type: Array, required: false }, // explicit ordering of keys
|
||||
modelValue: { type: Array, required: false }, // deprecated alias
|
||||
activeKeys: { type: Array, required: false }, // v-model:activeKeys target
|
||||
class: { type: [String, Array, Object], required: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:activeKeys'])
|
||||
|
||||
const { config } = useChartContext()
|
||||
|
||||
// Derive ordered keys from config
|
||||
const allKeys = computed(() => {
|
||||
const keys = Object.keys(config)
|
||||
if (props.order && props.order.length) {
|
||||
return props.order.filter(k => keys.includes(k))
|
||||
}
|
||||
return keys
|
||||
})
|
||||
|
||||
// Internal active state (if parent not controlling)
|
||||
const internalActive = ref(allKeys.value.reduce((acc, k) => { acc[k] = true; return acc }, {}))
|
||||
|
||||
const activeMap = computed(() => {
|
||||
// If parent passes controlled array use that
|
||||
if (props.activeKeys && props.activeKeys.length) {
|
||||
return props.activeKeys.reduce((acc, k) => { acc[k] = true; return acc }, {})
|
||||
}
|
||||
return internalActive.value
|
||||
})
|
||||
|
||||
const items = computed(() => allKeys.value.map(k => {
|
||||
const series = config[k] || {}
|
||||
const color = series.color || (series.theme && (series.theme.light || series.theme.dark)) || 'var(--foreground)'
|
||||
return { key: k, label: series.label || k, color, active: !!activeMap.value[k] }
|
||||
}))
|
||||
|
||||
function toggle(key) {
|
||||
// controlled mode
|
||||
if (props.activeKeys) {
|
||||
const next = items.value.filter(i => i.key === key ? !i.active : i.active).map(i => i.key)
|
||||
// If item was active we remove it, else add it
|
||||
const wasActive = activeMap.value[key]
|
||||
const result = wasActive
|
||||
? props.activeKeys.filter(k => k !== key)
|
||||
: [...props.activeKeys, key]
|
||||
emit('update:activeKeys', result)
|
||||
return
|
||||
}
|
||||
// uncontrolled mode
|
||||
internalActive.value[key] = !internalActive.value[key]
|
||||
const result = Object.entries(internalActive.value).filter(([, v]) => v).map(([k]) => k)
|
||||
emit('update:activeKeys', result)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['flex items-center justify-center flex-wrap gap-4 text-xs select-none', props.class]">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
:class="[
|
||||
'flex items-center gap-2 transition-colors',
|
||||
item.active ? 'opacity-100' : 'opacity-40',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm'
|
||||
]"
|
||||
@click="toggle(item.key)"
|
||||
>
|
||||
<span class="h-2.5 w-2.5 rounded-[3px] border border-border" :style="{ background: item.color }" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
32
resources/js/Components/ui/chart/ChartContainer.vue
Normal file
32
resources/js/Components/ui/chart/ChartContainer.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { provideChartContext } from './interface';
|
||||
|
||||
const props = defineProps({
|
||||
config: { type: Object, required: false, default: () => ({}) },
|
||||
class: { type: [String, Array, Object], required: false },
|
||||
cursor: { type: Boolean, default: true },
|
||||
id: { type: String, required: false },
|
||||
});
|
||||
|
||||
// Provide context (even if empty) so descendants can attempt to read series config.
|
||||
const ctx = provideChartContext(props.config, props.id);
|
||||
const chartDomId = computed(() => `chart-${ctx.id}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-chart-container
|
||||
:data-chart="chartDomId"
|
||||
:class="['relative w-full flex flex-col', props.class]"
|
||||
:style="{
|
||||
// Default color variables; series components can override via inline style or CSS theme logic.
|
||||
'--vis-primary-color': 'hsl(var(--primary))',
|
||||
'--vis-secondary-color': 'hsl(var(--secondary))',
|
||||
'--vis-crosshair-line-stroke-width': props.cursor ? '1px' : '0px',
|
||||
'--vis-font-family': 'var(--font-sans)',
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
54
resources/js/Components/ui/chart/ChartCrosshair.vue
Normal file
54
resources/js/Components/ui/chart/ChartCrosshair.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script setup>
|
||||
import { omit } from "@unovis/ts";
|
||||
import { VisCrosshair, VisTooltip } from "@unovis/vue";
|
||||
import { createApp } from "vue";
|
||||
import { ChartTooltip } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
colors: { type: Array, required: false, default: () => [] },
|
||||
index: { type: String, required: true },
|
||||
// items now optional when using external template factory
|
||||
items: { type: Array, required: false, default: () => [] },
|
||||
customTooltip: { type: null, required: false },
|
||||
labelFormatter: { type: Function, required: false },
|
||||
// template override (e.g., componentToString(...))
|
||||
template: { type: Function, required: false },
|
||||
});
|
||||
|
||||
// Use weakmap to store reference to each datapoint for Tooltip
|
||||
const wm = new WeakMap();
|
||||
function internalTemplate(d) {
|
||||
// If we have cached markup and no custom formatter altering title, reuse.
|
||||
if (wm.has(d) && !props.labelFormatter && !props.template) {
|
||||
return wm.get(d);
|
||||
}
|
||||
// If external template provided, delegate directly
|
||||
if (props.template) {
|
||||
const html = props.template(d, d[props.index]);
|
||||
wm.set(d, html);
|
||||
return html;
|
||||
}
|
||||
const componentDiv = document.createElement("div");
|
||||
const omittedData = Object.entries(omit(d, [props.index])).map(([key, value]) => {
|
||||
const legendReference = props.items.find((i) => i.name === key);
|
||||
return { ...legendReference, value };
|
||||
});
|
||||
const TooltipComponent = props.customTooltip ?? ChartTooltip;
|
||||
createApp(TooltipComponent, {
|
||||
title: d[props.index],
|
||||
data: omittedData,
|
||||
labelFormatter: props.labelFormatter,
|
||||
}).mount(componentDiv);
|
||||
wm.set(d, componentDiv.innerHTML);
|
||||
return componentDiv.innerHTML;
|
||||
}
|
||||
|
||||
function color(d, i) {
|
||||
return props.colors[i] ?? "transparent";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisTooltip :horizontal-shift="20" :vertical-shift="20" />
|
||||
<VisCrosshair :template="internalTemplate" :color="color" />
|
||||
</template>
|
||||
66
resources/js/Components/ui/chart/ChartLegend.vue
Normal file
66
resources/js/Components/ui/chart/ChartLegend.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<script setup>
|
||||
import { BulletLegend } from "@unovis/ts";
|
||||
import { VisBulletLegend } from "@unovis/vue";
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, required: true, default: () => [] },
|
||||
});
|
||||
|
||||
const emits = defineEmits(["legendItemClick", "update:items"]);
|
||||
|
||||
const elRef = ref();
|
||||
|
||||
function keepStyling() {
|
||||
const selector = `.${BulletLegend.selectors.item}`;
|
||||
nextTick(() => {
|
||||
const elements = elRef.value?.querySelectorAll(selector);
|
||||
const classes = buttonVariants({ variant: "ghost", size: "xs" }).split(" ");
|
||||
|
||||
elements?.forEach((el) =>
|
||||
el.classList.add(...classes, "!inline-flex", "!mr-2"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
keepStyling();
|
||||
});
|
||||
|
||||
function onLegendItemClick(d, i) {
|
||||
emits("legendItemClick", d, i);
|
||||
const isBulletActive = !props.items[i].inactive;
|
||||
const isFilterApplied = props.items.some((i) => i.inactive);
|
||||
if (isFilterApplied && isBulletActive) {
|
||||
// reset filter
|
||||
emits(
|
||||
"update:items",
|
||||
props.items.map((item) => ({ ...item, inactive: false })),
|
||||
);
|
||||
} else {
|
||||
// apply selection, set other item as inactive
|
||||
emits(
|
||||
"update:items",
|
||||
props.items.map((item) =>
|
||||
item.name === d.name
|
||||
? { ...d, inactive: false }
|
||||
: { ...item, inactive: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
keepStyling();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="elRef"
|
||||
class="w-max"
|
||||
:style="{
|
||||
'--vis-legend-bullet-size': '16px',
|
||||
}"
|
||||
>
|
||||
<VisBulletLegend :items="items" :on-legend-item-click="onLegendItemClick" />
|
||||
</div>
|
||||
</template>
|
||||
73
resources/js/Components/ui/chart/ChartSingleTooltip.vue
Normal file
73
resources/js/Components/ui/chart/ChartSingleTooltip.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<script setup>
|
||||
import { omit } from "@unovis/ts";
|
||||
import { VisTooltip } from "@unovis/vue";
|
||||
import { createApp } from "vue";
|
||||
import { ChartTooltip } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
selector: { type: String, required: true },
|
||||
index: { type: String, required: true },
|
||||
items: { type: Array, required: false },
|
||||
valueFormatter: { type: Function, required: false },
|
||||
customTooltip: { type: null, required: false },
|
||||
});
|
||||
|
||||
// Use weakmap to store reference to each datapoint for Tooltip
|
||||
const wm = new WeakMap();
|
||||
function template(d, i, elements) {
|
||||
const valueFormatter = props.valueFormatter ?? ((tick) => `${tick}`);
|
||||
if (props.index in d) {
|
||||
if (wm.has(d)) {
|
||||
return wm.get(d);
|
||||
} else {
|
||||
const componentDiv = document.createElement("div");
|
||||
const omittedData = Object.entries(omit(d, [props.index])).map(
|
||||
([key, value]) => {
|
||||
const legendReference = props.items?.find((i) => i.name === key);
|
||||
return { ...legendReference, value: valueFormatter(value) };
|
||||
},
|
||||
);
|
||||
const TooltipComponent = props.customTooltip ?? ChartTooltip;
|
||||
createApp(TooltipComponent, {
|
||||
title: d[props.index],
|
||||
data: omittedData,
|
||||
}).mount(componentDiv);
|
||||
wm.set(d, componentDiv.innerHTML);
|
||||
return componentDiv.innerHTML;
|
||||
}
|
||||
} else {
|
||||
const data = d.data;
|
||||
|
||||
if (wm.has(data)) {
|
||||
return wm.get(data);
|
||||
} else {
|
||||
const style = getComputedStyle(elements[i]);
|
||||
const omittedData = [
|
||||
{
|
||||
name: data.name,
|
||||
value: valueFormatter(data[props.index]),
|
||||
color: style.fill,
|
||||
},
|
||||
];
|
||||
const componentDiv = document.createElement("div");
|
||||
const TooltipComponent = props.customTooltip ?? ChartTooltip;
|
||||
createApp(TooltipComponent, {
|
||||
title: d[props.index],
|
||||
data: omittedData,
|
||||
}).mount(componentDiv);
|
||||
wm.set(d, componentDiv.innerHTML);
|
||||
return componentDiv.innerHTML;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisTooltip
|
||||
:horizontal-shift="20"
|
||||
:vertical-shift="20"
|
||||
:triggers="{
|
||||
[selector]: template,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
41
resources/js/Components/ui/chart/ChartTooltip.vue
Normal file
41
resources/js/Components/ui/chart/ChartTooltip.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<script setup>
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/Components/ui/card';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: false },
|
||||
data: { type: Array, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="text-sm">
|
||||
<CardHeader v-if="title" class="p-3 border-b">
|
||||
<CardTitle>
|
||||
{{ title }}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="p-3 min-w-[180px] flex flex-col gap-1">
|
||||
<div v-for="(item, key) in data" :key="key" class="flex justify-between">
|
||||
<div class="flex items-center">
|
||||
<span class="w-2.5 h-2.5 mr-2">
|
||||
<svg width="100%" height="100%" viewBox="0 0 30 30">
|
||||
<path
|
||||
d=" M 15 15 m -14, 0 a 14,14 0 1,1 28,0 a 14,14 0 1,1 -28,0"
|
||||
:stroke="item.color"
|
||||
:fill="item.color"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
<span class="font-semibold ml-4">{{ item.value }}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
85
resources/js/Components/ui/chart/ChartTooltipContent.vue
Normal file
85
resources/js/Components/ui/chart/ChartTooltipContent.vue
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<script setup>
|
||||
// Advanced tooltip component (original implementation) inspired by external patterns.
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
hideLabel: { type: Boolean, default: false },
|
||||
hideIndicator: { type: Boolean, default: false },
|
||||
indicator: { type: String, default: 'dot' }, // 'dot' | 'line' | 'dashed'
|
||||
nameKey: { type: String, required: false },
|
||||
labelKey: { type: String, required: false },
|
||||
labelFormatter: { type: Function, required: false },
|
||||
payload: { type: Object, required: false, default: () => ({}) },
|
||||
config: { type: Object, required: false, default: () => ({}) },
|
||||
class: { type: [String, Array, Object], required: false },
|
||||
color: { type: String, required: false },
|
||||
x: { type: [Number, Date, String], required: false },
|
||||
});
|
||||
|
||||
// Build array of entries referencing config for label & color
|
||||
const entries = computed(() => {
|
||||
return Object.entries(props.payload)
|
||||
.map(([key, value]) => {
|
||||
const seriesKey = props.nameKey || key;
|
||||
const itemConfig = props.config[seriesKey] || props.config[key] || {};
|
||||
const indicatorColor = itemConfig.color || props.color;
|
||||
return { key, value, itemConfig, indicatorColor };
|
||||
})
|
||||
.filter(e => e.itemConfig && (e.itemConfig.label || e.value !== undefined));
|
||||
});
|
||||
|
||||
const singleSeries = computed(() => entries.value.length === 1 && props.indicator !== 'dot');
|
||||
|
||||
const formattedLabel = computed(() => {
|
||||
if (props.hideLabel) return null;
|
||||
if (props.labelFormatter && props.x !== undefined) {
|
||||
return props.labelFormatter(props.x);
|
||||
}
|
||||
if (props.labelKey) {
|
||||
const cfg = props.config[props.labelKey];
|
||||
return cfg?.label || props.payload[props.labelKey];
|
||||
}
|
||||
return props.x instanceof Date ? props.x.toLocaleDateString() : props.x;
|
||||
});
|
||||
|
||||
function formatValue(v) {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'number') return v.toLocaleString();
|
||||
return v;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['border border-border/50 bg-background min-w-32 rounded-lg px-2.5 py-1.5 text-xs shadow-xl', props.class]">
|
||||
<div v-if="!singleSeries && formattedLabel" class="font-medium mb-1">{{ formattedLabel }}</div>
|
||||
<div class="grid gap-1.5">
|
||||
<div
|
||||
v-for="{ key, value, itemConfig, indicatorColor } in entries"
|
||||
:key="key"
|
||||
class="flex w-full flex-wrap items-stretch gap-2"
|
||||
:class="indicator === 'dot' ? 'items-center' : 'items-start'"
|
||||
>
|
||||
<!-- Indicator -->
|
||||
<template v-if="!hideIndicator">
|
||||
<div
|
||||
:class="[
|
||||
'shrink-0 rounded-[2px] border-border',
|
||||
indicator === 'dot' && 'h-2.5 w-2.5',
|
||||
indicator === 'line' && 'w-1 h-4',
|
||||
indicator === 'dashed' && 'w-0 h-4 border-[1.5px] border-dashed bg-transparent',
|
||||
singleSeries && indicator === 'dashed' && 'my-0.5'
|
||||
]"
|
||||
:style="{ '--color-bg': indicatorColor, '--color-border': indicatorColor }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div :class="['flex flex-1 justify-between leading-none', singleSeries ? 'items-end' : 'items-center']">
|
||||
<div class="grid gap-1.5">
|
||||
<div v-if="singleSeries && formattedLabel" class="font-medium">{{ formattedLabel }}</div>
|
||||
<span class="text-muted-foreground">{{ itemConfig.label || formatValue(value) }}</span>
|
||||
</div>
|
||||
<span v-if="value !== undefined" class="font-mono font-medium tabular-nums">{{ formatValue(value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
38
resources/js/Components/ui/chart/componentToString.js
Normal file
38
resources/js/Components/ui/chart/componentToString.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { createApp, h } from 'vue'
|
||||
|
||||
// Simple cache map to avoid re-rendering identical payloads.
|
||||
const _cache = new Map()
|
||||
|
||||
function serializeKey(obj) {
|
||||
try {
|
||||
return JSON.stringify(obj, Object.keys(obj).sort())
|
||||
} catch (e) {
|
||||
return Math.random().toString(36)
|
||||
}
|
||||
}
|
||||
|
||||
// Factory returning template function for Unovis Crosshair.
|
||||
// config: chart series configuration
|
||||
// Component: Vue component to render
|
||||
// extraProps: static props (e.g. labelKey, labelFormatter)
|
||||
export function componentToString(config, Component, extraProps = {}) {
|
||||
return function (_data, x) {
|
||||
const row = _data && _data.data ? _data.data : _data
|
||||
// Build series-only payload (exclude non-config fields like date/dateLabel)
|
||||
const seriesPayload = {}
|
||||
Object.keys(config).forEach(k => {
|
||||
if (row && row[k] !== undefined) seriesPayload[k] = row[k]
|
||||
})
|
||||
const cacheKeyBase = { ...seriesPayload, __x: x }
|
||||
const key = serializeKey(cacheKeyBase)
|
||||
if (_cache.has(key)) return _cache.get(key)
|
||||
|
||||
const el = document.createElement('div')
|
||||
const app = createApp(Component, { ...extraProps, payload: seriesPayload, config, x: row?.date ?? x })
|
||||
app.mount(el)
|
||||
const html = el.innerHTML
|
||||
app.unmount()
|
||||
_cache.set(key, html)
|
||||
return html
|
||||
}
|
||||
}
|
||||
28
resources/js/Components/ui/chart/index.js
Normal file
28
resources/js/Components/ui/chart/index.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export { default as ChartCrosshair } from "./ChartCrosshair.vue";
|
||||
export { default as ChartLegend } from "./ChartLegend.vue";
|
||||
export { default as ChartSingleTooltip } from "./ChartSingleTooltip.vue";
|
||||
export { default as ChartTooltip } from "./ChartTooltip.vue";
|
||||
export { default as ChartContainer } from "./ChartContainer.vue";
|
||||
export { default as ChartTooltipContent } from "./ChartTooltipContent.vue";
|
||||
export { componentToString } from "./componentToString";
|
||||
export { provideChartContext, useChartContext } from "./interface";
|
||||
export { default as ChartAutoLegend } from "./ChartAutoLegend.vue";
|
||||
|
||||
export function defaultColors(count = 3) {
|
||||
const quotient = Math.floor(count / 2);
|
||||
const remainder = count % 2;
|
||||
|
||||
const primaryCount = quotient + remainder;
|
||||
const secondaryCount = quotient;
|
||||
return [
|
||||
...Array.from(new Array(primaryCount).keys()).map(
|
||||
(i) => `hsl(var(--vis-primary-color) / ${1 - (1 / primaryCount) * i})`,
|
||||
),
|
||||
...Array.from(new Array(secondaryCount).keys()).map(
|
||||
(i) =>
|
||||
`hsl(var(--vis-secondary-color) / ${1 - (1 / secondaryCount) * i})`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export * from "./interface";
|
||||
49
resources/js/Components/ui/chart/interface.js
Normal file
49
resources/js/Components/ui/chart/interface.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Chart interface and context helpers
|
||||
// This is a fresh, original implementation inspired conceptually by patterns
|
||||
// observed in external registries. No code copied.
|
||||
|
||||
import { inject, provide, reactive } from 'vue';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ChartSeriesConfig
|
||||
* @property {string|import('vue').Component} [label] Display label or component
|
||||
* @property {import('vue').Component} [icon] Optional icon component
|
||||
* @property {string} [color] Static CSS color value (e.g. 'var(--chart-1)')
|
||||
* @property {Object} [theme] Optional theme map: { light: string, dark: string }
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object.<string, ChartSeriesConfig>} ChartConfig
|
||||
* Keys are series identifiers. Each value declares label/icon and either a
|
||||
* static color or a theme object with light/dark variants.
|
||||
*/
|
||||
|
||||
const ChartContextSymbol = Symbol('ChartContext');
|
||||
let _idCounter = 0;
|
||||
|
||||
/**
|
||||
* Provide chart context for descendants.
|
||||
* @param {ChartConfig} config Reactive or plain config object.
|
||||
* @param {string} [explicitId] Optional id override.
|
||||
* @returns {{ id: string, config: ChartConfig }}
|
||||
*/
|
||||
export function provideChartContext(config, explicitId) {
|
||||
const id = explicitId || `c${Date.now().toString(36)}${(++_idCounter).toString(36)}`;
|
||||
const ctx = { id, config: reactive(config) };
|
||||
provide(ChartContextSymbol, ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject previously provided chart context.
|
||||
* @returns {{ id: string, config: ChartConfig }}
|
||||
*/
|
||||
export function useChartContext() {
|
||||
const ctx = inject(ChartContextSymbol, null);
|
||||
if (!ctx) {
|
||||
throw new Error('useChartContext() called without a provider. Wrap in <ChartContainer>.');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export {}; // preserve module boundaries
|
||||
24
resources/js/Components/ui/item/Item.vue
Normal file
24
resources/js/Components/ui/item/Item.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script setup>
|
||||
import { Primitive } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { itemVariants } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false, default: "div" },
|
||||
class: { type: null, required: false },
|
||||
variant: { type: null, required: false },
|
||||
size: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="item"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(itemVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
16
resources/js/Components/ui/item/ItemActions.vue
Normal file
16
resources/js/Components/ui/item/ItemActions.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
:class="cn('flex items-center gap-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
resources/js/Components/ui/item/ItemContent.vue
Normal file
21
resources/js/Components/ui/item/ItemContent.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="item-content"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
22
resources/js/Components/ui/item/ItemDescription.vue
Normal file
22
resources/js/Components/ui/item/ItemDescription.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="item-description"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
18
resources/js/Components/ui/item/ItemFooter.vue
Normal file
18
resources/js/Components/ui/item/ItemFooter.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
:class="
|
||||
cn('flex basis-full items-center justify-between gap-2', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
resources/js/Components/ui/item/ItemGroup.vue
Normal file
17
resources/js/Components/ui/item/ItemGroup.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
:class="cn('group/item-group flex flex-col', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
18
resources/js/Components/ui/item/ItemHeader.vue
Normal file
18
resources/js/Components/ui/item/ItemHeader.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="item-header"
|
||||
:class="
|
||||
cn('flex basis-full items-center justify-between gap-2', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
19
resources/js/Components/ui/item/ItemMedia.vue
Normal file
19
resources/js/Components/ui/item/ItemMedia.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { itemMediaVariants } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
variant: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="item-media"
|
||||
:data-variant="props.variant"
|
||||
:class="cn(itemMediaVariants({ variant }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
resources/js/Components/ui/item/ItemSeparator.vue
Normal file
20
resources/js/Components/ui/item/ItemSeparator.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
|
||||
const props = defineProps({
|
||||
orientation: { type: String, required: false },
|
||||
decorative: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
:class="cn('my-0', props.class)"
|
||||
/>
|
||||
</template>
|
||||
21
resources/js/Components/ui/item/ItemTitle.vue
Normal file
21
resources/js/Components/ui/item/ItemTitle.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="item-title"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
50
resources/js/Components/ui/item/index.js
Normal file
50
resources/js/Components/ui/item/index.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { cva } from "class-variance-authority";
|
||||
|
||||
export { default as Item } from "./Item.vue";
|
||||
export { default as ItemActions } from "./ItemActions.vue";
|
||||
export { default as ItemContent } from "./ItemContent.vue";
|
||||
export { default as ItemDescription } from "./ItemDescription.vue";
|
||||
export { default as ItemFooter } from "./ItemFooter.vue";
|
||||
export { default as ItemGroup } from "./ItemGroup.vue";
|
||||
export { default as ItemHeader } from "./ItemHeader.vue";
|
||||
export { default as ItemMedia } from "./ItemMedia.vue";
|
||||
export { default as ItemSeparator } from "./ItemSeparator.vue";
|
||||
export { default as ItemTitle } from "./ItemTitle.vue";
|
||||
|
||||
export const itemVariants = cva(
|
||||
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-1",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border-border",
|
||||
muted: "bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "p-4 gap-4 ",
|
||||
sm: "py-3 px-4 gap-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
30
resources/js/Components/ui/scroll-area/ScrollArea.vue
Normal file
30
resources/js/Components/ui/scroll-area/ScrollArea.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaViewport } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ScrollBar from "./ScrollBar.vue";
|
||||
|
||||
const props = defineProps({
|
||||
type: { type: String, required: false },
|
||||
dir: { type: String, required: false },
|
||||
scrollHideDelay: { type: Number, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaRoot
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('relative overflow-hidden', props.class)"
|
||||
>
|
||||
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
||||
33
resources/js/Components/ui/scroll-area/ScrollBar.vue
Normal file
33
resources/js/Components/ui/scroll-area/ScrollBar.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ScrollAreaScrollbar, ScrollAreaThumb } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
orientation: { type: String, required: false, default: "vertical" },
|
||||
forceMount: { 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>
|
||||
<ScrollAreaScrollbar
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent p-px',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent p-px',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaScrollbar>
|
||||
</template>
|
||||
2
resources/js/Components/ui/scroll-area/index.js
Normal file
2
resources/js/Components/ui/scroll-area/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ScrollArea } from "./ScrollArea.vue";
|
||||
export { default as ScrollBar } from "./ScrollBar.vue";
|
||||
|
|
@ -471,7 +471,7 @@ const availableSegmentsCount = computed(() => {
|
|||
>
|
||||
<!-- Toolbar Actions -->
|
||||
<template #toolbar-actions v-if="edit">
|
||||
<Button variant="outline" @click="onCreate"> Nova </Button>
|
||||
<Button variant="outline" @click="onCreate">Nova pogodba</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="onAttachSegment"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import SectionTitle from "@/Components/SectionTitle.vue";
|
|||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { onBeforeMount, ref, computed } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import ContractDrawer from "./Partials/ContractDrawer.vue";
|
||||
import ContractTable from "./Partials/ContractTable.vue";
|
||||
import ActivityDrawer from "./Partials/ActivityDrawer.vue";
|
||||
|
|
@ -15,15 +15,10 @@ import DocumentUploadDialog from "@/Components/DocumentsTable/DocumentUploadDial
|
|||
import DocumentViewerDialog from "@/Components/DocumentsTable/DocumentViewerDialog.vue";
|
||||
import { classifyDocument } from "@/Services/documents";
|
||||
import { router, useForm, usePage } from "@inertiajs/vue3";
|
||||
import { AngleDownIcon, AngleUpIcon } from "@/Utilities/Icons";
|
||||
import Pagination from "@/Components/Pagination.vue";
|
||||
import DeleteDialog from "@/Components/Dialogs/DeleteDialog.vue";
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { DropdownMenu } from "@/Components/ui/dropdown-menu";
|
||||
import DropdownMenuContent from "@/Components/ui/dropdown-menu/DropdownMenuContent.vue";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -246,7 +241,7 @@ const submitAttachSegment = () => {
|
|||
<template>
|
||||
<AppLayout title="Client case">
|
||||
<template #header></template>
|
||||
<div class="pt-12">
|
||||
<div class="pt-6">
|
||||
<!-- Client details -->
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Current segment badge (right aligned, above the card) -->
|
||||
|
|
@ -260,8 +255,8 @@ const submitAttachSegment = () => {
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Card class="border-l-4 border-blue-500">
|
||||
<div class="mx-auto max-w-4x1 p-3 flex justify-between items-center">
|
||||
<Card class="border-l-4 border-blue-400">
|
||||
<div class="p-3 flex justify-between items-center">
|
||||
<SectionTitle>
|
||||
<template #title>
|
||||
<a class="hover:text-blue-500" :href="route('client.show', client)">
|
||||
|
|
@ -269,16 +264,14 @@ const submitAttachSegment = () => {
|
|||
</a>
|
||||
</template>
|
||||
</SectionTitle>
|
||||
<Badge variant="secondary" class="bg-blue-500 text-white dark:bg-blue-600">
|
||||
Naročnik
|
||||
</Badge>
|
||||
<Badge class="bg-blue-500 text-white"> Naročnik </Badge>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-1" :hidden="clientDetails">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<Card class="border-l-4 border-blue-400">
|
||||
<Card>
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<PersonInfoGrid
|
||||
:types="types"
|
||||
|
|
@ -316,7 +309,7 @@ const submitAttachSegment = () => {
|
|||
</div>
|
||||
<div class="pt-1">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<Card class="border-l-4 border-red-400">
|
||||
<Card>
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<PersonInfoGrid
|
||||
:types="types"
|
||||
|
|
|
|||
|
|
@ -1,21 +1,30 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { ref } from "vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import DataTable from "@/Components/DataTable/DataTable.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { Link, router, usePage } from "@inertiajs/vue3";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
} from "@/Components/ui/select"; // kept in case elsewhere but segment filter replaced
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import PersonInfoGrid from "@/Components/PersonInfo/PersonInfoGrid.vue";
|
||||
import SectionTitle from "@/Components/SectionTitle.vue";
|
||||
import DateRangePicker from "@/Components/DateRangePicker.vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { ButtonGroup } from "@/Components/ui/button-group";
|
||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
import { Filter, LinkIcon } from "lucide-vue-next";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
|
|
@ -25,14 +34,29 @@ const props = defineProps({
|
|||
types: Object,
|
||||
});
|
||||
|
||||
const page = usePage();
|
||||
// Expose as a callable computed: use in templates as hasPerm('permission-slug')
|
||||
const hasPerm = computed(() => (permission) =>
|
||||
hasPermission(page.props.auth?.user, permission)
|
||||
);
|
||||
|
||||
const dateRange = ref({
|
||||
start: props.filters?.from || null,
|
||||
end: props.filters?.to || null,
|
||||
});
|
||||
const search = ref(props.filters?.search || "");
|
||||
const selectedSegment = ref(props.filters?.segment || null);
|
||||
// Multi-segment selection (backwards compatible if legacy single 'segment' present)
|
||||
const selectedSegments = ref(
|
||||
Array.isArray(props.filters?.segments)
|
||||
? props.filters.segments.map((s) => String(s))
|
||||
: props.filters?.segment
|
||||
? [String(props.filters.segment)]
|
||||
: []
|
||||
);
|
||||
const filterPopoverOpen = ref(false);
|
||||
|
||||
function applyDateFilter() {
|
||||
filterPopoverOpen.value = false;
|
||||
const params = Object.fromEntries(
|
||||
new URLSearchParams(window.location.search).entries()
|
||||
);
|
||||
|
|
@ -51,32 +75,27 @@ function applyDateFilter() {
|
|||
} else {
|
||||
delete params.search;
|
||||
}
|
||||
if (selectedSegment.value) {
|
||||
params.segment = String(selectedSegment.value);
|
||||
if (selectedSegments.value.length > 0) {
|
||||
// join as comma list for backend; adjust server parsing accordingly
|
||||
params.segments = selectedSegments.value.join(",");
|
||||
} else {
|
||||
delete params.segments;
|
||||
}
|
||||
delete params.page;
|
||||
// remove legacy single segment param if present
|
||||
delete params.segment;
|
||||
router.get(route("client.contracts", { uuid: props.client.uuid }), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
only: ['contracts'],
|
||||
only: ["contracts"],
|
||||
});
|
||||
}
|
||||
|
||||
function clearDateFilter() {
|
||||
dateRange.value = { start: null, end: null };
|
||||
selectedSegment.value = null;
|
||||
applyDateFilter();
|
||||
}
|
||||
|
||||
function handleDateRangeUpdate() {
|
||||
applyDateFilter();
|
||||
}
|
||||
|
||||
function handleSegmentChange(value) {
|
||||
selectedSegment.value = value;
|
||||
selectedSegments.value = [];
|
||||
search.value = "";
|
||||
applyDateFilter();
|
||||
}
|
||||
|
||||
|
|
@ -111,132 +130,184 @@ function formatDate(value) {
|
|||
<AppLayout title="Pogodbe">
|
||||
<template #header></template>
|
||||
<!-- Header card (matches Client/Show header style) -->
|
||||
<div class="pt-12">
|
||||
<div class="pt-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
|
||||
>
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<SectionTitle>
|
||||
<template #title>
|
||||
{{ client.person.full_name }}
|
||||
</template>
|
||||
</SectionTitle>
|
||||
</div>
|
||||
<Card class="border-l-4 border-blue-400">
|
||||
<div class="p-3 flex justify-between items-center">
|
||||
<SectionTitle>
|
||||
<template #title>
|
||||
{{ client.person.full_name }}
|
||||
</template>
|
||||
</SectionTitle>
|
||||
<Badge class="bg-blue-500 text-white"> Naročnik </Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client details card (separate container) -->
|
||||
<div class="pt-1">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
|
||||
>
|
||||
<div class="mx-auto max-w-4x1 px-2">
|
||||
<PersonInfoGrid :types="types" :person="client.person" />
|
||||
<Card>
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<PersonInfoGrid
|
||||
:types="types"
|
||||
:person="client.person"
|
||||
:edit="hasPerm('client-edit')"
|
||||
></PersonInfoGrid>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contracts list card -->
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="mx-auto max-w-4x1 py-3">
|
||||
<div class="mb-4">
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
as-child
|
||||
:variant="route().current('client.show') ? 'default' : 'ghost'"
|
||||
>
|
||||
<Link :href="route('client.show', { uuid: client.uuid })">
|
||||
Primeri
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
as-child
|
||||
:variant="route().current('client.contracts') ? 'default' : 'ghost'"
|
||||
>
|
||||
<Link :href="route('client.contracts', { uuid: client.uuid })">
|
||||
Pogodbe
|
||||
</Link>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="mx-auto max-w-4x1">
|
||||
<div class="px-3 py-4 flex flex-row items-center gap-3">
|
||||
<Link
|
||||
:class="
|
||||
cn(
|
||||
'border border-gray-200 py-2 px-3 rounded-md hover:bg-accent hover:text-accent-foreground ',
|
||||
route().current('client.show')
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''
|
||||
)
|
||||
"
|
||||
:href="route('client.show', { uuid: client.uuid })"
|
||||
>
|
||||
Primeri
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
:class="
|
||||
cn(
|
||||
'border border-gray-200 py-2 px-3 rounded-md hover:bg-accent hover:text-accent-foreground ',
|
||||
route().current('client.contracts')
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''
|
||||
)
|
||||
"
|
||||
:href="route('client.contracts', { uuid: client.uuid })"
|
||||
>
|
||||
Pogodbe
|
||||
</Link>
|
||||
</div>
|
||||
<DataTable
|
||||
:show-search="true"
|
||||
:show-page-size="true"
|
||||
:show-filters="true"
|
||||
:has-active-filters="!!(dateRange?.start || dateRange?.end || selectedSegment)"
|
||||
:columns="[
|
||||
{ key: 'select', label: '', sortable: false, width: '50px' },
|
||||
{ key: 'reference', label: 'Referenca', sortable: false },
|
||||
{ key: 'customer', label: 'Stranka', sortable: false },
|
||||
{ key: 'start', label: 'Začetek', sortable: false },
|
||||
{ key: 'segment', label: 'Segment', sortable: false },
|
||||
{ key: 'balance', label: 'Stanje', sortable: false, align: 'right' },
|
||||
]"
|
||||
:rows="contracts.data || []"
|
||||
:meta="{ current_page: contracts.current_page, per_page: contracts.per_page, total: contracts.total, last_page: contracts.last_page, from: contracts.from, to: contracts.to, links: contracts.links }"
|
||||
:data="contracts.data || []"
|
||||
:meta="{
|
||||
current_page: contracts.current_page,
|
||||
per_page: contracts.per_page,
|
||||
total: contracts.total,
|
||||
last_page: contracts.last_page,
|
||||
from: contracts.from,
|
||||
to: contracts.to,
|
||||
links: contracts.links,
|
||||
}"
|
||||
route-name="client.contracts"
|
||||
:route-params="{ uuid: client.uuid }"
|
||||
:search="search"
|
||||
row-key="uuid"
|
||||
:only-props="['contracts']"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
page-param-name="contracts_page"
|
||||
per-page-param-name="contracts_per_page"
|
||||
:show-toolbar="true"
|
||||
>
|
||||
<template #toolbar-filters>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Datumska območja</label>
|
||||
<DateRangePicker
|
||||
v-model="dateRange"
|
||||
format="dd.MM.yyyy"
|
||||
@update:model-value="handleDateRangeUpdate"
|
||||
placeholder="Izberi datumska območja"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Segment</label>
|
||||
<Select
|
||||
:model-value="selectedSegment"
|
||||
@update:model-value="handleSegmentChange"
|
||||
>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Vsi segmenti" />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="w-[var(--radix-select-trigger-width)]">
|
||||
<SelectItem :value="null">Vsi segmenti</SelectItem>
|
||||
<SelectItem
|
||||
v-for="segment in segments"
|
||||
:key="segment.id"
|
||||
:value="String(segment.id)"
|
||||
>
|
||||
{{ segment.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex justify-end pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!dateRange?.start && !dateRange?.end && !selectedSegment"
|
||||
@click="clearDateFilter"
|
||||
>
|
||||
Počisti
|
||||
<AppPopover
|
||||
v-model:open="filterPopoverOpen"
|
||||
align="start"
|
||||
content-class="w-[400px]"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button variant="outline" size="sm" class="gap-2">
|
||||
<Filter class="h-4 w-4" />
|
||||
Filtri
|
||||
<span
|
||||
v-if="
|
||||
dateRange?.start || dateRange?.end || selectedSegments?.length
|
||||
"
|
||||
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
|
||||
>
|
||||
{{
|
||||
[
|
||||
dateRange?.start || dateRange?.end ? 1 : 0,
|
||||
selectedSegments?.length ? 1 : 0,
|
||||
].reduce((a, b) => a + b, 0)
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium text-sm">Filtri pogodb</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Izberite filtre za prikaz pogodb
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Iskanje</InputLabel>
|
||||
<Input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Išči po referenci, stranki..."
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Datumska območja</InputLabel>
|
||||
<DateRangePicker
|
||||
v-model="dateRange"
|
||||
format="dd.MM.yyyy"
|
||||
placeholder="Izberi datumska območja"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Segmenti</InputLabel>
|
||||
<AppMultiSelect
|
||||
v-model="selectedSegments"
|
||||
:items="
|
||||
segments.map((s) => ({ value: String(s.id), label: s.name }))
|
||||
"
|
||||
placeholder="Vsi segmenti"
|
||||
search-placeholder="Išči segment..."
|
||||
empty-text="Ni segmentov"
|
||||
chip-variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="
|
||||
!dateRange?.start &&
|
||||
!dateRange?.end &&
|
||||
selectedSegments.length === 0 &&
|
||||
search === ''
|
||||
"
|
||||
@click="clearDateFilter"
|
||||
>
|
||||
Počisti
|
||||
</Button>
|
||||
<Button type="button" size="sm" @click="applyDateFilter">
|
||||
Uporabi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPopover>
|
||||
</template>
|
||||
<template #cell-reference="{ row }">
|
||||
<Link
|
||||
:href="route('clientCase.show', caseShowParams(row))"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="font-semibold hover:underline text-primary-700"
|
||||
>
|
||||
{{ row.reference }}
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import { Link, router, usePage } from "@inertiajs/vue3";
|
|||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faUserGroup } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/Components/ui/card";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
|
|
@ -30,7 +28,8 @@ import { useForm } from "vee-validate";
|
|||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import ActionMessage from "@/Components/ActionMessage.vue";
|
||||
import { Mail, Plug2Icon } from "lucide-vue-next";
|
||||
import { Mail, Plug2Icon, Plus } from "lucide-vue-next";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
|
||||
const props = defineProps({
|
||||
clients: Object,
|
||||
|
|
@ -164,13 +163,13 @@ const fmtCurrency = (v) => {
|
|||
<template>
|
||||
<AppLayout title="Client">
|
||||
<template #header> </template>
|
||||
<div class="py-12">
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader class="p-5">
|
||||
<CardTitle>Naročniki</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent class="p-0">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ key: 'nu', label: 'Št.', sortable: false },
|
||||
|
|
@ -193,11 +192,16 @@ const fmtCurrency = (v) => {
|
|||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
row-key="uuid"
|
||||
:striped="true"
|
||||
empty-text="Ni najdenih naročnikov."
|
||||
>
|
||||
<template #toolbar-add>
|
||||
<Button @click="openDrawerCreateClient">
|
||||
<Plug2Icon class="w-4 h-4 mr-2" /> Dodaj
|
||||
<template #toolbar-actions>
|
||||
<Button
|
||||
variant="outline"
|
||||
v-if="hasPerm('client-edit')"
|
||||
@click="openDrawerCreateClient"
|
||||
>
|
||||
<Plus class="w-4 h-4" /> Novi naročnik
|
||||
</Button>
|
||||
</template>
|
||||
<template #cell-nu="{ row }">
|
||||
|
|
@ -206,7 +210,7 @@ const fmtCurrency = (v) => {
|
|||
<template #cell-name="{ row }">
|
||||
<Link
|
||||
:href="route('client.show', { uuid: row.uuid })"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="font-semibold hover:underline text-primary-700"
|
||||
>
|
||||
{{ row.person?.full_name || "-" }}
|
||||
</Link>
|
||||
|
|
@ -249,7 +253,7 @@ const fmtCurrency = (v) => {
|
|||
<div class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="full_name">
|
||||
<FormItem>
|
||||
<FormLabel>Naziv</FormLabel>
|
||||
<FormLabel>Naziv *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="fullname"
|
||||
|
|
@ -262,129 +266,137 @@ const fmtCurrency = (v) => {
|
|||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="tax_number">
|
||||
<FormItem>
|
||||
<FormLabel>Davčna</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="taxnumber"
|
||||
type="text"
|
||||
autocomplete="tax-number"
|
||||
placeholder="Davčna številka"
|
||||
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="socialSecurityNumber"
|
||||
type="text"
|
||||
autocomplete="social-security-number"
|
||||
placeholder="Matična / Emšo"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="address.address">
|
||||
<FormItem>
|
||||
<FormLabel>Naslov</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
autocomplete="address"
|
||||
placeholder="Naslov"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="address.country">
|
||||
<FormItem>
|
||||
<FormLabel>Država</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="addressCountry"
|
||||
type="text"
|
||||
autocomplete="address-country"
|
||||
placeholder="Država"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="address.type_id">
|
||||
<FormItem>
|
||||
<FormLabel>Vrsta naslova</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<CardTitle class="full">Osebni podatki</CardTitle>
|
||||
<Separator />
|
||||
<div class="flex flex-row gap-2">
|
||||
<FormField v-slot="{ componentField }" name="tax_number">
|
||||
<FormItem class="flex-1">
|
||||
<FormLabel>Davčna</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi vrsto naslova" />
|
||||
</SelectTrigger>
|
||||
<Input
|
||||
id="taxnumber"
|
||||
type="text"
|
||||
autocomplete="tax-number"
|
||||
placeholder="Davčna številka"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem :value="1">Stalni</SelectItem>
|
||||
<SelectItem :value="2">Začasni</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="phone.country_code">
|
||||
<FormItem>
|
||||
<FormLabel>Koda države tel.</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormField v-slot="{ componentField }" name="social_security_number">
|
||||
<FormItem class="flex-1">
|
||||
<FormLabel>Matična / Emšo</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi kodo države" />
|
||||
</SelectTrigger>
|
||||
<Input
|
||||
id="socialSecurityNumber"
|
||||
type="text"
|
||||
autocomplete="social-security-number"
|
||||
placeholder="Matična / Emšo"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="00386">+386 (Slovenija)</SelectItem>
|
||||
<SelectItem value="00385">+385 (Hrvaška)</SelectItem>
|
||||
<SelectItem value="0039">+39 (Italija)</SelectItem>
|
||||
<SelectItem value="0036">+36 (Madžarska)</SelectItem>
|
||||
<SelectItem value="0043">+43 (Avstrija)</SelectItem>
|
||||
<SelectItem value="00381">+381 (Srbija)</SelectItem>
|
||||
<SelectItem value="00387">+387 (Bosna in Hercegovina)</SelectItem>
|
||||
<SelectItem value="00382">+382 (Črna gora)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<CardTitle class="full">Naslov</CardTitle>
|
||||
<Separator />
|
||||
<div class="grid sm:grid-cols-2 gap-2">
|
||||
<FormField v-slot="{ componentField }" name="address.address">
|
||||
<FormItem>
|
||||
<FormLabel>Naslov</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
autocomplete="address"
|
||||
placeholder="Naslov"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="phone.nu">
|
||||
<FormItem>
|
||||
<FormLabel>Telefonska št.</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="phoneNu"
|
||||
type="text"
|
||||
autocomplete="phone-nu"
|
||||
placeholder="Telefonska številka"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="address.country">
|
||||
<FormItem>
|
||||
<FormLabel>Država</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="addressCountry"
|
||||
type="text"
|
||||
autocomplete="address-country"
|
||||
placeholder="Država"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="address.type_id">
|
||||
<FormItem>
|
||||
<FormLabel>Vrsta naslova</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi vrsto naslova" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent align="end" position="popper">
|
||||
<SelectItem :value="1">Stalni</SelectItem>
|
||||
<SelectItem :value="2">Začasni</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<CardTitle class="full">Naslov</CardTitle>
|
||||
<Separator />
|
||||
<div class="flex flex-row gap-2">
|
||||
<FormField v-slot="{ value, handleChange }" name="phone.country_code">
|
||||
<FormItem class="flex-1/3">
|
||||
<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 value="00386">+386 (Slovenija)</SelectItem>
|
||||
<SelectItem value="00385">+385 (Hrvaška)</SelectItem>
|
||||
<SelectItem value="0039">+39 (Italija)</SelectItem>
|
||||
<SelectItem value="0036">+36 (Madžarska)</SelectItem>
|
||||
<SelectItem value="0043">+43 (Avstrija)</SelectItem>
|
||||
<SelectItem value="00381">+381 (Srbija)</SelectItem>
|
||||
<SelectItem value="00387">+387 (Bosna in Hercegovina)</SelectItem>
|
||||
<SelectItem value="00382">+382 (Črna gora)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="phone.nu">
|
||||
<FormItem class="flex-2/3">
|
||||
<FormLabel>Telefonska št.</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="phoneNu"
|
||||
type="text"
|
||||
autocomplete="phone-nu"
|
||||
placeholder="Telefonska številka"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { Link, usePage } from "@inertiajs/vue3";
|
||||
import { Link, router, usePage } from "@inertiajs/vue3";
|
||||
import SectionTitle from "@/Components/SectionTitle.vue";
|
||||
import PersonInfoGrid from "@/Components/PersonInfo/PersonInfoGrid.vue";
|
||||
import FormCreateCase from "./Partials/FormCreateCase.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTable.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { ButtonGroup } from "@/Components/ui/button-group";
|
||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import Badge from "@/Components/ui/badge/Badge.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
|
|
@ -34,72 +37,92 @@ const drawerCreateCase = ref(false);
|
|||
const openDrawerCreateCase = () => {
|
||||
drawerCreateCase.value = true;
|
||||
};
|
||||
|
||||
function applySearch() {
|
||||
const params = Object.fromEntries(
|
||||
new URLSearchParams(window.location.search).entries()
|
||||
);
|
||||
const term = (search.value || "").trim();
|
||||
if (term) {
|
||||
params.search = term;
|
||||
} else {
|
||||
delete params.search;
|
||||
}
|
||||
delete params.page;
|
||||
router.get(route("client.show", { uuid: props.client.uuid }), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
only: ["client_cases"],
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Client">
|
||||
<template #header></template>
|
||||
<div class="pt-12">
|
||||
<div class="pt-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
|
||||
>
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<SectionTitle>
|
||||
<template #title>
|
||||
{{ client.person.full_name }}
|
||||
</template>
|
||||
</SectionTitle>
|
||||
</div>
|
||||
<Card class="border-l-4 border-blue-400">
|
||||
<div class="p-3 flex justify-between items-center">
|
||||
<SectionTitle>
|
||||
<template #title>
|
||||
{{ client.person.full_name }}
|
||||
</template>
|
||||
</SectionTitle>
|
||||
<Badge class="bg-blue-500 text-white"> Naročnik </Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-1">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
|
||||
>
|
||||
<div class="mx-auto max-w-4x1 px-2">
|
||||
<Card>
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<PersonInfoGrid
|
||||
:types="types"
|
||||
:person="client.person"
|
||||
:edit="hasPerm('client-edit')"
|
||||
></PersonInfoGrid>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="mx-auto max-w-4x1 py-3">
|
||||
<div class="mb-4">
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
as-child
|
||||
:variant="route().current('client.show') ? 'default' : 'ghost'"
|
||||
>
|
||||
<Link :href="route('client.show', { uuid: client.uuid })">
|
||||
Primeri
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
as-child
|
||||
:variant="route().current('client.contracts') ? 'default' : 'ghost'"
|
||||
>
|
||||
<Link :href="route('client.contracts', { uuid: client.uuid })">
|
||||
Pogodbe
|
||||
</Link>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="mx-auto max-w-4x1">
|
||||
<div class="px-3 py-4 flex flex-row items-center gap-3">
|
||||
<Link
|
||||
:class="
|
||||
cn(
|
||||
'border border-gray-200 py-2 px-3 rounded-md hover:bg-accent hover:text-accent-foreground ',
|
||||
route().current('client.show')
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''
|
||||
)
|
||||
"
|
||||
:href="route('client.show', { uuid: client.uuid })"
|
||||
>
|
||||
Primeri
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
:class="
|
||||
cn(
|
||||
'border border-gray-200 py-2 px-3 rounded-md hover:bg-accent hover:text-accent-foreground ',
|
||||
route().current('client.contracts')
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''
|
||||
)
|
||||
"
|
||||
:href="route('client.contracts', { uuid: client.uuid })"
|
||||
>
|
||||
Pogodbe
|
||||
</Link>
|
||||
</div>
|
||||
<DataTable
|
||||
:show-search="true"
|
||||
:show-page-size="true"
|
||||
:show-add="true"
|
||||
:columns="[
|
||||
{ key: 'nu', label: 'Št.', sortable: false, class: 'w-40' },
|
||||
{ key: 'case', label: 'Primer', sortable: false },
|
||||
|
|
@ -117,7 +140,7 @@ const openDrawerCreateCase = () => {
|
|||
align: 'right',
|
||||
},
|
||||
]"
|
||||
:rows="client_cases.data || []"
|
||||
:data="client_cases.data || []"
|
||||
:meta="{
|
||||
current_page: client_cases.current_page,
|
||||
per_page: client_cases.per_page,
|
||||
|
|
@ -130,16 +153,32 @@ const openDrawerCreateCase = () => {
|
|||
route-name="client.show"
|
||||
:route-params="{ uuid: client.uuid }"
|
||||
row-key="uuid"
|
||||
:search="search"
|
||||
:only-props="['client_cases']"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
:show-toolbar="true"
|
||||
>
|
||||
<template #toolbar-add>
|
||||
<ActionMenuItem
|
||||
<template #toolbar-filters>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
v-model="search"
|
||||
placeholder="Išči po primeru, davčni, osebi..."
|
||||
class="w-[260px]"
|
||||
@keydown.enter="applySearch"
|
||||
/>
|
||||
<Button size="sm" variant="outline" @click="applySearch">Išči</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar-actions>
|
||||
<Button
|
||||
v-if="hasPerm('case-edit')"
|
||||
label="Dodaj primer"
|
||||
:icon="faPlus"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="gap-2"
|
||||
@click="openDrawerCreateCase"
|
||||
/>
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4" />
|
||||
Dodaj primer
|
||||
</Button>
|
||||
</template>
|
||||
<template #cell-nu="{ row }">
|
||||
{{ row.person?.nu || "-" }}
|
||||
|
|
@ -147,7 +186,7 @@ const openDrawerCreateCase = () => {
|
|||
<template #cell-case="{ row }">
|
||||
<Link
|
||||
:href="route('clientCase.show', { client_case: row.uuid })"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="font-semibold hover:underline text-primary-700"
|
||||
>
|
||||
{{ row.person?.full_name || "-" }}
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -171,16 +171,14 @@ function safeCaseHref(uuid, segment = null) {
|
|||
>
|
||||
<FontAwesomeIcon :icon="k.icon" class="w-5 h-5" />
|
||||
</span>
|
||||
<span
|
||||
class="text-[11px] text-gray-400 uppercase tracking-wide"
|
||||
>{{ k.label }}</span
|
||||
>
|
||||
<span class="text-[11px] text-gray-400 uppercase tracking-wide">{{
|
||||
k.label
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<span
|
||||
class="text-2xl font-semibold tracking-tight text-gray-900"
|
||||
>{{ props.kpis?.[k.key] ?? "—" }}</span
|
||||
>
|
||||
<span class="text-2xl font-semibold tracking-tight text-gray-900">{{
|
||||
props.kpis?.[k.key] ?? "—"
|
||||
}}</span>
|
||||
<span
|
||||
class="text-[10px] text-indigo-500 opacity-0 group-hover:opacity-100 transition"
|
||||
>Odpri →</span
|
||||
|
|
@ -250,20 +248,13 @@ function safeCaseHref(uuid, segment = null) {
|
|||
<div class="grid lg:grid-cols-3 gap-8">
|
||||
<!-- Activity Feed -->
|
||||
<div class="lg:col-span-1 space-y-4">
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-5 flex flex-col gap-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-5 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase"
|
||||
>
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase">
|
||||
Aktivnost
|
||||
</h3>
|
||||
</div>
|
||||
<ul
|
||||
class="divide-y divide-gray-100 text-sm"
|
||||
v-if="activities"
|
||||
>
|
||||
<ul class="divide-y divide-gray-100 text-sm" v-if="activities">
|
||||
<li
|
||||
v-for="a in activityItems"
|
||||
:key="a.id"
|
||||
|
|
@ -296,11 +287,7 @@ function safeCaseHref(uuid, segment = null) {
|
|||
</li>
|
||||
</ul>
|
||||
<ul v-else class="animate-pulse space-y-2">
|
||||
<li
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="h-5 bg-gray-100 rounded"
|
||||
/>
|
||||
<li v-for="n in 5" :key="n" class="h-5 bg-gray-100 rounded" />
|
||||
</ul>
|
||||
<div class="pt-1 flex justify-between items-center text-[11px]">
|
||||
<Link
|
||||
|
|
@ -320,19 +307,13 @@ function safeCaseHref(uuid, segment = null) {
|
|||
<!-- Right side panels -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<!-- SMS Overview -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
SMS stanje
|
||||
</h3>
|
||||
<div v-if="props.smsStats?.length" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead
|
||||
class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider"
|
||||
>
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Profil</th>
|
||||
<th class="px-3 py-2 text-left">Bilanca</th>
|
||||
|
|
@ -349,9 +330,7 @@ function safeCaseHref(uuid, segment = null) {
|
|||
class="border-t last:border-b"
|
||||
>
|
||||
<td class="px-3 py-2">
|
||||
<span class="font-medium text-gray-900">{{
|
||||
p.name
|
||||
}}</span>
|
||||
<span class="font-medium text-gray-900">{{ p.name }}</span>
|
||||
<span
|
||||
class="ml-2 text-[11px]"
|
||||
:class="p.active ? 'text-emerald-600' : 'text-gray-400'"
|
||||
|
|
@ -371,18 +350,12 @@ function safeCaseHref(uuid, segment = null) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-500">
|
||||
Ni podatkov o SMS.
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-500">Ni podatkov o SMS.</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
System Health
|
||||
</h3>
|
||||
<div
|
||||
|
|
@ -390,17 +363,13 @@ function safeCaseHref(uuid, segment = null) {
|
|||
class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 text-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-[11px] uppercase text-gray-400"
|
||||
>Queue backlog</span
|
||||
>
|
||||
<span class="text-[11px] uppercase text-gray-400">Queue backlog</span>
|
||||
<span class="font-semibold text-gray-800">{{
|
||||
systemHealth.queue_backlog ?? "—"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-[11px] uppercase text-gray-400"
|
||||
>Failed jobs</span
|
||||
>
|
||||
<span class="text-[11px] uppercase text-gray-400">Failed jobs</span>
|
||||
<span class="font-semibold text-gray-800">{{
|
||||
systemHealth.failed_jobs ?? "—"
|
||||
}}</span>
|
||||
|
|
@ -422,30 +391,20 @@ function safeCaseHref(uuid, segment = null) {
|
|||
>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-[11px] uppercase text-gray-400"
|
||||
>Generated</span
|
||||
>
|
||||
<span class="text-[11px] uppercase text-gray-400">Generated</span>
|
||||
<span class="font-semibold text-gray-800">{{
|
||||
new Date(systemHealth.generated_at).toLocaleTimeString()
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="grid sm:grid-cols-4 gap-4 animate-pulse">
|
||||
<div
|
||||
v-for="n in 4"
|
||||
:key="n"
|
||||
class="h-10 bg-gray-100 rounded"
|
||||
/>
|
||||
<div v-for="n in 4" :key="n" class="h-10 bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Field Jobs Trend (7 dni) -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
Zaključena terenska dela (7 dni)
|
||||
</h3>
|
||||
<div v-if="trends" class="h-24">
|
||||
|
|
@ -479,18 +438,11 @@ function safeCaseHref(uuid, segment = null) {
|
|||
</div>
|
||||
|
||||
<!-- Stale Cases -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
Stari primeri brez aktivnosti
|
||||
</h3>
|
||||
<ul
|
||||
v-if="staleCases"
|
||||
class="divide-y divide-gray-100 text-sm"
|
||||
>
|
||||
<ul v-if="staleCases" class="divide-y divide-gray-100 text-sm">
|
||||
<li
|
||||
v-for="c in staleCases"
|
||||
:key="c.id"
|
||||
|
|
@ -511,8 +463,7 @@ function safeCaseHref(uuid, segment = null) {
|
|||
{{ formatStaleDaysLabel(c.days_without_activity ?? c.days_stale) }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] px-2 py-0.5 rounded bg-amber-50 text-amber-600"
|
||||
<span class="text-[10px] px-2 py-0.5 rounded bg-amber-50 text-amber-600"
|
||||
>Stale</span
|
||||
>
|
||||
</li>
|
||||
|
|
@ -524,27 +475,16 @@ function safeCaseHref(uuid, segment = null) {
|
|||
</li>
|
||||
</ul>
|
||||
<div v-else class="space-y-2 animate-pulse">
|
||||
<div
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="h-5 bg-gray-100 rounded"
|
||||
/>
|
||||
<div v-for="n in 5" :key="n" class="h-5 bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Field Jobs Assigned Today -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
Današnje dodelitve terenskih
|
||||
</h3>
|
||||
<ul
|
||||
v-if="fieldJobsAssignedToday"
|
||||
class="divide-y divide-gray-100 text-sm"
|
||||
>
|
||||
<ul v-if="fieldJobsAssignedToday" class="divide-y divide-gray-100 text-sm">
|
||||
<li
|
||||
v-for="f in fieldJobsAssignedToday"
|
||||
:key="f.id"
|
||||
|
|
@ -567,10 +507,7 @@ function safeCaseHref(uuid, segment = null) {
|
|||
<span v-else class="text-gray-700">{{
|
||||
f.contract.reference || f.contract.uuid?.slice(0, 8)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="f.contract.person_full_name"
|
||||
class="text-gray-500"
|
||||
>
|
||||
<span v-if="f.contract.person_full_name" class="text-gray-500">
|
||||
– {{ f.contract.person_full_name }}
|
||||
</span>
|
||||
</template>
|
||||
|
|
@ -595,27 +532,16 @@ function safeCaseHref(uuid, segment = null) {
|
|||
</li>
|
||||
</ul>
|
||||
<div v-else class="space-y-2 animate-pulse">
|
||||
<div
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="h-5 bg-gray-100 rounded"
|
||||
/>
|
||||
<div v-for="n in 5" :key="n" class="h-5 bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Imports In Progress -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
Uvozi v teku
|
||||
</h3>
|
||||
<ul
|
||||
v-if="importsInProgress"
|
||||
class="divide-y divide-gray-100 text-sm"
|
||||
>
|
||||
<ul v-if="importsInProgress" class="divide-y divide-gray-100 text-sm">
|
||||
<li v-for="im in importsInProgress" :key="im.id" class="py-2 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium text-gray-700 truncate">
|
||||
|
|
@ -626,9 +552,7 @@ function safeCaseHref(uuid, segment = null) {
|
|||
>{{ im.status }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="w-full h-2 bg-gray-100 rounded overflow-hidden"
|
||||
>
|
||||
<div class="w-full h-2 bg-gray-100 rounded overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-indigo-500"
|
||||
:style="{ width: (im.progress_pct || 0) + '%' }"
|
||||
|
|
@ -647,27 +571,16 @@ function safeCaseHref(uuid, segment = null) {
|
|||
</li>
|
||||
</ul>
|
||||
<div v-else class="space-y-2 animate-pulse">
|
||||
<div
|
||||
v-for="n in 4"
|
||||
:key="n"
|
||||
class="h-5 bg-gray-100 rounded"
|
||||
/>
|
||||
<div v-for="n in 4" :key="n" class="h-5 bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Document Templates -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
Aktivne predloge dokumentov
|
||||
</h3>
|
||||
<ul
|
||||
v-if="activeTemplates"
|
||||
class="divide-y divide-gray-100 text-sm"
|
||||
>
|
||||
<ul v-if="activeTemplates" class="divide-y divide-gray-100 text-sm">
|
||||
<li
|
||||
v-for="t in activeTemplates"
|
||||
:key="t.id"
|
||||
|
|
@ -695,11 +608,7 @@ function safeCaseHref(uuid, segment = null) {
|
|||
</li>
|
||||
</ul>
|
||||
<div v-else class="space-y-2 animate-pulse">
|
||||
<div
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="h-5 bg-gray-100 rounded"
|
||||
/>
|
||||
<div v-for="n in 5" :key="n" class="h-5 bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
87
resources/js/Pages/Dashboard/Index.vue
Normal file
87
resources/js/Pages/Dashboard/Index.vue
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import SimpleKpiCard from "./Partials/SimpleKpiCard.vue";
|
||||
import ActivityFeed from "./Partials/ActivityFeed.vue";
|
||||
import SmsOverview from "./Partials/SmsOverview.vue";
|
||||
import CompletedFieldJobsTrend from "./Partials/CompletedFieldJobsTrend.vue";
|
||||
import FieldJobsAssignedToday from "./Partials/FieldJobsAssignedToday.vue";
|
||||
import { Users, FileText, Banknote, CalendarCheck } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
kpis: Object,
|
||||
activities: Array,
|
||||
trends: Object,
|
||||
systemHealth: Object,
|
||||
staleCases: Array,
|
||||
fieldJobsAssignedToday: Array,
|
||||
importsInProgress: Array,
|
||||
activeTemplates: Array,
|
||||
smsStats: Array,
|
||||
});
|
||||
|
||||
// Format balance as currency
|
||||
const formatBalance = (amount) => {
|
||||
if (amount == null) return "—";
|
||||
return new Intl.NumberFormat("sl-SI", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Nadzorna plošča">
|
||||
<template #header> </template>
|
||||
|
||||
<div class="max-w-7xl mx-auto space-y-10 py-6">
|
||||
<!-- KPI Cards Grid -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<SimpleKpiCard
|
||||
label="Aktivni stranke"
|
||||
:value="kpis?.active_clients"
|
||||
:icon="Users"
|
||||
/>
|
||||
<SimpleKpiCard
|
||||
label="Aktivne pogodbe"
|
||||
:value="kpis?.active_contracts"
|
||||
:icon="FileText"
|
||||
icon-bg="bg-chart-2/10"
|
||||
icon-color="text-chart-2"
|
||||
/>
|
||||
<SimpleKpiCard
|
||||
label="Skupno stanje"
|
||||
:value="formatBalance(kpis?.total_balance)"
|
||||
:icon="Banknote"
|
||||
icon-bg="bg-chart-3/10"
|
||||
icon-color="text-chart-3"
|
||||
/>
|
||||
<SimpleKpiCard
|
||||
label="Aktivne obljube"
|
||||
:value="kpis?.active_promises"
|
||||
:icon="CalendarCheck"
|
||||
icon-bg="bg-chart-4/10"
|
||||
icon-color="text-chart-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-8">
|
||||
<!-- Activity Feed -->
|
||||
<div class="lg:col-span-1 space-y-4">
|
||||
<ActivityFeed :activities="activities" :systemHealth="systemHealth" />
|
||||
<!-- Field Jobs Assigned Today -->
|
||||
<FieldJobsAssignedToday :fieldJobsAssignedToday="fieldJobsAssignedToday" />
|
||||
</div>
|
||||
|
||||
<!-- Right side panels -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<!-- SMS Overview -->
|
||||
<SmsOverview :smsStats="smsStats" />
|
||||
|
||||
<!-- Completed Field Jobs Trend (7 dni) -->
|
||||
<CompletedFieldJobsTrend :trends="trends" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
108
resources/js/Pages/Dashboard/Partials/ActivityFeed.vue
Normal file
108
resources/js/Pages/Dashboard/Partials/ActivityFeed.vue
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<script setup>
|
||||
import { computed, onMounted } from "vue";
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
import Badge from "@/Components/ui/badge/Badge.vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/Components/ui/item";
|
||||
import { BadgeCheckIcon, ChevronRightIcon, Radar, Radio, RssIcon } from "lucide-vue-next";
|
||||
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||
|
||||
const props = defineProps({
|
||||
activities: Array,
|
||||
systemHealth: Object,
|
||||
});
|
||||
|
||||
function buildRelated(a) {
|
||||
const links = [];
|
||||
if (a.client_case_uuid || a.client_case_id) {
|
||||
const caseParam = a.client_case_uuid || a.client_case_id;
|
||||
try {
|
||||
const href = String(route("clientCase.show", { client_case: caseParam }));
|
||||
links.push({
|
||||
type: "client_case",
|
||||
label: "Primer",
|
||||
href,
|
||||
});
|
||||
} catch (e) {
|
||||
links.push({
|
||||
type: "client_case",
|
||||
label: "Primer",
|
||||
href: `/client-cases/${caseParam}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log((props.activities || []).map((a) => ({ ...a, links: buildRelated(a) })));
|
||||
});
|
||||
|
||||
const activityItems = computed(() =>
|
||||
(props.activities || []).map((a) => ({ ...a, links: buildRelated(a) }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-2"
|
||||
header-class="py-3! px-4 border-b text-muted-foreground gap-0"
|
||||
body-class="flex flex-col gap-4"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Radio size="20" />
|
||||
<CardTitle class="uppercase"> Aktivnost </CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
<ScrollArea class="h-96 w-full">
|
||||
<div class="flex flex-col gap-1 px-1" v-if="activities">
|
||||
<Item v-for="a in activityItems" :key="a.id" variant="outline" size="sm" as-child>
|
||||
<a :href="a.links[0].href ?? ''">
|
||||
<ItemMedia>
|
||||
<span class="w-2 h-2 mt-2 rounded-full bg-primary" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{{ a.note || "Dogodek" }}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{{ new Date(a.created_at).toLocaleString() }}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon class="size-4" />
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
<div v-if="!activities?.length" class="py-4 text-xs text-gray-500 text-center">
|
||||
Ni zabeleženih aktivnosti.
|
||||
</div>
|
||||
</div>
|
||||
<ul v-else class="animate-pulse space-y-2">
|
||||
<li v-for="n in 5" :key="n" class="h-5 bg-gray-100 rounded" />
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
|
||||
<div class="flex justify-between items-center text-[11px] p-2">
|
||||
<Link
|
||||
:href="route('dashboard')"
|
||||
class="inline-flex items-center gap-1 font-medium text-primary hover:underline"
|
||||
>Več kmalu <FontAwesomeIcon :icon="faArrowUpRightFromSquare" class="w-3 h-3"
|
||||
/></Link>
|
||||
<span v-if="systemHealth" class="text-gray-400"
|
||||
>Posodobljeno {{ new Date(systemHealth.generated_at).toLocaleTimeString() }}</span
|
||||
>
|
||||
</div>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { VisAxis, VisGroupedBar, VisXYContainer } from "@unovis/vue";
|
||||
import {
|
||||
ChartAutoLegend,
|
||||
ChartContainer,
|
||||
ChartTooltipContent,
|
||||
ChartCrosshair,
|
||||
provideChartContext,
|
||||
componentToString,
|
||||
} from "@/Components/ui/chart";
|
||||
import AppChartDisplay from "@/Components/app/ui/charts/AppChartDisplay.vue";
|
||||
|
||||
const props = defineProps({
|
||||
trends: Object,
|
||||
});
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (
|
||||
!props.trends?.labels ||
|
||||
!props.trends?.field_jobs_completed ||
|
||||
!props.trends?.field_jobs
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.trends.labels.map((label, i) => ({
|
||||
date: new Date(label),
|
||||
dateLabel: label,
|
||||
completed: props.trends.field_jobs_completed[i] || 0,
|
||||
assigned: props.trends.field_jobs[i] || 0,
|
||||
}));
|
||||
});
|
||||
|
||||
const chartConfig = {
|
||||
completed: {
|
||||
label: "Zaključeni",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
assigned: {
|
||||
label: "Dodeljeni",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
};
|
||||
|
||||
// Provide chart context at component root so legend (outside ChartContainer) can access it
|
||||
provideChartContext(chartConfig);
|
||||
|
||||
// (No gradients needed for bar chart)
|
||||
|
||||
// Active series keys controlled by auto legend
|
||||
const activeKeys = ref(["completed", "assigned"]);
|
||||
const activeSeries = computed(() => activeKeys.value);
|
||||
const yAccessors = computed(() => activeSeries.value.map((key) => (d) => d[key]));
|
||||
|
||||
// Prevent all series from being disabled (Unovis crosshair needs at least one component with x accessor)
|
||||
let _lastNonEmpty = [...activeKeys.value];
|
||||
watch(activeKeys, (val, oldVal) => {
|
||||
if (val.length === 0) {
|
||||
// revert to previous non-empty selection
|
||||
activeKeys.value = _lastNonEmpty.length ? _lastNonEmpty : oldVal;
|
||||
} else {
|
||||
_lastNonEmpty = [...val];
|
||||
}
|
||||
});
|
||||
|
||||
// Crosshair template using componentToString to render advanced tooltip component
|
||||
const crosshairTemplate = componentToString(chartConfig, ChartTooltipContent, {
|
||||
labelKey: "dateLabel",
|
||||
labelFormatter: (x) => crosshairLabelFormatter(x),
|
||||
});
|
||||
|
||||
const totalCompleted = computed(() => {
|
||||
return chartData.value.reduce((sum, item) => sum + item.completed, 0);
|
||||
});
|
||||
|
||||
const totalAssigned = computed(() => {
|
||||
return chartData.value.reduce((sum, item) => sum + item.assigned, 0);
|
||||
});
|
||||
|
||||
// Formatter for tooltip title (date) and potential item labels
|
||||
const crosshairLabelFormatter = (value) => {
|
||||
// Handle Date objects or parsable date strings
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (isNaN(date)) return value?.toString?.() ?? "";
|
||||
return date.toLocaleDateString("sl-SI", { month: "long", day: "numeric" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppChartDisplay name="ChartLine" class="md:col-span-2 lg:col-span-3">
|
||||
<Card class="p-0">
|
||||
<CardHeader class="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
||||
<div class="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
||||
<CardTitle>Terenska dela - Pregled</CardTitle>
|
||||
<CardDescription>Zadnjih 7 dni</CardDescription>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left sm:border-l sm:border-t-0 sm:px-8 sm:py-6"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">Zaključeni</span>
|
||||
<span class="text-lg font-bold leading-none sm:text-3xl">
|
||||
{{ totalCompleted.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left sm:border-l sm:border-t-0 sm:px-8 sm:py-6"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">Dodeljeni</span>
|
||||
<span class="text-lg font-bold leading-none sm:text-3xl">
|
||||
{{ totalAssigned.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="px-2 sm:px-6 sm:pt-6 pb-4">
|
||||
<div v-if="chartData.length" class="w-full aspect-auto h-[250px]">
|
||||
<ChartContainer :config="chartConfig" class="h-full">
|
||||
<VisXYContainer
|
||||
:data="chartData"
|
||||
:height="250"
|
||||
:margin="{ left: 5, right: 5 }"
|
||||
>
|
||||
<VisGroupedBar
|
||||
:x="(d) => d.date"
|
||||
:y="yAccessors"
|
||||
:color="(d, i) => chartConfig[activeSeries[i]].color"
|
||||
:bar-padding="0.3"
|
||||
/>
|
||||
|
||||
<VisAxis
|
||||
type="x"
|
||||
:tick-line="false"
|
||||
:grid-line="false"
|
||||
:num-ticks="7"
|
||||
:tick-format="
|
||||
(d) => {
|
||||
const date = new Date(d);
|
||||
return date.toLocaleDateString('sl-SI', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
"
|
||||
/>
|
||||
<VisAxis type="y" :num-ticks="4" :tick-line="false" :grid-line="true" />
|
||||
<ChartCrosshair
|
||||
:index="'date'"
|
||||
:template="crosshairTemplate"
|
||||
:colors="[chartConfig.completed.color, chartConfig.assigned.color]"
|
||||
/>
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
<div v-else class="h-[250px] animate-pulse bg-gray-100 rounded" />
|
||||
</CardContent>
|
||||
<div class="border-t px-6 py-2 flex justify-center">
|
||||
<ChartAutoLegend
|
||||
v-model:activeKeys="activeKeys"
|
||||
:order="['completed', 'assigned']"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</AppChartDisplay>
|
||||
</template>
|
||||
110
resources/js/Pages/Dashboard/Partials/FieldJobsAssignedToday.vue
Normal file
110
resources/js/Pages/Dashboard/Partials/FieldJobsAssignedToday.vue
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<script setup>
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
import { computed } from "vue";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import { ChevronRightIcon, MapIcon, UserRound } from "lucide-vue-next";
|
||||
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/Components/ui/item";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
fieldJobsAssignedToday: Array,
|
||||
});
|
||||
|
||||
// Robust time formatter to avoid fixed 02:00:00 (timezone / fallback issues)
|
||||
function formatJobTime(ts) {
|
||||
if (!ts) return "";
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const pad = (n) => n.toString().padStart(2, "0");
|
||||
const h = pad(d.getHours());
|
||||
const m = pad(d.getMinutes());
|
||||
const s = d.getSeconds();
|
||||
return s ? `${h}:${m}:${pad(s)}` : `${h}:${m}`;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Safely build a client case href using Ziggy when available, with a plain fallback.
|
||||
function safeCaseHref(uuid, segment = null) {
|
||||
if (!uuid) {
|
||||
return "#";
|
||||
}
|
||||
try {
|
||||
const params = { client_case: uuid };
|
||||
if (segment != null) {
|
||||
params.segment = segment;
|
||||
}
|
||||
return String(route("clientCase.show", params));
|
||||
} catch (e) {
|
||||
return segment != null
|
||||
? `/client-cases/${uuid}?segment=${segment}`
|
||||
: `/client-cases/${uuid}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-2"
|
||||
header-class="py-3! px-4 border-b gap-0 text-muted-foreground"
|
||||
body-class="flex flex-col gap-4"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<MapIcon size="18" />
|
||||
<CardTitle class="text-muted-foreground uppercase"> Današnji teren </CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
<ScrollArea
|
||||
class="h-96 w-full"
|
||||
v-if="fieldJobsAssignedToday && fieldJobsAssignedToday.length > 0"
|
||||
>
|
||||
<div class="flex flex-col gap-1 px-1">
|
||||
<Item
|
||||
v-for="f in fieldJobsAssignedToday"
|
||||
:key="f.id"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
as-child
|
||||
>
|
||||
<a :href="safeCaseHref(f.contract.client_case_uuid, f.contract.segment_id)">
|
||||
<ItemMedia>
|
||||
<span class="w-2 h-2 mt-2 rounded-full bg-primary" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>{{ f.contract.person_full_name }}</span>
|
||||
</ItemTitle>
|
||||
<ItemDescription class="flex gap-1">
|
||||
<Badge>{{ f.contract.reference }}</Badge>
|
||||
<Badge variant="outline">{{ formatJobTime(f.created_at) }}</Badge>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon class="size-4" />
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div
|
||||
v-if="!fieldJobsAssignedToday?.length"
|
||||
class="py-4 text-xs text-gray-500 text-center"
|
||||
>
|
||||
Ni zabeleženih primerov.
|
||||
</div>
|
||||
</AppCard>
|
||||
</template>
|
||||
49
resources/js/Pages/Dashboard/Partials/SimpleKpiCard.vue
Normal file
49
resources/js/Pages/Dashboard/Partials/SimpleKpiCard.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script setup>
|
||||
import { Card, CardContent } from "@/Components/ui/card";
|
||||
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
value: [String, Number],
|
||||
icon: Object,
|
||||
iconBg: {
|
||||
type: String,
|
||||
default: "bg-primary/10",
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: "text-primary",
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="hover:border-primary/30 hover:shadow transition">
|
||||
<CardContent class="px-4 py-5 flex items-center gap-4">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center justify-center h-10 w-10 rounded-md transition',
|
||||
iconBg,
|
||||
iconColor,
|
||||
]"
|
||||
>
|
||||
<component :is="icon" class="w-5 h-5" />
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-muted-foreground uppercase tracking-wide truncate">
|
||||
{{ label }}
|
||||
</p>
|
||||
<p
|
||||
v-if="!loading"
|
||||
class="text-2xl font-semibold tracking-tight text-foreground mt-1"
|
||||
>
|
||||
{{ value ?? "—" }}
|
||||
</p>
|
||||
<div v-else class="h-8 w-20 bg-muted animate-pulse rounded mt-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
110
resources/js/Pages/Dashboard/Partials/SmsOverview.vue
Normal file
110
resources/js/Pages/Dashboard/Partials/SmsOverview.vue
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<script setup>
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from "@/Components/ui/item";
|
||||
import {
|
||||
MessageSquare,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
MessageCircle,
|
||||
Smartphone,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
smsStats: { type: Array, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-2"
|
||||
header-class="py-3! px-4 border-b gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Smartphone size="18" />
|
||||
<CardTitle class="uppercase">SMS stanje </CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="smsStats?.length">
|
||||
<div v-for="p in smsStats" :key="p.id" class="flex flex-col rounded-lg bg-card">
|
||||
<Item variant="outline" size="lg" class="rounded-t-none border-t-0">
|
||||
<ItemContent class="gap-0">
|
||||
<ItemTitle
|
||||
class="w-full flex flex-row items-center justify-between border-b py-2 px-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-base">{{ p.name }}</span>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="
|
||||
p.active
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
"
|
||||
>
|
||||
{{ p.active ? "Aktiven" : "Neaktiven" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-right flex-1">
|
||||
<div class="text-xs text-muted-foreground">Bilanca</div>
|
||||
<div class="text-xl font-bold">{{ p.balance ?? "—" }}</div>
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<!-- Stats grid -->
|
||||
<ItemDescription>
|
||||
<div class="grid grid-cols-4 divide-x">
|
||||
<div class="flex flex-row items-center justify-between p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-muted-foreground text-xs">Skupaj</span>
|
||||
<span class="text-xl font-bold">{{ p.today?.total ?? 0 }}</span>
|
||||
</div>
|
||||
<MessageSquare class="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
<div class="flex flex-row items-center justify-between p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-muted-foreground text-xs">Poslano</span>
|
||||
<span class="text-xl font-bold">{{ p.today?.sent ?? 0 }}</span>
|
||||
</div>
|
||||
<Send class="h-5 w-5 text-sky-600" />
|
||||
</div>
|
||||
<div class="flex flex-row items-center justify-between p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-muted-foreground text-xs">Delivered</span>
|
||||
<span class="text-xl font-bold">{{ p.today?.delivered ?? 0 }}</span>
|
||||
</div>
|
||||
<CheckCircle class="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
<div class="flex flex-row items-center justify-between p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-muted-foreground text-xs">Failed</span>
|
||||
<span class="text-xl font-bold">{{ p.today?.failed ?? 0 }}</span>
|
||||
</div>
|
||||
<XCircle class="h-5 w-5 text-rose-600" />
|
||||
</div>
|
||||
</div>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-500 p-4">Ni podatkov o SMS.</div>
|
||||
</AppCard>
|
||||
</template>
|
||||
38
tests/Feature/ClientContractsPaginationTest.php
Normal file
38
tests/Feature/ClientContractsPaginationTest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('supports contracts_page and contracts_per_page parameters', function () {
|
||||
// Create a client with a single client case and multiple contracts
|
||||
$client = Client::factory()->create();
|
||||
$case = ClientCase::factory()->create(['client_id' => $client->id]);
|
||||
|
||||
// Create 5 contracts tied to the case with deterministic references
|
||||
foreach (range(1, 5) as $i) {
|
||||
Contract::factory()->create([
|
||||
'client_case_id' => $case->id,
|
||||
'reference' => 'REF-'.$i,
|
||||
]);
|
||||
}
|
||||
|
||||
// Request page 2 with page size 2 using custom param names
|
||||
$response = $this->get(route('client.contracts', ['client' => $client->uuid, 'contracts_page' => 2, 'contracts_per_page' => 2]));
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Extract props
|
||||
$props = $response->inertia()->toArray()['props'];
|
||||
expect($props['contracts']['per_page'])->toBe(2)
|
||||
->and($props['contracts']['current_page'])->toBe(2)
|
||||
->and($props['contracts']['data'])->toHaveCount(2);
|
||||
|
||||
// Ensure links use the custom page parameter name
|
||||
$anyLinkWithCustomParam = collect($props['contracts']['links'])->contains(function ($link) {
|
||||
return str_contains($link['url'] ?? '', 'contracts_page=');
|
||||
});
|
||||
expect($anyLinkWithCustomParam)->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user