Dashboard final version, TODO: update main sidebar menu

This commit is contained in:
Simon Pocrnjič 2025-11-23 21:33:01 +01:00
parent c3de189e9d
commit c1ac92efbf
67 changed files with 5195 additions and 844 deletions

1
.gitignore vendored
View File

@ -19,3 +19,4 @@ yarn-error.log
/.idea
/.vscode
/.zed
/shadcn-vue

View File

@ -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,

View File

@ -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();
});
},
]);
}

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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
View 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);
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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 -->

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@ -0,0 +1,8 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue";
</script>
<template>
<div></div>
</template>
<style></style>

View 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>

View 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>

View File

@ -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" },

View File

@ -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: {

View File

@ -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 />

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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";

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}
}

View 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";

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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",
},
},
);

View 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>

View 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>

View File

@ -0,0 +1,2 @@
export { default as ScrollArea } from "./ScrollArea.vue";
export { default as ScrollBar } from "./ScrollBar.vue";

View File

@ -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"

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View File

@ -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>

View 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>

View 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>

View 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>

View 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();
});