changes to UI mostly

This commit is contained in:
Simon Pocrnjič 2025-09-30 22:00:03 +02:00
parent 53917f2ca0
commit db99a57030
18 changed files with 2169 additions and 777 deletions

View File

@ -20,15 +20,43 @@ class ClientCaseContoller extends Controller
*/ */
public function index(ClientCase $clientCase, Request $request) public function index(ClientCase $clientCase, Request $request)
{ {
$query = $clientCase::query()
->with(['person', 'client.person'])
->where('active', 1)
->when($request->input('search'), function ($que, $search) {
$que->whereHas('person', function ($q) use ($search) {
$q->where('full_name', 'ilike', '%'.$search.'%');
});
})
->addSelect([
// Count of active contracts (a contract is considered active if it has an active pivot in contract_segment)
'active_contracts_count' => \DB::query()
->from('contracts')
->selectRaw('COUNT(*)')
->whereColumn('contracts.client_case_id', 'client_cases.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
// Sum of balances for accounts of active contracts
'active_contracts_balance_sum' => \DB::query()
->from('contracts')
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
->whereColumn('contracts.client_case_id', 'client_cases.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
])
->orderByDesc('created_at');
return Inertia::render('Cases/Index', [ return Inertia::render('Cases/Index', [
'client_cases' => $clientCase::with(['person']) 'client_cases' => $query
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
'person',
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
)
)
->where('active', 1)
->orderByDesc('created_at')
->paginate(15, ['*'], 'client-cases-page') ->paginate(15, ['*'], 'client-cases-page')
->withQueryString(), ->withQueryString(),
'filters' => $request->only(['search']), 'filters' => $request->only(['search']),
@ -122,7 +150,9 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ
}); });
return to_route('clientCase.show', $clientCase); // Preserve segment filter if present
$segment = request('segment');
return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]);
} }
public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request) public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request)
@ -163,7 +193,9 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr
}); });
return to_route('clientCase.show', $clientCase); // Preserve segment filter if present
$segment = request('segment');
return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]);
} }
public function storeActivity(ClientCase $clientCase, Request $request) public function storeActivity(ClientCase $clientCase, Request $request)
@ -219,6 +251,20 @@ public function storeActivity(ClientCase $clientCase, Request $request)
} }
public function deleteActivity(ClientCase $clientCase, \App\Models\Activity $activity, Request $request)
{
// Ensure activity belongs to this case
if ($activity->client_case_id !== $clientCase->id) {
abort(404);
}
\DB::transaction(function () use ($activity) {
$activity->delete();
});
return back()->with('success', 'Activity deleted.');
}
public function deleteContract(ClientCase $clientCase, string $uuid, Request $request) public function deleteContract(ClientCase $clientCase, string $uuid, Request $request)
{ {
$contract = Contract::where('uuid', $uuid)->firstOrFail(); $contract = Contract::where('uuid', $uuid)->firstOrFail();
@ -227,7 +273,9 @@ public function deleteContract(ClientCase $clientCase, string $uuid, Request $re
$contract->delete(); $contract->delete();
}); });
return to_route('clientCase.show', $clientCase); // Preserve segment filter if present
$segment = request('segment');
return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]);
} }
public function updateContractSegment(ClientCase $clientCase, string $uuid, Request $request) public function updateContractSegment(ClientCase $clientCase, string $uuid, Request $request)
@ -923,11 +971,25 @@ public function show(ClientCase $clientCase)
'phone_types' => \App\Models\Person\PhoneType::all(), 'phone_types' => \App\Models\Person\PhoneType::all(),
]; ];
// Optional segment filter from query string
$segmentId = request()->integer('segment');
// Prepare contracts and a reference map // Prepare contracts and a reference map
$contracts = $case->contracts() $contractsQuery = $case->contracts()
->with(['type', 'account', 'objects', 'segments:id,name']) ->with(['type', 'account', 'objects', 'segments:id,name'])
->orderByDesc('created_at') ->orderByDesc('created_at');
->get();
if (! empty($segmentId)) {
// Filter to contracts that are in the provided segment and active on pivot
$contractsQuery->whereExists(function ($q) use ($segmentId) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.segment_id', $segmentId)
->where('contract_segment.active', true);
});
}
$contracts = $contractsQuery->get();
$contractRefMap = []; $contractRefMap = [];
foreach ($contracts as $c) { foreach ($contracts as $c) {
$contractRefMap[$c->id] = $c->reference; $contractRefMap[$c->id] = $c->reference;
@ -937,11 +999,11 @@ public function show(ClientCase $clientCase)
$contractIds = $contracts->pluck('id'); $contractIds = $contracts->pluck('id');
$contractDocs = Document::query() $contractDocs = Document::query()
->where('documentable_type', Contract::class) ->where('documentable_type', Contract::class)
->whereIn('documentable_id', $contractIds) ->when($contractIds->isNotEmpty(), fn ($q) => $q->whereIn('documentable_id', $contractIds))
->orderByDesc('created_at') ->orderByDesc('created_at')
->get() ->get()
->map(function ($d) use ($contractRefMap) { ->map(function ($d) use ($contractRefMap) {
$arr = $d->toArray(); $arr = method_exists($d, 'toArray') ? $d->toArray() : (array) $d;
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null; $arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
$arr['documentable_type'] = Contract::class; $arr['documentable_type'] = Contract::class;
$arr['contract_uuid'] = optional(Contract::withTrashed()->find($d->documentable_id))->uuid; $arr['contract_uuid'] = optional(Contract::withTrashed()->find($d->documentable_id))->uuid;
@ -950,7 +1012,7 @@ public function show(ClientCase $clientCase)
}); });
$caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) { $caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) {
$arr = $d->toArray(); $arr = method_exists($d, 'toArray') ? $d->toArray() : (array) $d;
$arr['documentable_type'] = ClientCase::class; $arr['documentable_type'] = ClientCase::class;
$arr['client_case_uuid'] = $case->uuid; $arr['client_case_uuid'] = $case->uuid;
@ -961,15 +1023,32 @@ public function show(ClientCase $clientCase)
->sortByDesc('created_at') ->sortByDesc('created_at')
->values(); ->values();
// Resolve current segment for display when filtered
$currentSegment = null;
if (! empty($segmentId)) {
$currentSegment = \App\Models\Segment::query()->select('id', 'name')->find($segmentId);
}
return Inertia::render('Cases/Show', [ return Inertia::render('Cases/Show', [
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'bankAccounts']))->firstOrFail(), 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'bankAccounts']))->firstOrFail(),
'client_case' => $case, 'client_case' => $case,
'contracts' => $contracts, 'contracts' => $contracts,
'activities' => tap( 'activities' => tap(
$case->activities() (function () use ($case, $segmentId, $contractIds) {
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name']) $q = $case->activities()
->orderByDesc('created_at') ->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->paginate(20, ['*'], 'activities'), ->orderByDesc('created_at');
if (! empty($segmentId)) {
// Only activities for filtered contracts or unlinked (contract_id null)
$q->where(function ($qq) use ($contractIds) {
$qq->whereNull('contract_id');
if ($contractIds->isNotEmpty()) {
$qq->orWhereIn('contract_id', $contractIds);
}
});
}
return $q->paginate(20, ['*'], 'activities')->withQueryString();
})(),
function ($p) { function ($p) {
$p->getCollection()->transform(function ($a) { $p->getCollection()->transform(function ($a) {
$a->setAttribute('user_name', optional($a->user)->name); $a->setAttribute('user_name', optional($a->user)->name);
@ -984,6 +1063,7 @@ function ($p) {
'types' => $types, 'types' => $types,
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']), 'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']), 'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
'current_segment' => $currentSegment,
]); ]);
} }

View File

@ -11,16 +11,48 @@ class ClientController extends Controller
{ {
public function index(Client $client, Request $request) public function index(Client $client, Request $request)
{ {
$query = $client::query()
->with('person')
->when($request->input('search'), function ($que, $search) {
$que->whereHas('person', function ($q) use ($search) {
$q->where('full_name', 'ilike', '%'.$search.'%');
});
})
->where('active', 1)
->addSelect([
// Number of client cases for this client that have at least one active contract
'cases_with_active_contracts_count' => DB::query()
->from('client_cases')
->join('contracts', 'contracts.client_case_id', '=', 'client_cases.id')
->selectRaw('COUNT(DISTINCT client_cases.id)')
->whereColumn('client_cases.client_id', 'clients.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
// Sum of account balances for active contracts that belong to this client's cases
'active_contracts_balance_sum' => DB::query()
->from('contracts')
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
->whereExists(function ($q) {
$q->from('client_cases')
->whereColumn('client_cases.id', 'contracts.client_case_id')
->whereColumn('client_cases.client_id', 'clients.id');
})
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
])
->orderByDesc('created_at');
return Inertia::render('Client/Index', [ return Inertia::render('Client/Index', [
'clients' => $client::query() 'clients' => $query
->with('person')
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
'person',
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
)
)
->where('active', 1)
->orderByDesc('created_at')
->paginate(15) ->paginate(15)
->withQueryString(), ->withQueryString(),
'filters' => $request->only(['search']), 'filters' => $request->only(['search']),
@ -42,12 +74,35 @@ public function show(Client $client, Request $request)
return Inertia::render('Client/Show', [ return Inertia::render('Client/Show', [
'client' => $data, 'client' => $data,
'client_cases' => $data->clientCases() 'client_cases' => $data->clientCases()
->with('person') ->with(['person', 'client.person'])
->when($request->input('search'), fn ($que, $search) => $que->whereHas( ->when($request->input('search'), fn ($que, $search) => $que->whereHas(
'person', 'person',
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%') fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
) )
) )
->addSelect([
'active_contracts_count' => \DB::query()
->from('contracts')
->selectRaw('COUNT(*)')
->whereColumn('contracts.client_case_id', 'client_cases.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
'active_contracts_balance_sum' => \DB::query()
->from('contracts')
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
->whereColumn('contracts.client_case_id', 'client_cases.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
])
->where('active', 1) ->where('active', 1)
->orderByDesc('created_at') ->orderByDesc('created_at')
->paginate(15) ->paginate(15)

View File

@ -24,7 +24,7 @@ public function index(Request $request)
])->filter()->unique()->values(); ])->filter()->unique()->values();
$contracts = Contract::query() $contracts = Contract::query()
->with(['clientCase.person', 'type', 'account']) ->with(['clientCase.person', 'clientCase.client.person', 'type', 'account'])
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) { ->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
$q->whereHas('segments', function ($sq) use ($segmentIds) { $q->whereHas('segments', function ($sq) use ($segmentIds) {
// Relation already filters on active pivots // Relation already filters on active pivots
@ -38,6 +38,13 @@ public function index(Request $request)
->limit(50) ->limit(50)
->get(); ->get();
// Mirror client onto the contract for simpler frontend access: c.client.person.full_name
$contracts->each(function (Contract $contract): void {
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
$contract->setRelation('client', $contract->clientCase->client);
}
});
// Build active assignment map keyed by contract uuid for quicker UI checks // Build active assignment map keyed by contract uuid for quicker UI checks
$assignments = collect(); $assignments = collect();
if ($contracts->isNotEmpty()) { if ($contracts->isNotEmpty()) {

View File

@ -2,14 +2,98 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Segment;
use App\Http\Requests\StoreSegmentRequest; use App\Http\Requests\StoreSegmentRequest;
use App\Http\Requests\UpdateSegmentRequest; use App\Http\Requests\UpdateSegmentRequest;
use App\Models\Segment;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
class SegmentController extends Controller class SegmentController extends Controller
{ {
public function index()
{
// Fetch active segments with number of active contracts and total balance sum of those contracts
// A contract is considered in a segment when the pivot is active=true.
$segments = Segment::query()
->where('active', true)
->withCount(['contracts as contracts_count' => function ($q) {
// On some drivers, wherePivot can compile oddly inside withCount; target the pivot table directly
$q->where('contract_segment.active', '=', 1);
}])
->get(['id', 'name', 'description']);
// Compute total balance per segment for active contracts
$balances = DB::table('segments')
->join('contract_segment', 'contract_segment.segment_id', '=', 'segments.id')
->join('contracts', 'contracts.id', '=', 'contract_segment.contract_id')
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->where('segments.active', '=', 1)
->where('contract_segment.active', '=', 1)
->groupBy('segments.id')
->pluck(DB::raw('COALESCE(SUM(accounts.balance_amount),0) as total_balance'), 'segments.id');
$segments = $segments->map(function ($seg) use ($balances) {
$seg->total_balance = (string) ($balances[$seg->id] ?? 0);
return $seg;
});
return Inertia::render('Segments/Index', [
'segments' => $segments,
]);
}
public function show(\App\Models\Segment $segment)
{
// Retrieve contracts that are active in this segment, eager-loading required relations
$search = request('search');
$contractsQuery = \App\Models\Contract::query()
->whereHas('segments', function ($q) use ($segment) {
$q->where('segments.id', $segment->id)
->where('contract_segment.active', '=', 1);
})
->with([
'clientCase.person',
'clientCase.client.person',
'type',
'account',
])
->latest('id');
if (!empty($search)) {
$contractsQuery->where(function ($qq) use ($search) {
$qq->where('contracts.reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
})
->orWhereHas('clientCase.client.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
});
});
}
$contracts = $contractsQuery
->paginate(15)
->withQueryString();
// Mirror client onto the contract to simplify frontend access (c.client.person.full_name)
$items = collect($contracts->items());
$items->each(function ($contract) {
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
$contract->setRelation('client', $contract->clientCase->client);
}
});
if (method_exists($contracts, 'setCollection')) {
$contracts->setCollection($items);
}
return Inertia::render('Segments/Show', [
'segment' => $segment->only(['id','name','description']),
'contracts' => $contracts,
]);
}
public function settings(Request $request) public function settings(Request $request)
{ {
return Inertia::render('Settings/Segments/Index', [ return Inertia::render('Settings/Segments/Index', [
@ -41,4 +125,3 @@ public function update(UpdateSegmentRequest $request, Segment $segment)
return to_route('settings.segments')->with('success', 'Segment updated'); return to_route('settings.segments')->with('success', 'Segment updated');
} }
} }

View File

@ -1,76 +1,200 @@
<script setup> <script setup>
import { Link } from '@inertiajs/vue3'; import { Link } from "@inertiajs/vue3";
import { computed } from "vue";
const props = defineProps({ const props = defineProps({
links: Array, links: { type: Array, default: () => [] },
from: Number, from: { type: Number, default: 0 },
to: Number, to: { type: Number, default: 0 },
total: Number total: { type: Number, default: 0 },
}); });
const num = props.links.length; const num = props.links?.length || 0;
const prevLink = computed(() => (num > 0 ? props.links[0] : null));
const nextLink = computed(() => (num > 1 ? props.links[num - 1] : null));
const numericLinks = computed(() => {
if (num < 3) return [];
return props.links
.slice(1, num - 1)
.map((l) => ({
...l,
page: Number.parseInt(String(l.label).replace(/[^0-9]/g, ""), 10),
}))
.filter((l) => !Number.isNaN(l.page));
});
const currentPage = computed(() => numericLinks.value.find((l) => l.active)?.page || 1);
const lastPage = computed(() =>
numericLinks.value.length ? Math.max(...numericLinks.value.map((l) => l.page)) : 1
);
const linkByPage = computed(() => {
const m = new Map();
for (const l of numericLinks.value) m.set(l.page, l);
return m;
});
const windowItems = computed(() => {
const items = [];
const cur = currentPage.value;
const last = lastPage.value;
const show = new Set([1, last, cur - 1, cur, cur + 1]);
if (cur <= 3) {
show.add(2);
show.add(3);
}
if (cur >= last - 2) {
show.add(last - 1);
show.add(last - 2);
}
// Prev
items.push({ kind: "prev", link: prevLink.value });
// Pages with ellipses
let inGap = false;
for (let p = 1; p <= last; p++) {
if (show.has(p)) {
items.push({
kind: "page",
link: linkByPage.value.get(p) || {
url: null,
label: String(p),
active: p === cur,
},
});
inGap = false;
} else if (!inGap) {
items.push({ kind: "ellipsis" });
inGap = true;
}
}
// Next
items.push({ kind: "next", link: nextLink.value });
return items;
});
</script> </script>
<template> <template>
<div class="flex items-center justify-between bg-white px-4 py-3 sm:px-6"> <div class="flex items-center justify-between bg-white px-4 py-3 sm:px-6">
<div class="flex flex-1 justify-between sm:hidden"> <!-- Mobile: Prev / Next -->
<a href="#" class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">Previous</a> <div class="flex flex-1 justify-between sm:hidden">
<a href="#" class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">Next</a> <component
</div> :is="links?.[0]?.url ? Link : 'span'"
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between"> :href="links?.[0]?.url"
<div> :aria-disabled="!links?.[0]?.url"
<p class="text-sm text-gray-700"> :tabindex="!links?.[0]?.url ? -1 : 0"
Showing class="relative inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium"
<span class="font-medium">{{ from }}</span> :class="
to links?.[0]?.url
<span class="font-medium">{{ to }}</span> ? 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
of : 'border-gray-200 bg-gray-100 text-gray-400'
<span class="font-medium">{{ total }}</span> "
results >
</p> Prejšnja
</div> </component>
<div> <component
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination"> :is="links?.[num - 1]?.url ? Link : 'span'"
<template v-for="(link, index) in links"> :href="links?.[num - 1]?.url"
<component :aria-disabled="!links?.[num - 1]?.url"
:is="link.url ? Link : 'span'" :tabindex="!links?.[num - 1]?.url ? -1 : 0"
v-if="index === 0 || index === (num - 1)" class="relative ml-3 inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium"
:href="link.url" :class="
class="relative inline-flex items-center px-2 py-2 ring-1 ring-inset ring-gray-300 focus:z-20 focus:outline-offset-0" links?.[num - 1]?.url
:class="{ ? 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
'rounded-l-md': index === 0, : 'border-gray-200 bg-gray-100 text-gray-400'
'rounded-r-md': index === (num - 1), "
'text-gray-900 hover:bg-gray-50': link.url, >
'text-gray-400 bg-gray-100': ! link.url}" Naslednja
> </component>
<span class="sr-only">
{{ index === 0 ? 'Previous' : 'Next' }}
</span>
<svg v-if="index === 0" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" data-slot="icon">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
</svg>
<svg v-else class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" data-slot="icon">
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
</component>
<component
v-else
:is="link.url ? Link : 'span'"
:href="link.url"
v-html="link.label"
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:outline-offset-0"
:class="{
'text-gray-700 ring-1 ring-inset ring-gray-300': ! link.url,
'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20': link.url && ! link.active,
'z-10 bg-blue-600 text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600': link.url && link.active
}"
>
</component>
</template>
</nav>
</div>
</div>
</div> </div>
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Showing
<span class="font-medium">{{ from }}</span>
to
<span class="font-medium">{{ to }}</span>
of
<span class="font-medium">{{ total }}</span>
results
</p>
</div>
<div>
<nav
class="isolate inline-flex -space-x-px rounded-md shadow-sm"
aria-label="Pagination"
>
<template v-for="(item, idx) in windowItems" :key="idx">
<!-- Prev / Next -->
<component
v-if="item.kind === 'prev' || item.kind === 'next'"
:is="item.link?.url ? Link : 'span'"
:href="item.link?.url"
class="relative inline-flex items-center px-2 py-2 ring-1 ring-inset ring-gray-300 focus:z-20 focus:outline-offset-0"
:class="{
'rounded-l-md': item.kind === 'prev',
'rounded-r-md': item.kind === 'next',
'text-gray-900 hover:bg-gray-50': item.link?.url,
'text-gray-400 bg-gray-100': !item.link?.url,
}"
>
<span class="sr-only">{{
item.kind === "prev" ? "Prejšnja" : "Naslednja"
}}</span>
<svg
v-if="item.kind === 'prev'"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
<svg
v-else
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</component>
<!-- Ellipsis -->
<span
v-else-if="item.kind === 'ellipsis'"
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-500 ring-1 ring-inset ring-gray-200 select-none"
></span
>
<!-- Page number -->
<component
v-else-if="item.kind === 'page'"
:is="item.link?.url ? Link : 'span'"
:href="item.link?.url"
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:outline-offset-0"
:class="{
'text-gray-700 ring-1 ring-inset ring-gray-300': !item.link?.url,
'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20':
item.link?.url && !item.link?.active,
'z-10 bg-blue-600 text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600':
item.link?.active,
}"
>
{{ item.link?.label || "" }}
</component>
</template>
</nav>
</div>
</div>
</div>
</template> </template>

View File

@ -145,6 +145,14 @@ watch(
<span v-if="!sidebarCollapsed">Nadzorna plošča</span> <span v-if="!sidebarCollapsed">Nadzorna plošča</span>
</Link> </Link>
</li> </li>
<li>
<Link :href="route('segments.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('segments.index') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Segmenti">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 3h7v7H3V3zm11 0h7v7h-7V3zM3 14h7v7H3v-7zm11 0h7v7h-7v-7z" />
</svg>
<span v-if="!sidebarCollapsed">Segmenti</span>
</Link>
</li>
<li> <li>
<Link :href="route('client')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('client') || route().current('client.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Naročniki"> <Link :href="route('client')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('client') || route().current('client.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Naročniki">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">

View File

@ -1,59 +1,121 @@
<script setup> <script setup>
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from "@/Layouts/AppLayout.vue";
import List from '@/Components/List.vue'; import Pagination from "@/Components/Pagination.vue";
import ListItem from '@/Components/ListItem.vue'; import SectionTitle from "@/Components/SectionTitle.vue";
import Pagination from '@/Components/Pagination.vue'; import { Link, router } from "@inertiajs/vue3";
import SearchInput from '@/Components/SearchInput.vue'; import { debounce } from "lodash";
import SectionTitle from '@/Components/SectionTitle.vue'; import { ref, watch, onUnmounted } from "vue";
const props = defineProps({ const props = defineProps({
client_cases: Object, client_cases: Object,
filters: Object filters: Object,
}); });
const search = { // Search state (initialize from server-provided filters)
search: props.filters.search, const search = ref(props.filters?.search || "");
route: { const applySearch = debounce((term) => {
link: 'clientCase' const params = Object.fromEntries(
} new URLSearchParams(window.location.search).entries()
} );
if (term) {
params.search = term;
} else {
delete params.search;
}
// Reset paginator key used by backend: 'client-cases-page'
delete params["client-cases-page"];
delete params.page;
router.get(route("clientCase"), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
}, 300);
watch(search, (v) => applySearch(v));
onUnmounted(() => applySearch.cancel && applySearch.cancel());
// Format helpers
const fmtCurrency = (v) => {
const n = Number(v ?? 0);
try {
return new Intl.NumberFormat("sl-SI", { style: "currency", currency: "EUR" }).format(
n
);
} catch (e) {
return `${n.toFixed(2)}`;
}
};
</script> </script>
<template> <template>
<AppLayout title="Client cases"> <AppLayout title="Client cases">
<template #header> <template #header> </template>
<h2 class="font-semibold text-xl text-gray-800 leading-tight"> <div class="py-12">
Contracts <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
</h2> <div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
</template> <div class="mx-auto max-w-4x1 py-3">
<div class="py-12"> <div class="flex items-center justify-between gap-3 pb-3">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <SectionTitle>
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg"> <template #title>Primeri</template>
<div class="mx-auto max-w-4x1 py-3"> </SectionTitle>
<div class="flex justify-end"> <input
<SearchInput :options="search" /> v-model="search"
</div> type="text"
<List> placeholder="Iskanje po imenu"
<ListItem v-for="clientCase in client_cases.data"> class="w-full sm:w-80 rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 text-sm"
<div class="flex justify-between shadow rounded border-solid border-l-4 border-red-400 p-3"> />
<div class="flex min-w-0 gap-x-4">
<div class="min-w-0 flex-auto">
<p class="text-sm font-semibold leading-6 text-gray-900"><a :href="route('clientCase.show', {uuid: clientCase.uuid})">{{ clientCase.person.full_name }}</a></p>
<p class="mt-1 truncate text-xs leading-5 text-gray-500">{{ clientCase.person.nu }}</p>
</div>
</div>
<div class="hidden shrink-0 sm:flex sm:flex-col sm:items-end">
<p class="text-sm leading-6 text-gray-900">{{ clientCase.person.tax_number }}</p>
<div class="mt-1 flex items-center gap-x-1.5">
<p class="text-xs leading-5 text-gray-500">{{ clientCase.person.name }}</p>
</div>
</div>
</div>
</ListItem>
</List>
</div>
<Pagination :links="client_cases.links" :from="client_cases.from" :to="client_cases.to" :total="client_cases.total" />
</div>
</div> </div>
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">Št.</th>
<th class="py-2 pr-4">Primer</th>
<th class="py-2 pr-4">Stranka</th>
<th class="py-2 pr-4">Davčna</th>
<th class="py-2 pr-4 text-right">Aktivne pogodbe</th>
<th class="py-2 pr-4 text-right">Skupaj stanje</th>
</tr>
</thead>
<tbody>
<tr
v-for="c in client_cases.data"
:key="c.uuid"
class="border-b last:border-0"
>
<td class="py-2 pr-4">{{ c.person?.nu || "-" }}</td>
<td class="py-2 pr-4">
<Link
:href="route('clientCase.show', { client_case: c.uuid })"
class="text-indigo-600 hover:underline"
>
{{ c.person?.full_name || "-" }}
</Link>
</td>
<td class="py-2 pr-4">{{ c.client?.person?.full_name || "-" }}</td>
<td class="py-2 pr-4">{{ c.person?.tax_number || "-" }}</td>
<td class="py-2 pr-4 text-right">
{{ c.active_contracts_count ?? 0 }}
</td>
<td class="py-2 pr-4 text-right">
{{ fmtCurrency(c.active_contracts_balance_sum) }}
</td>
</tr>
<tr v-if="!client_cases.data || client_cases.data.length === 0">
<td colspan="6" class="py-4 text-gray-500">Ni zadetkov.</td>
</tr>
</tbody>
</table>
</div>
</div>
<Pagination
:links="client_cases.links"
:from="client_cases.from"
:to="client_cases.to"
:total="client_cases.total"
/>
</div> </div>
</AppLayout> </div>
</div>
</AppLayout>
</template> </template>

View File

@ -1,53 +1,163 @@
<script setup> <script setup>
import BasicTable from '@/Components/BasicTable.vue'; import { ref } from "vue";
import { LinkOptions as C_LINK, TableColumn as C_TD, TableRow as C_TR} from '@/Shared/AppObjects'; import { router } from "@inertiajs/vue3";
import Dropdown from "@/Components/Dropdown.vue";
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
import SecondaryButton from "@/Components/SecondaryButton.vue";
import DangerButton from "@/Components/DangerButton.vue";
import {
FwbTable,
FwbTableHead,
FwbTableHeadCell,
FwbTableBody,
FwbTableRow,
FwbTableCell,
} from "flowbite-vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faTrash, faEllipsisVertical } from "@fortawesome/free-solid-svg-icons";
library.add(faTrash, faEllipsisVertical);
const props = defineProps({ const props = defineProps({
client_case: Object, client_case: Object,
activities: Object activities: Object,
}); });
// Dropdown component manages its own open/close state
let header = [ const fmtDate = (d) => {
C_TD.make('Pogodba', 'header'), if (!d) return "";
C_TD.make('Datum', 'header'), try {
C_TD.make('Akcija', 'header'), return new Date(d).toLocaleDateString("sl-SI");
C_TD.make('Odločitev', 'header'), } catch (e) {
C_TD.make('Opomba', 'header'), return String(d);
C_TD.make('Datum zapadlosti', 'header'), }
C_TD.make('Znesek obljube', 'header'), };
C_TD.make('Dodal', 'header') const fmtCurrency = (v) => {
]; const n = Number(v ?? 0);
try {
return new Intl.NumberFormat("sl-SI", { style: "currency", currency: "EUR" }).format(
n
);
} catch {
return `${n.toFixed(2)}`;
}
};
const createBody = (data) => { const deleteActivity = (row) => {
let body = []; if (!row?.id) return;
router.delete(
data.forEach((p) => { route("clientCase.activity.delete", {
const createdDate = new Date(p.created_at).toLocaleDateString('de'); client_case: props.client_case.uuid,
const dueDate = (p.due_date) ? new Date().toLocaleDateString('de') : null; activity: row.id,
const userName = (p.user && p.user.name) ? p.user.name : (p.user_name || ''); }),
{
const cols = [ preserveScroll: true,
C_TD.make(p.contract?.reference ?? ''), }
C_TD.make(createdDate, 'body' ), );
C_TD.make(p.action.name, 'body'), };
C_TD.make(p.decision.name, 'body'),
C_TD.make(p.note, 'body' ),
C_TD.make(dueDate, 'body' ),
C_TD.make(Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(p.amount), 'body' ),
C_TD.make(userName, 'body')
];
body.push(
C_TR.make(cols)
)
});
return body;
}
// Confirmation modal state and handlers
const confirmDelete = ref(false);
const toDeleteRow = ref(null);
const openDelete = (row) => {
toDeleteRow.value = row;
confirmDelete.value = true;
};
const cancelDelete = () => {
confirmDelete.value = false;
toDeleteRow.value = null;
};
const confirmDeleteAction = () => {
if (toDeleteRow.value) {
deleteActivity(toDeleteRow.value);
}
confirmDelete.value = false;
toDeleteRow.value = null;
};
</script> </script>
<template> <template>
<BasicTable :header="header" :body="createBody(activities.data)" /> <div class="overflow-x-auto">
<FwbTable hoverable striped class="min-w-full text-left text-sm">
<FwbTableHead>
<FwbTableHeadCell class="py-2 pr-4">Pogodba</FwbTableHeadCell>
<FwbTableHeadCell class="py-2 pr-4">Datum</FwbTableHeadCell>
<FwbTableHeadCell class="py-2 pr-4">Akcija</FwbTableHeadCell>
<FwbTableHeadCell class="py-2 pr-4">Odločitev</FwbTableHeadCell>
<FwbTableHeadCell class="py-2 pr-4">Opomba</FwbTableHeadCell>
<FwbTableHeadCell class="py-2 pr-4">Datum zapadlosti</FwbTableHeadCell>
<FwbTableHeadCell class="py-2 pr-4 text-right">Znesek obljube</FwbTableHeadCell>
<FwbTableHeadCell class="py-2 pr-4">Dodal</FwbTableHeadCell>
<FwbTableHeadCell class="py-2 pl-2 pr-2 w-8 text-right"></FwbTableHeadCell>
</FwbTableHead>
<FwbTableBody>
<FwbTableRow v-for="row in activities.data" :key="row.id" class="border-b">
<FwbTableCell class="py-2 pr-4">{{
row.contract?.reference || ""
}}</FwbTableCell>
<FwbTableCell class="py-2 pr-4">{{ fmtDate(row.created_at) }}</FwbTableCell>
<FwbTableCell class="py-2 pr-4">{{ row.action?.name || "" }}</FwbTableCell>
<FwbTableCell class="py-2 pr-4">{{ row.decision?.name || "" }}</FwbTableCell>
<FwbTableCell class="py-2 pr-4">{{ row.note || "" }}</FwbTableCell>
<FwbTableCell class="py-2 pr-4">{{ fmtDate(row.due_date) }}</FwbTableCell>
<FwbTableCell class="py-2 pr-4 text-right">{{
fmtCurrency(row.amount)
}}</FwbTableCell>
<FwbTableCell class="py-2 pr-4">{{
row.user?.name || row.user_name || ""
}}</FwbTableCell>
<FwbTableCell class="py-2 pl-2 pr-2 text-right">
<Dropdown align="right" width="30" :content-classes="['py-1', 'bg-white']">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-gray-100"
aria-haspopup="menu"
>
<FontAwesomeIcon
:icon="['fas', 'ellipsis-vertical']"
class="text-gray-600 text-[20px]"
/>
</button>
</template>
<template #content>
<button
type="button"
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-red-50 text-red-600"
@click.stop="openDelete(row)"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="text-[16px]" />
<span>Izbriši</span>
</button>
</template>
</Dropdown>
</FwbTableCell>
</FwbTableRow>
<FwbTableRow v-if="!activities?.data || activities.data.length === 0">
<FwbTableCell :colspan="9" class="py-4 text-gray-500"
>Ni aktivnosti.</FwbTableCell
>
</FwbTableRow>
</FwbTableBody>
</FwbTable>
</div>
<!-- Confirm deletion modal -->
<ConfirmationModal :show="confirmDelete" @close="cancelDelete">
<template #title>Potrditev</template>
<template #content>
Ali ste prepričani, da želite izbrisati to aktivnost? Tega dejanja ni mogoče
razveljaviti.
</template>
<template #footer>
<SecondaryButton type="button" @click="cancelDelete">Prekliči</SecondaryButton>
<DangerButton type="button" class="ml-2" @click="confirmDeleteAction"
>Izbriši</DangerButton
>
</template>
</ConfirmationModal>
</template> </template>

View File

@ -94,10 +94,21 @@ const storeOrUpdate = () => {
}, },
preserveScroll: true, preserveScroll: true,
} }
const params = {}
try {
const url = new URL(window.location.href)
const seg = url.searchParams.get('segment')
if (seg) params.segment = seg
} catch (e) {}
if (isEdit) { if (isEdit) {
formContract.put(route('clientCase.contract.update', { client_case: props.client_case.uuid, uuid: formContract.uuid }), options) formContract.put(route('clientCase.contract.update', { client_case: props.client_case.uuid, uuid: formContract.uuid, ...params }), options)
} else { } else {
formContract.post(route('clientCase.contract.store', props.client_case), options) // route helper merges params for GET; for POST we can append query manually if needed
let postUrl = route('clientCase.contract.store', props.client_case)
if (params.segment) {
postUrl += (postUrl.includes('?') ? '&' : '?') + 'segment=' + encodeURIComponent(params.segment)
}
formContract.post(postUrl, options)
} }
} }

View File

@ -12,11 +12,11 @@ import DocumentsTable from "@/Components/DocumentsTable.vue";
import DocumentUploadDialog from "@/Components/DocumentUploadDialog.vue"; import DocumentUploadDialog from "@/Components/DocumentUploadDialog.vue";
import DocumentViewerDialog from "@/Components/DocumentViewerDialog.vue"; import DocumentViewerDialog from "@/Components/DocumentViewerDialog.vue";
import { classifyDocument } from "@/Services/documents"; import { classifyDocument } from "@/Services/documents";
import { router, useForm } from '@inertiajs/vue3'; import { router, useForm } from "@inertiajs/vue3";
import { AngleDownIcon, AngleUpIcon } from "@/Utilities/Icons"; import { AngleDownIcon, AngleUpIcon } from "@/Utilities/Icons";
import Pagination from "@/Components/Pagination.vue"; import Pagination from "@/Components/Pagination.vue";
import ConfirmDialog from "@/Components/ConfirmDialog.vue"; import ConfirmDialog from "@/Components/ConfirmDialog.vue";
import DialogModal from '@/Components/DialogModal.vue'; import DialogModal from "@/Components/DialogModal.vue";
const props = defineProps({ const props = defineProps({
client: Object, client: Object,
@ -30,33 +30,55 @@ const props = defineProps({
documents: Array, documents: Array,
segments: { type: Array, default: () => [] }, segments: { type: Array, default: () => [] },
all_segments: { type: Array, default: () => [] }, all_segments: { type: Array, default: () => [] },
current_segment: { type: Object, default: null },
}); });
const showUpload = ref(false); const showUpload = ref(false);
const openUpload = () => { showUpload.value = true; }; const openUpload = () => {
const closeUpload = () => { showUpload.value = false; }; showUpload.value = true;
};
const closeUpload = () => {
showUpload.value = false;
};
const onUploaded = () => { const onUploaded = () => {
// Refresh page data to include the new document // Refresh page data to include the new document
router.reload({ only: ['documents'] }); router.reload({ only: ["documents"] });
}; };
const viewer = ref({ open: false, src: '', title: '' }); const viewer = ref({ open: false, src: "", title: "" });
const openViewer = (doc) => { const openViewer = (doc) => {
const kind = classifyDocument(doc) const kind = classifyDocument(doc);
const isContractDoc = (doc?.documentable_type || '').toLowerCase().includes('contract') const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
if (kind === 'preview') { if (kind === "preview") {
const url = isContractDoc && doc.contract_uuid const url =
? route('contract.document.view', { contract: doc.contract_uuid, document: doc.uuid }) isContractDoc && doc.contract_uuid
: route('clientCase.document.view', { client_case: props.client_case.uuid, document: doc.uuid }) ? route("contract.document.view", {
viewer.value = { open: true, src: url, title: doc.original_name || doc.name } contract: doc.contract_uuid,
document: doc.uuid,
})
: route("clientCase.document.view", {
client_case: props.client_case.uuid,
document: doc.uuid,
});
viewer.value = { open: true, src: url, title: doc.original_name || doc.name };
} else { } else {
const url = isContractDoc && doc.contract_uuid const url =
? route('contract.document.download', { contract: doc.contract_uuid, document: doc.uuid }) isContractDoc && doc.contract_uuid
: route('clientCase.document.download', { client_case: props.client_case.uuid, document: doc.uuid }) ? route("contract.document.download", {
window.location.href = url contract: doc.contract_uuid,
document: doc.uuid,
})
: route("clientCase.document.download", {
client_case: props.client_case.uuid,
document: doc.uuid,
});
window.location.href = url;
} }
} };
const closeViewer = () => { viewer.value.open = false; viewer.value.src = ''; }; const closeViewer = () => {
viewer.value.open = false;
viewer.value.src = "";
};
const clientDetails = ref(false); const clientDetails = ref(false);
@ -82,17 +104,36 @@ const openDrawerAddActivity = (c = null) => {
}; };
// delete confirmation // delete confirmation
const confirmDelete = ref({ show: false, contract: null }) const confirmDelete = ref({ show: false, contract: null });
const requestDeleteContract = (c) => { confirmDelete.value = { show: true, contract: c } } const requestDeleteContract = (c) => {
const closeConfirmDelete = () => { confirmDelete.value.show = false; confirmDelete.value.contract = null } confirmDelete.value = { show: true, contract: c };
};
const closeConfirmDelete = () => {
confirmDelete.value.show = false;
confirmDelete.value.contract = null;
};
const doDeleteContract = () => { const doDeleteContract = () => {
const c = confirmDelete.value.contract const c = confirmDelete.value.contract;
if (!c) return closeConfirmDelete() if (!c) return closeConfirmDelete();
router.delete(route('clientCase.contract.delete', { client_case: props.client_case.uuid, uuid: c.uuid }), { // Keep segment filter in redirect
preserveScroll: true, const params = {};
onFinish: () => closeConfirmDelete(), try {
}) const url = new URL(window.location.href);
} const seg = url.searchParams.get("segment");
if (seg) params.segment = seg;
} catch (e) {}
router.delete(
route("clientCase.contract.delete", {
client_case: props.client_case.uuid,
uuid: c.uuid,
...params,
}),
{
preserveScroll: true,
onFinish: () => closeConfirmDelete(),
}
);
};
//Close drawer (all) //Close drawer (all)
const closeDrawer = () => { const closeDrawer = () => {
@ -109,35 +150,52 @@ const hideClietnDetails = () => {
}; };
// Attach segment to case // Attach segment to case
const showAttachSegment = ref(false) const showAttachSegment = ref(false);
const openAttachSegment = () => { showAttachSegment.value = true } const openAttachSegment = () => {
const closeAttachSegment = () => { showAttachSegment.value = false } showAttachSegment.value = true;
const attachForm = useForm({ segment_id: null }) };
const closeAttachSegment = () => {
showAttachSegment.value = false;
};
const attachForm = useForm({ segment_id: null });
const availableSegments = computed(() => { const availableSegments = computed(() => {
const current = new Set((props.segments || []).map(s => s.id)) const current = new Set((props.segments || []).map((s) => s.id));
return (props.all_segments || []).filter(s => !current.has(s.id)) return (props.all_segments || []).filter((s) => !current.has(s.id));
}) });
const submitAttachSegment = () => { const submitAttachSegment = () => {
if (!attachForm.segment_id) { return } if (!attachForm.segment_id) {
attachForm.post(route('clientCase.segments.attach', props.client_case), { return;
}
attachForm.post(route("clientCase.segments.attach", props.client_case), {
preserveScroll: true, preserveScroll: true,
only: ['segments'], only: ["segments"],
onSuccess: () => { onSuccess: () => {
closeAttachSegment() closeAttachSegment();
attachForm.reset('segment_id') attachForm.reset("segment_id");
} },
}) });
} };
</script> </script>
<template> <template>
<AppLayout title="Client case"> <AppLayout title="Client case">
<template #header></template> <template #header></template>
<div class="pt-12"> <div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<!-- Current segment badge (right aligned, above the card) -->
<div v-if="current_segment" class="flex justify-end pb-3">
<div class="text-sm text-gray-700">
<span class="mr-2">Segment:</span>
<span
class="inline-block px-3 py-1 rounded-md border border-indigo-200 bg-indigo-50 text-indigo-800 font-medium"
>
{{ current_segment.name }}
</span>
</div>
</div>
<div <div
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-500" class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-500"
> >
<div class="mx-auto max-w-4x1 p-3 flex justify-between"> <div class="mx-auto max-w-4x1 p-3 flex justify-between items-center">
<SectionTitle> <SectionTitle>
<template #title> <template #title>
<a class="hover:text-blue-500" :href="route('client.show', client)"> <a class="hover:text-blue-500" :href="route('client.show', client)">
@ -175,9 +233,15 @@ const submitAttachSegment = () => {
<SectionTitle> <SectionTitle>
<template #title> Primer - oseba </template> <template #title> Primer - oseba </template>
</SectionTitle> </SectionTitle>
<div v-if="client_case && client_case.client_ref" class="text-xs text-gray-600"> <div
v-if="client_case && client_case.client_ref"
class="text-xs text-gray-600"
>
<span class="mr-1">Ref:</span> <span class="mr-1">Ref:</span>
<span class="inline-block px-2 py-0.5 rounded border bg-gray-50 font-mono text-gray-700">{{ client_case.client_ref }}</span> <span
class="inline-block px-2 py-0.5 rounded border bg-gray-50 font-mono text-gray-700"
>{{ client_case.client_ref }}</span
>
</div> </div>
</div> </div>
</div> </div>
@ -189,7 +253,11 @@ const submitAttachSegment = () => {
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-red-400" class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-red-400"
> >
<div class="mx-auto max-w-4x1 p-3"> <div class="mx-auto max-w-4x1 p-3">
<PersonInfoGrid :types="types" tab-color="red-600" :person="client_case.person" /> <PersonInfoGrid
:types="types"
tab-color="red-600"
:person="client_case.person"
/>
</div> </div>
</div> </div>
</div> </div>
@ -204,8 +272,16 @@ const submitAttachSegment = () => {
</SectionTitle> </SectionTitle>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<FwbButton @click="openDrawerCreateContract">Nova</FwbButton> <FwbButton @click="openDrawerCreateContract">Nova</FwbButton>
<FwbButton color="light" :disabled="availableSegments.length === 0" @click="openAttachSegment"> <FwbButton
{{ availableSegments.length ? 'Dodaj segment' : 'Ni razpoložljivih segmentov' }} color="light"
:disabled="availableSegments.length === 0"
@click="openAttachSegment"
>
{{
availableSegments.length
? "Dodaj segment"
: "Ni razpoložljivih segmentov"
}}
</FwbButton> </FwbButton>
</div> </div>
</div> </div>
@ -230,12 +306,16 @@ const submitAttachSegment = () => {
<div class="flex justify-between p-4"> <div class="flex justify-between p-4">
<SectionTitle> <SectionTitle>
<template #title>Aktivnosti</template> <template #title>Aktivnosti</template>
</SectionTitle> </SectionTitle>
<FwbButton @click="openDrawerAddActivity">Nova</FwbButton> <FwbButton @click="openDrawerAddActivity">Nova</FwbButton>
</div> </div>
<ActivityTable :client_case="client_case" :activities="activities" /> <ActivityTable :client_case="client_case" :activities="activities" />
<Pagination :links="activities.links" :from="activities.from" :to="activities.to" :total="activities.total" /> <Pagination
:links="activities.links"
:from="activities.from"
:to="activities.to"
:total="activities.total"
/>
</div> </div>
</div> </div>
</div> </div>
@ -254,12 +334,22 @@ const submitAttachSegment = () => {
<DocumentsTable <DocumentsTable
:documents="documents" :documents="documents"
@view="openViewer" @view="openViewer"
:download-url-builder="doc => { :download-url-builder="
const isContractDoc = (doc?.documentable_type || '').toLowerCase().includes('contract') (doc) => {
return isContractDoc && doc.contract_uuid const isContractDoc = (doc?.documentable_type || '')
? route('contract.document.download', { contract: doc.contract_uuid, document: doc.uuid }) .toLowerCase()
: route('clientCase.document.download', { client_case: client_case.uuid, document: doc.uuid }) .includes('contract');
}" return isContractDoc && doc.contract_uuid
? route('contract.document.download', {
contract: doc.contract_uuid,
document: doc.uuid,
})
: route('clientCase.document.download', {
client_case: client_case.uuid,
document: doc.uuid,
});
}
"
/> />
</div> </div>
</div> </div>
@ -272,7 +362,12 @@ const submitAttachSegment = () => {
:post-url="route('clientCase.document.store', client_case)" :post-url="route('clientCase.document.store', client_case)"
:contracts="contracts" :contracts="contracts"
/> />
<DocumentViewerDialog :show="viewer.open" :src="viewer.src" :title="viewer.title" @close="closeViewer" /> <DocumentViewerDialog
:show="viewer.open"
:src="viewer.src"
:title="viewer.title"
@close="closeViewer"
/>
</AppLayout> </AppLayout>
<ContractDrawer <ContractDrawer
:show="drawerCreateContract" :show="drawerCreateContract"
@ -303,16 +398,28 @@ const submitAttachSegment = () => {
<template #content> <template #content>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Segment</label> <label class="block text-sm font-medium text-gray-700">Segment</label>
<select v-model="attachForm.segment_id" class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"> <select
v-model="attachForm.segment_id"
class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null" disabled>-- izberi segment --</option> <option :value="null" disabled>-- izberi segment --</option>
<option v-for="s in availableSegments" :key="s.id" :value="s.id">{{ s.name }}</option> <option v-for="s in availableSegments" :key="s.id" :value="s.id">
{{ s.name }}
</option>
</select> </select>
<div v-if="attachForm.errors.segment_id" class="text-sm text-red-600">{{ attachForm.errors.segment_id }}</div> <div v-if="attachForm.errors.segment_id" class="text-sm text-red-600">
{{ attachForm.errors.segment_id }}
</div>
</div> </div>
</template> </template>
<template #footer> <template #footer>
<FwbButton color="light" @click="closeAttachSegment">Prekliči</FwbButton> <FwbButton color="light" @click="closeAttachSegment">Prekliči</FwbButton>
<FwbButton class="ml-2" :disabled="attachForm.processing || !attachForm.segment_id" @click="submitAttachSegment">Dodaj</FwbButton> <FwbButton
class="ml-2"
:disabled="attachForm.processing || !attachForm.segment_id"
@click="submitAttachSegment"
>Dodaj</FwbButton
>
</template> </template>
</DialogModal> </DialogModal>
</template> </template>

View File

@ -1,254 +1,302 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from "vue";
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from "@/Layouts/AppLayout.vue";
import List from '@/Components/List.vue'; import PrimaryButton from "@/Components/PrimaryButton.vue";
import ListItem from '@/Components/ListItem.vue'; import InputLabel from "@/Components/InputLabel.vue";
import PrimaryButton from '@/Components/PrimaryButton.vue'; import TextInput from "@/Components/TextInput.vue";
import InputLabel from '@/Components/InputLabel.vue'; import { Link, useForm, router } from "@inertiajs/vue3";
import TextInput from '@/Components/TextInput.vue'; import ActionMessage from "@/Components/ActionMessage.vue";
import { Link, useForm } from '@inertiajs/vue3'; import DialogModal from "@/Components/DialogModal.vue";
import ActionMessage from '@/Components/ActionMessage.vue'; import Pagination from "@/Components/Pagination.vue";
import DialogModal from '@/Components/DialogModal.vue'; import { debounce } from "lodash";
import Pagination from '@/Components/Pagination.vue';
import SearchInput from '@/Components/SearchInput.vue';
const props = defineProps({ const props = defineProps({
clients: Object, clients: Object,
filters: Object filters: Object,
}); });
const Address = { const Address = {
address: '', address: "",
country: '', country: "",
type_id: 1 type_id: 1,
}; };
const Phone = { const Phone = {
nu: '', nu: "",
country_code: '00386', country_code: "00386",
type_id: 1 type_id: 1,
} };
const formClient = useForm({ const formClient = useForm({
first_name: '', first_name: "",
last_name: '', last_name: "",
full_name: '', full_name: "",
tax_number: '', tax_number: "",
social_security_number: '', social_security_number: "",
description: '', description: "",
address: Address, address: Address,
phone: Phone phone: Phone,
}); });
//Create client drawer //Create client drawer
const drawerCreateClient = ref(false); const drawerCreateClient = ref(false);
//Search input // Search state (table-friendly, SPA)
const search = { const search = ref(props.filters?.search || "");
search: props.filters.search, const applySearch = debounce((term) => {
route: { const params = Object.fromEntries(
link: 'client' new URLSearchParams(window.location.search).entries()
} );
} if (term) {
params.search = term;
} else {
delete params.search;
}
delete params.page; // reset pagination
router.get(route("client"), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
}, 300);
watch(search, (v) => applySearch(v));
//Open drawer create client //Open drawer create client
const openDrawerCreateClient = () => { const openDrawerCreateClient = () => {
drawerCreateClient.value = true drawerCreateClient.value = true;
} };
//Close any drawer on page //Close any drawer on page
const closeDrawer = () => { const closeDrawer = () => {
drawerCreateClient.value = false drawerCreateClient.value = false;
} };
//Ajax call post to store new client //Ajax call post to store new client
const storeClient = () => { const storeClient = () => {
formClient.post(route('client.store'), { formClient.post(route("client.store"), {
onBefore: () => { onBefore: () => {
formClient.address.type_id = Number(formClient.address.type_id); formClient.address.type_id = Number(formClient.address.type_id);
}, },
onSuccess: () => { onSuccess: () => {
closeDrawer(); closeDrawer();
formClient.reset(); formClient.reset();
} },
}); });
}; };
// Formatting helpers
const fmtCurrency = (v) => {
const n = Number(v ?? 0);
try {
return new Intl.NumberFormat("sl-SI", { style: "currency", currency: "EUR" }).format(
n
);
} catch (e) {
return `${n.toFixed(2)}`;
}
};
</script> </script>
<template> <template>
<AppLayout title="Client"> <AppLayout title="Client">
<template #header> <template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"></h2>
Naročniki </template>
</h2> <div class="py-12">
</template> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="py-12"> <div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="mx-auto max-w-4x1 py-3">
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg"> <div class="flex items-center justify-between gap-3">
<div class="mx-auto max-w-4x1 py-3"> <PrimaryButton @click="openDrawerCreateClient" class="bg-blue-400"
<div class="flex justify-between"> >Dodaj</PrimaryButton
<PrimaryButton @click="openDrawerCreateClient" class="bg-blue-400">Dodaj</PrimaryButton> >
<SearchInput :options="search" /> <input
</div> v-model="search"
<List class="mt-2"> type="text"
<ListItem v-for="client in clients.data"> placeholder="Iskanje po imenu"
<div class="flex justify-between shadow rounded border-solid border-l-4 border-blue-400 p-3"> class="w-full sm:w-80 rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 text-sm"
<div class="flex min-w-0 gap-x-4"> />
<div class="min-w-0 flex-auto">
<p class="text-sm font-semibold leading-6 text-gray-900"><Link :href="route('client.show', {uuid: client.uuid})">{{ client.person.full_name }}</Link></p>
<p class="mt-1 truncate text-xs leading-5 text-gray-500">{{ client.person.nu }}</p>
</div>
</div>
<div class="hidden shrink-0 sm:flex sm:flex-col sm:items-end">
<p class="text-sm leading-6 text-gray-900">{{ client.person.tax_number }}</p>
<div class="mt-1 flex items-center gap-x-1.5">
<p class="text-xs leading-5 text-gray-500">Naročnik</p>
</div>
</div>
</div>
</ListItem>
</List>
</div>
<Pagination :links="clients.links" :from="clients.from" :to="clients.to" :total="clients.total" />
</div>
</div> </div>
<div class="overflow-x-auto mt-3">
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">Št.</th>
<th class="py-2 pr-4">Naročnik</th>
<th class="py-2 pr-4 text-right">Primeri z aktivnimi pogodbami</th>
<th class="py-2 pr-4 text-right">Skupaj stanje</th>
</tr>
</thead>
<tbody>
<tr
v-for="client in clients.data"
:key="client.uuid"
class="border-b last:border-0"
>
<td class="py-2 pr-4">{{ client.person?.nu || "-" }}</td>
<td class="py-2 pr-4">
<Link
:href="route('client.show', { uuid: client.uuid })"
class="text-indigo-600 hover:underline"
>
{{ client.person?.full_name || "-" }}
</Link>
</td>
<td class="py-2 pr-4 text-right">
{{ client.cases_with_active_contracts_count ?? 0 }}
</td>
<td class="py-2 pr-4 text-right">
{{ fmtCurrency(client.active_contracts_balance_sum) }}
</td>
</tr>
<tr v-if="!clients.data || clients.data.length === 0">
<td colspan="4" class="py-4 text-gray-500">Ni zadetkov.</td>
</tr>
</tbody>
</table>
</div>
</div>
<Pagination
:links="clients.links"
:from="clients.from"
:to="clients.to"
:total="clients.total"
/>
</div> </div>
</AppLayout> </div>
<DialogModal </div>
:show="drawerCreateClient" </AppLayout>
@close="drawerCreateClient = false" <DialogModal :show="drawerCreateClient" @close="drawerCreateClient = false">
> <template #title>Novi naročnik</template>
<template #title>Novi naročnik</template> <template #content>
<template #content> <form @submit.prevent="storeClient">
<form @submit.prevent="storeClient"> <div>
<div> <div class="col-span-6 sm:col-span-4">
<InputLabel for="fullname" value="Naziv" />
<TextInput
id="fullname"
ref="fullnameInput"
v-model="formClient.full_name"
type="text"
class="mt-1 block w-full"
autocomplete="full-name"
/>
</div>
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<InputLabel for="fullname" value="Naziv"/> <InputLabel for="taxnumber" value="Davčna" />
<TextInput <TextInput
id="fullname" id="taxnumber"
ref="fullnameInput" ref="taxnumberInput"
v-model="formClient.full_name" v-model="formClient.tax_number"
type="text" type="text"
class="mt-1 block w-full" class="mt-1 block w-full"
autocomplete="full-name" autocomplete="tax-number"
/> />
</div> </div>
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<InputLabel for="taxnumber" value="Davčna"/> <InputLabel for="socialSecurityNumber" value="Matična / Emšo" />
<TextInput <TextInput
id="taxnumber" id="socialSecurityNumber"
ref="taxnumberInput" ref="socialSecurityNumberInput"
v-model="formClient.tax_number" v-model="formClient.social_security_number"
type="text" type="text"
class="mt-1 block w-full" class="mt-1 block w-full"
autocomplete="tax-number" autocomplete="social-security-number"
/> />
</div> </div>
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<InputLabel for="socialSecurityNumber" value="Matična / Emšo"/> <InputLabel for="address" value="Naslov" />
<TextInput <TextInput
id="socialSecurityNumber" id="address"
ref="socialSecurityNumberInput" ref="addressInput"
v-model="formClient.social_security_number" v-model="formClient.address.address"
type="text" type="text"
class="mt-1 block w-full" class="mt-1 block w-full"
autocomplete="social-security-number" autocomplete="address"
/> />
</div> </div>
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<InputLabel for="address" value="Naslov"/> <InputLabel for="addressCountry" value="Država" />
<TextInput <TextInput
id="address" id="addressCountry"
ref="addressInput" ref="addressCountryInput"
v-model="formClient.address.address" v-model="formClient.address.country"
type="text" type="text"
class="mt-1 block w-full" class="mt-1 block w-full"
autocomplete="address" autocomplete="address-country"
/> />
</div> </div>
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<InputLabel for="addressCountry" value="Država"/> <InputLabel for="addressType" value="Vrsta naslova" />
<TextInput <select
id="addressCountry" class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
ref="addressCountryInput" id="addressType"
v-model="formClient.address.country" v-model="formClient.address.type_id"
type="text" >
class="mt-1 block w-full" <option value="1">Stalni</option>
autocomplete="address-country" <option value="2">Začasni</option>
/> <!-- ... -->
</div> </select>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="phoneCountyCode" value="Koda države tel." />
<select
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
id="phoneCountyCode"
v-model="formClient.phone.country_code"
>
<option value="00386">+386 (Slovenija)</option>
<option value="00385">+385 (Hrvaška)</option>
<option value="0039">+39 (Italija)</option>
<option value="0036">+39 (Madžarska)</option>
<option value="0043">+43 (Avstrija)</option>
<option value="00381">+381 (Srbija)</option>
<option value="00387">+387 (Bosna in Hercegovina)</option>
<option value="00382">+382 (Črna gora)</option>
<!-- ... -->
</select>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="phoneNu" value="Telefonska št." />
<TextInput
id="phoneNu"
ref="phoneNuInput"
v-model="formClient.phone.nu"
type="text"
class="mt-1 block w-full"
autocomplete="phone-nu"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="description" value="Opis" />
<TextInput
id="description"
ref="descriptionInput"
v-model="formClient.description"
type="text"
class="mt-1 block w-full"
autocomplete="description"
/>
</div>
</div>
<div class="flex justify-end mt-4">
<ActionMessage :on="formClient.recentlySuccessful" class="me-3">
Shranjeno.
</ActionMessage>
<div class="col-span-6 sm:col-span-4"> <PrimaryButton
<InputLabel for="addressType" value="Vrsta naslova"/> :class="{ 'opacity-25': formClient.processing }"
<select :disabled="formClient.processing"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" >
id="addressType" Shrani
v-model="formClient.address.type_id" </PrimaryButton>
> </div>
<option value="1">Stalni</option> </form>
<option value="2">Začasni</option> </template>
<!-- ... --> </DialogModal>
</select>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="phoneCountyCode" value="Koda države tel."/>
<select
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
id="phoneCountyCode"
v-model="formClient.phone.country_code"
>
<option value="00386">+386 (Slovenija)</option>
<option value="00385">+385 (Hrvaška)</option>
<option value="0039">+39 (Italija)</option>
<option value="0036">+39 (Madžarska)</option>
<option value="0043">+43 (Avstrija)</option>
<option value="00381">+381 (Srbija)</option>
<option value="00387">+387 (Bosna in Hercegovina)</option>
<option value="00382">+382 (Črna gora)</option>
<!-- ... -->
</select>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="phoneNu" value="Telefonska št."/>
<TextInput
id="phoneNu"
ref="phoneNuInput"
v-model="formClient.phone.nu"
type="text"
class="mt-1 block w-full"
autocomplete="phone-nu"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="description" value="Opis"/>
<TextInput
id="description"
ref="descriptionInput"
v-model="formClient.description"
type="text"
class="mt-1 block w-full"
autocomplete="description"
/>
</div>
</div>
<div class="flex justify-end mt-4">
<ActionMessage :on="formClient.recentlySuccessful" class="me-3">
Shranjeno.
</ActionMessage>
<PrimaryButton :class="{ 'opacity-25': formClient.processing }" :disabled="formClient.processing">
Shrani
</PrimaryButton>
</div>
</form>
</template>
</DialogModal>
</template> </template>

View File

@ -1,101 +1,154 @@
<script setup> <script setup>
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from "@/Layouts/AppLayout.vue";
import List from '@/Components/List.vue'; import PrimaryButton from "@/Components/PrimaryButton.vue";
import ListItem from '@/Components/ListItem.vue'; import { ref, watch } from "vue";
import PrimaryButton from '@/Components/PrimaryButton.vue'; import { Link, router } from "@inertiajs/vue3";
import { ref } from 'vue'; import SectionTitle from "@/Components/SectionTitle.vue";
import { Link } from '@inertiajs/vue3'; import PersonInfoGrid from "@/Components/PersonInfoGrid.vue";
import SectionTitle from '@/Components/SectionTitle.vue'; import Pagination from "@/Components/Pagination.vue";
import PersonInfoGrid from '@/Components/PersonInfoGrid.vue'; import FormCreateCase from "./Partials/FormCreateCase.vue";
import Pagination from '@/Components/Pagination.vue'; import { debounce } from "lodash";
import SearchInput from '@/Components/SearchInput.vue';
import FormCreateCase from './Partials/FormCreateCase.vue';
const props = defineProps({ const props = defineProps({
client: Object, client: Object,
client_cases: Object, client_cases: Object,
urlPrev: String, urlPrev: String,
filters: Object, filters: Object,
types: Object types: Object,
}); });
const search = { // Table-friendly search for client cases
search: props.filters.search, const search = ref(props.filters?.search || "");
route: { const applySearch = debounce((term) => {
link: 'client.show', const params = Object.fromEntries(
options: {uuid: props.client.uuid} new URLSearchParams(window.location.search).entries()
} );
} 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,
});
}, 300);
watch(search, (v) => applySearch(v));
const drawerCreateCase = ref(false); const drawerCreateCase = ref(false);
const openDrawerCreateCase = () => { const openDrawerCreateCase = () => {
drawerCreateCase.value = true; drawerCreateCase.value = true;
} };
</script> </script>
<template> <template>
<AppLayout title="Client"> <AppLayout title="Client">
<template #header></template> <template #header></template>
<div class="pt-12"> <div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <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
<div class="mx-auto max-w-4x1 p-3 flex justify-between"> class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
<SectionTitle> >
<template #title> <div class="mx-auto max-w-4x1 p-3 flex justify-between">
{{ client.person.full_name }} <SectionTitle>
</template> <template #title>
</SectionTitle> {{ client.person.full_name }}
</div> </template>
</div> </SectionTitle>
</div> </div>
</div> </div>
<div class="pt-1"> </div>
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> </div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"> <div class="pt-1">
<div class="mx-auto max-w-4x1 px-2"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<PersonInfoGrid :types="types" :person="client.person"></PersonInfoGrid> <div
</div> class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
</div> >
</div> <div class="mx-auto max-w-4x1 px-2">
<PersonInfoGrid :types="types" :person="client.person"></PersonInfoGrid>
</div>
</div> </div>
</div>
</div>
<div class="py-12"> <div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <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="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
<div class="mx-auto max-w-4x1 py-3"> <div class="mx-auto max-w-4x1 py-3">
<div class="flex justify-between"> <div class="flex items-center justify-between gap-3">
<PrimaryButton @click="openDrawerCreateCase" class="bg-blue-400">Dodaj</PrimaryButton> <PrimaryButton @click="openDrawerCreateCase" class="bg-blue-400"
<SearchInput :options="search" /> >Dodaj</PrimaryButton
</div> >
<List class="mt-2"> <input
<ListItem v-for="clientCase in client_cases.data"> v-model="search"
<div class="flex justify-between shadow rounded border-solid border-l-4 border-red-400 p-2"> type="text"
<div class="flex min-w-0 gap-x-4"> placeholder="Iskanje po imenu"
<div class="min-w-0 flex-auto"> class="w-full sm:w-80 rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 text-sm"
<p class="text-sm font-semibold leading-6 text-gray-900"><Link :href="route('clientCase.show', {uuid: clientCase.uuid})">{{ clientCase.person.full_name }}</Link></p> />
<p class="mt-1 truncate text-xs leading-5 text-gray-500"></p>
</div>
</div>
<div class="hidden shrink-0 sm:flex sm:flex-col sm:items-end">
<p class="text-sm leading-6 text-gray-900">{{ clientCase.person.nu }}</p>
<div class="mt-1 flex items-center gap-x-1.5">
<p class="text-xs leading-5 text-gray-500">Primer naročnika</p>
</div>
</div>
</div>
</ListItem>
</List>
</div>
<Pagination :links="client_cases.links" :from="client_cases.from" :to="client_cases.to" :total="client_cases.total" />
</div>
</div> </div>
<div class="overflow-x-auto mt-3">
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">Št.</th>
<th class="py-2 pr-4">Primer</th>
<th class="py-2 pr-4">Davčna</th>
<th class="py-2 pr-4 text-right">Aktivne pogodbe</th>
<th class="py-2 pr-4 text-right">Skupaj stanje</th>
</tr>
</thead>
<tbody>
<tr
v-for="c in client_cases.data"
:key="c.uuid"
class="border-b last:border-0"
>
<td class="py-2 pr-4">{{ c.person?.nu || "-" }}</td>
<td class="py-2 pr-4">
<Link
:href="route('clientCase.show', { client_case: c.uuid })"
class="text-indigo-600 hover:underline"
>
{{ c.person?.full_name || "-" }}
</Link>
</td>
<td class="py-2 pr-4">{{ c.person?.tax_number || "-" }}</td>
<td class="py-2 pr-4 text-right">
{{ c.active_contracts_count ?? 0 }}
</td>
<td class="py-2 pr-4 text-right">
{{
new Intl.NumberFormat("sl-SI", {
style: "currency",
currency: "EUR",
}).format(Number(c.active_contracts_balance_sum ?? 0))
}}
</td>
</tr>
<tr v-if="!client_cases.data || client_cases.data.length === 0">
<td colspan="5" class="py-4 text-gray-500">Ni zadetkov.</td>
</tr>
</tbody>
</table>
</div>
</div>
<Pagination
:links="client_cases.links"
:from="client_cases.from"
:to="client_cases.to"
:total="client_cases.total"
/>
</div> </div>
</AppLayout> </div>
<FormCreateCase </div>
:show="drawerCreateCase" </AppLayout>
@close="drawerCreateCase = false" <FormCreateCase
:client-uuid="client?.uuid" :show="drawerCreateCase"
/> @close="drawerCreateCase = false"
:client-uuid="client?.uuid"
/>
</template> </template>

View File

@ -1,7 +1,7 @@
<script setup> <script setup>
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, useForm } from '@inertiajs/vue3' import { Link, useForm } from "@inertiajs/vue3";
import { computed, ref } from 'vue' import { computed, ref, watch } from "vue";
const props = defineProps({ const props = defineProps({
setting: Object, setting: Object,
@ -17,71 +17,160 @@ const form = useForm({
end_date: null, end_date: null,
}); });
// Global search (applies to both tables)
const search = ref("");
// Format helpers (Slovenian formatting) // Format helpers (Slovenian formatting)
function formatDate(value) { function formatDate(value) {
if (!value) { return '-'; } if (!value) {
return "-";
}
const d = new Date(value); const d = new Date(value);
if (isNaN(d)) { return value; } if (isNaN(d)) {
const dd = String(d.getDate()).padStart(2, '0'); return value;
const mm = String(d.getMonth() + 1).padStart(2, '0'); }
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear(); const yyyy = d.getFullYear();
return `${dd}.${mm}.${yyyy}`; return `${dd}.${mm}.${yyyy}`;
} }
function formatCurrencyEUR(value) { function formatCurrencyEUR(value) {
if (value === null || value === undefined) { return '-'; } if (value === null || value === undefined) {
return "-";
}
const n = Number(value); const n = Number(value);
if (isNaN(n)) { return String(value); } if (isNaN(n)) {
return String(value);
}
// Thousands separator as dot, decimal as comma, with suffix // Thousands separator as dot, decimal as comma, with suffix
return n.toLocaleString('sl-SI', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; return (
n.toLocaleString("sl-SI", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +
" €"
);
}
function primaryCaseAddress(contract) {
const addrs = contract?.client_case?.person?.addresses || [];
if (!Array.isArray(addrs) || addrs.length === 0) {
return "-";
}
const a = addrs[0];
const address = a?.address || "";
const country = a?.country || "";
return [address, country].filter(Boolean).join(", ");
} }
function assign(contract) { function assign(contract) {
form.contract_uuid = contract.uuid form.contract_uuid = contract.uuid;
// minimal UX: if no user selected yet, just post will fail with error; page can be enhanced later with dropdown. // minimal UX: if no user selected yet, just post will fail with error; page can be enhanced later with dropdown.
form.post(route('fieldjobs.assign')) form.post(route("fieldjobs.assign"));
} }
function cancelAssignment(contract) { function cancelAssignment(contract) {
const payload = { contract_uuid: contract.uuid } const payload = { contract_uuid: contract.uuid };
form.transform(() => payload).post(route('fieldjobs.cancel')) form.transform(() => payload).post(route("fieldjobs.cancel"));
} }
function isAssigned(contract) { function isAssigned(contract) {
return !!(props.assignments && props.assignments[contract.uuid]) return !!(props.assignments && props.assignments[contract.uuid]);
} }
function assignedTo(contract) { function assignedTo(contract) {
return props.assignments?.[contract.uuid]?.assigned_to?.name || null return props.assignments?.[contract.uuid]?.assigned_to?.name || null;
} }
function assignedBy(contract) { function assignedBy(contract) {
return props.assignments?.[contract.uuid]?.assigned_by?.name || null return props.assignments?.[contract.uuid]?.assigned_by?.name || null;
} }
// removed window.open behavior; default SPA navigation via Inertia Link // removed window.open behavior; default SPA navigation via Inertia Link
// Derived lists // Derived lists
const unassignedContracts = computed(() => { const unassignedContracts = computed(() => {
return (props.contracts || []).filter(c => !isAssigned(c)) return (props.contracts || []).filter((c) => !isAssigned(c));
}) });
const assignedContracts = computed(() => { const assignedContracts = computed(() => {
return (props.contracts || []).filter(c => isAssigned(c)) return (props.contracts || []).filter((c) => isAssigned(c));
}) });
// Apply search to lists
function matchesSearch(c) {
if (!search.value) {
return true;
}
const q = String(search.value).toLowerCase();
const ref = String(c.reference || "").toLowerCase();
const casePerson = String(c.client_case?.person?.full_name || "").toLowerCase();
// Optionally include client person in search as well for convenience
const clientPerson = String(c.client?.person?.full_name || "").toLowerCase();
// Include address fields
const primaryAddr = String(primaryCaseAddress(c) || "").toLowerCase();
const allAddrs = String(
(c.client_case?.person?.addresses || [])
.map((a) => `${a?.address || ""} ${a?.country || ""}`.trim())
.join(" ")
).toLowerCase();
return (
ref.includes(q) ||
casePerson.includes(q) ||
clientPerson.includes(q) ||
primaryAddr.includes(q) ||
allAddrs.includes(q)
);
}
const unassignedFiltered = computed(() =>
unassignedContracts.value.filter(matchesSearch)
);
// Filter for assigned table // Filter for assigned table
const assignedFilterUserId = ref('') const assignedFilterUserId = ref("");
const assignedContractsFiltered = computed(() => { const assignedContractsFiltered = computed(() => {
const list = assignedContracts.value let list = assignedContracts.value;
if (!assignedFilterUserId.value) { if (assignedFilterUserId.value) {
return list list = list.filter((c) => {
const uid = props.assignments?.[c.uuid]?.assigned_to?.id;
return String(uid) === String(assignedFilterUserId.value);
});
} }
return list.filter(c => { return list.filter(matchesSearch);
const uid = props.assignments?.[c.uuid]?.assigned_to?.id });
return String(uid) === String(assignedFilterUserId.value)
}) // Pagination state
}) const unassignedPage = ref(1);
const unassignedPerPage = ref(10);
const assignedPage = ref(1);
const assignedPerPage = ref(10);
// Reset pages when filters change
watch([search], () => {
unassignedPage.value = 1;
assignedPage.value = 1;
});
watch([assignedFilterUserId], () => {
assignedPage.value = 1;
});
// Paginated lists
const unassignedTotal = computed(() => unassignedFiltered.value.length);
const unassignedTotalPages = computed(() =>
Math.max(1, Math.ceil(unassignedTotal.value / unassignedPerPage.value))
);
const unassignedPageItems = computed(() => {
const start = (unassignedPage.value - 1) * unassignedPerPage.value;
return unassignedFiltered.value.slice(start, start + unassignedPerPage.value);
});
const assignedTotal = computed(() => assignedContractsFiltered.value.length);
const assignedTotalPages = computed(() =>
Math.max(1, Math.ceil(assignedTotal.value / assignedPerPage.value))
);
const assignedPageItems = computed(() => {
const start = (assignedPage.value - 1) * assignedPerPage.value;
return assignedContractsFiltered.value.slice(start, start + assignedPerPage.value);
});
</script> </script>
<template> <template>
@ -89,60 +178,134 @@ const assignedContractsFiltered = computed(() => {
<template #header></template> <template #header></template>
<div class="pt-12"> <div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div v-if="!setting" class="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded p-4 mb-6"> <div
Nastavitev za terenska opravila ni najdena. Najprej jo ustvarite v Nastavitve Nastavitve terenskih opravil. v-if="!setting"
class="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded p-4 mb-6"
>
Nastavitev za terenska opravila ni najdena. Najprej jo ustvarite v Nastavitve
Nastavitve terenskih opravil.
</div>
<!-- Global search -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-4 mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Iskanje (št. pogodbe, nazivu ali naslovu)</label
>
<input
v-model="search"
type="text"
placeholder="Išči po številki pogodbe, nazivu ali naslovu"
class="border rounded px-3 py-2 w-full max-w-xl"
/>
</div> </div>
<!-- Unassigned (Assignable) Contracts --> <!-- Unassigned (Assignable) Contracts -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6 mb-8"> <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6 mb-8">
<h2 class="text-xl font-semibold mb-4">Pogodbe (nedodeljene)</h2> <h2 class="text-xl font-semibold mb-4">Pogodbe (nedodeljene)</h2>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Dodeli uporabniku</label> <label class="block text-sm font-medium text-gray-700 mb-1"
<select v-model="form.assigned_user_id" class="border rounded px-3 py-2 w-full max-w-xs"> >Dodeli uporabniku</label
>
<select
v-model="form.assigned_user_id"
class="border rounded px-3 py-2 w-full max-w-xs"
>
<option :value="null" disabled>Izberite uporabnika</option> <option :value="null" disabled>Izberite uporabnika</option>
<option v-for="u in users || []" :key="u.id" :value="u.id">{{ u.name }}</option> <option v-for="u in users || []" :key="u.id" :value="u.id">
{{ u.name }}
</option>
</select> </select>
<div v-if="form.errors.assigned_user_id" class="text-red-600 text-sm mt-1">{{ form.errors.assigned_user_id }}</div> <div v-if="form.errors.assigned_user_id" class="text-red-600 text-sm mt-1">
{{ form.errors.assigned_user_id }}
</div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full text-left text-sm"> <table class="min-w-full text-left text-sm">
<thead> <thead>
<tr class="border-b"> <tr class="border-b">
<th class="py-2 pr-4">Pogodba</th> <th class="py-2 pr-4">Pogodba</th>
<th class="py-2 pr-4">Primer</th>
<th class="py-2 pr-4">Naslov</th>
<th class="py-2 pr-4">Stranka</th> <th class="py-2 pr-4">Stranka</th>
<th class="py-2 pr-4">Vrsta</th>
<th class="py-2 pr-4">Začetek</th> <th class="py-2 pr-4">Začetek</th>
<th class="py-2 pr-4">Konec</th>
<th class="py-2 pr-4">Stanje</th> <th class="py-2 pr-4">Stanje</th>
<th class="py-2 pr-4">Dejanje</th> <th class="py-2 pr-4">Dejanje</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="c in unassignedContracts" :key="c.uuid" class="border-b last:border-0"> <tr
v-for="c in unassignedPageItems"
:key="c.uuid"
class="border-b last:border-0"
>
<td class="py-2 pr-4">{{ c.reference }}</td> <td class="py-2 pr-4">{{ c.reference }}</td>
<td class="py-2 pr-4"> <td class="py-2 pr-4">
<Link <Link
v-if="c.client_case?.uuid" v-if="c.client_case?.uuid"
:href="route('clientCase.show', { client_case: c.client_case.uuid })" :href="
route('clientCase.show', { client_case: c.client_case.uuid })
"
class="text-indigo-600 hover:underline" class="text-indigo-600 hover:underline"
> >
{{ c.client_case?.person?.full_name || 'Primer stranke' }} {{ c.client_case?.person?.full_name || "Primer stranke" }}
</Link> </Link>
<span v-else>{{ c.client_case?.person?.full_name || '-' }}</span> <span v-else>{{ c.client_case?.person?.full_name || "-" }}</span>
</td>
<td class="py-2 pr-4">{{ primaryCaseAddress(c) }}</td>
<td class="py-2 pr-4">
{{ c.client?.person?.full_name || "-" }}
</td> </td>
<td class="py-2 pr-4">{{ c.type?.name }}</td>
<td class="py-2 pr-4">{{ formatDate(c.start_date) }}</td> <td class="py-2 pr-4">{{ formatDate(c.start_date) }}</td>
<td class="py-2 pr-4">{{ formatDate(c.end_date) }}</td> <td class="py-2 pr-4">
<td class="py-2 pr-4">{{ formatCurrencyEUR(c.account?.balance_amount) }}</td> {{ formatCurrencyEUR(c.account?.balance_amount) }}
</td>
<td class="py-2 pr-4 flex items-center gap-2"> <td class="py-2 pr-4 flex items-center gap-2">
<button <button
class="px-3 py-1 text-sm rounded bg-indigo-600 text-white" class="px-3 py-1 text-sm rounded bg-indigo-600 text-white"
@click="assign(c)" @click="assign(c)"
>Dodeli</button> >
Dodeli
</button>
</td> </td>
</tr> </tr>
<tr v-if="unassignedPageItems.length === 0">
<td colspan="9" class="py-4 text-gray-500">Ni najdenih pogodb.</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Unassigned pagination -->
<div class="flex items-center justify-between mt-4 text-sm text-gray-700">
<div>
Prikazano
{{
Math.min((unassignedPage - 1) * unassignedPerPage + 1, unassignedTotal)
}}{{ Math.min(unassignedPage * unassignedPerPage, unassignedTotal) }} od
{{ unassignedTotal }}
</div>
<div class="flex items-center gap-2">
<select v-model.number="unassignedPerPage" class="border rounded px-2 py-1">
<option :value="10">10</option>
<option :value="25">25</option>
<option :value="50">50</option>
</select>
<button
class="px-2 py-1 border rounded disabled:opacity-50"
:disabled="unassignedPage <= 1"
@click="unassignedPage = Math.max(1, unassignedPage - 1)"
>
Prejšnja
</button>
<span>Stran {{ unassignedPage }} / {{ unassignedTotalPages }}</span>
<button
class="px-2 py-1 border rounded disabled:opacity-50"
:disabled="unassignedPage >= unassignedTotalPages"
@click="
unassignedPage = Math.min(unassignedTotalPages, unassignedPage + 1)
"
>
Naslednja
</button>
</div>
</div>
</div> </div>
<!-- Assigned Contracts --> <!-- Assigned Contracts -->
@ -153,7 +316,9 @@ const assignedContractsFiltered = computed(() => {
<label class="text-sm text-gray-700">Filter po uporabniku</label> <label class="text-sm text-gray-700">Filter po uporabniku</label>
<select v-model="assignedFilterUserId" class="border rounded px-3 py-2"> <select v-model="assignedFilterUserId" class="border rounded px-3 py-2">
<option value="">Vsi</option> <option value="">Vsi</option>
<option v-for="u in users || []" :key="u.id" :value="u.id">{{ u.name }}</option> <option v-for="u in users || []" :key="u.id" :value="u.id">
{{ u.name }}
</option>
</select> </select>
</div> </div>
</div> </div>
@ -162,6 +327,8 @@ const assignedContractsFiltered = computed(() => {
<thead> <thead>
<tr class="border-b"> <tr class="border-b">
<th class="py-2 pr-4">Pogodba</th> <th class="py-2 pr-4">Pogodba</th>
<th class="py-2 pr-4">Primer</th>
<th class="py-2 pr-4">Naslov</th>
<th class="py-2 pr-4">Stranka</th> <th class="py-2 pr-4">Stranka</th>
<th class="py-2 pr-4">Dodeljeno dne</th> <th class="py-2 pr-4">Dodeljeno dne</th>
<th class="py-2 pr-4">Dodeljeno komu</th> <th class="py-2 pr-4">Dodeljeno komu</th>
@ -170,31 +337,84 @@ const assignedContractsFiltered = computed(() => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="c in assignedContractsFiltered" :key="c.uuid" class="border-b last:border-0"> <tr
v-for="c in assignedPageItems"
:key="c.uuid"
class="border-b last:border-0"
>
<td class="py-2 pr-4">{{ c.reference }}</td> <td class="py-2 pr-4">{{ c.reference }}</td>
<td class="py-2 pr-4"> <td class="py-2 pr-4">
<Link <Link
v-if="c.client_case?.uuid" v-if="c.client_case?.uuid"
:href="route('clientCase.show', { client_case: c.client_case.uuid })" :href="
route('clientCase.show', { client_case: c.client_case.uuid })
"
class="text-indigo-600 hover:underline" class="text-indigo-600 hover:underline"
> >
{{ c.client_case?.person?.full_name || 'Primer stranke' }} {{ c.client_case?.person?.full_name || "Primer stranke" }}
</Link> </Link>
<span v-else>{{ c.client_case?.person?.full_name || '-' }}</span> <span v-else>{{ c.client_case?.person?.full_name || "-" }}</span>
</td> </td>
<td class="py-2 pr-4">{{ formatDate(props.assignments?.[c.uuid]?.assigned_at) }}</td> <td class="py-2 pr-4">{{ primaryCaseAddress(c) }}</td>
<td class="py-2 pr-4">{{ assignedTo(c) || '-' }}</td>
<td class="py-2 pr-4">{{ formatCurrencyEUR(c.account?.balance_amount) }}</td>
<td class="py-2 pr-4"> <td class="py-2 pr-4">
<button class="px-3 py-1 text-sm rounded bg-red-600 text-white" @click="cancelAssignment(c)">Prekliči</button> {{ c.client?.person?.full_name || "-" }}
</td>
<td class="py-2 pr-4">
{{ formatDate(props.assignments?.[c.uuid]?.assigned_at) }}
</td>
<td class="py-2 pr-4">{{ assignedTo(c) || "-" }}</td>
<td class="py-2 pr-4">
{{ formatCurrencyEUR(c.account?.balance_amount) }}
</td>
<td class="py-2 pr-4">
<button
class="px-3 py-1 text-sm rounded bg-red-600 text-white"
@click="cancelAssignment(c)"
>
Prekliči
</button>
</td> </td>
</tr> </tr>
<tr v-if="assignedContractsFiltered.length === 0"> <tr v-if="assignedPageItems.length === 0">
<td colspan="6" class="py-4 text-gray-500">Ni dodeljenih pogodb za izbran filter.</td> <td colspan="8" class="py-4 text-gray-500">
Ni dodeljenih pogodb za izbran filter.
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Assigned pagination -->
<div class="flex items-center justify-between mt-4 text-sm text-gray-700">
<div>
Prikazano
{{ Math.min((assignedPage - 1) * assignedPerPage + 1, assignedTotal) }}{{
Math.min(assignedPage * assignedPerPage, assignedTotal)
}}
od {{ assignedTotal }}
</div>
<div class="flex items-center gap-2">
<select v-model.number="assignedPerPage" class="border rounded px-2 py-1">
<option :value="10">10</option>
<option :value="25">25</option>
<option :value="50">50</option>
</select>
<button
class="px-2 py-1 border rounded disabled:opacity-50"
:disabled="assignedPage <= 1"
@click="assignedPage = Math.max(1, assignedPage - 1)"
>
Prejšnja
</button>
<span>Stran {{ assignedPage }} / {{ assignedTotalPages }}</span>
<button
class="px-2 py-1 border rounded disabled:opacity-50"
:disabled="assignedPage >= assignedTotalPages"
@click="assignedPage = Math.min(assignedTotalPages, assignedPage + 1)"
>
Naslednja
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,20 +1,20 @@
<script setup> <script setup>
import AppPhoneLayout from '@/Layouts/AppPhoneLayout.vue'; import AppPhoneLayout from "@/Layouts/AppPhoneLayout.vue";
import SectionTitle from '@/Components/SectionTitle.vue'; import SectionTitle from "@/Components/SectionTitle.vue";
import PersonDetailPhone from '@/Components/PersonDetailPhone.vue'; import PersonDetailPhone from "@/Components/PersonDetailPhone.vue";
// Removed table-based component for phone; render a list instead // Removed table-based component for phone; render a list instead
// import DocumentsTable from '@/Components/DocumentsTable.vue'; // import DocumentsTable from '@/Components/DocumentsTable.vue';
import DocumentViewerDialog from '@/Components/DocumentViewerDialog.vue'; import DocumentViewerDialog from "@/Components/DocumentViewerDialog.vue";
import { classifyDocument } from '@/Services/documents'; import { classifyDocument } from "@/Services/documents";
import { reactive, ref, computed } from 'vue'; import { reactive, ref, computed } from "vue";
import DialogModal from '@/Components/DialogModal.vue'; import DialogModal from "@/Components/DialogModal.vue";
import InputLabel from '@/Components/InputLabel.vue'; import InputLabel from "@/Components/InputLabel.vue";
import TextInput from '@/Components/TextInput.vue'; import TextInput from "@/Components/TextInput.vue";
import PrimaryButton from '@/Components/PrimaryButton.vue'; import PrimaryButton from "@/Components/PrimaryButton.vue";
import BasicButton from '@/Components/buttons/BasicButton.vue'; import BasicButton from "@/Components/buttons/BasicButton.vue";
import { useForm } from '@inertiajs/vue3'; import { useForm } from "@inertiajs/vue3";
import ActivityDrawer from '@/Pages/Cases/Partials/ActivityDrawer.vue'; import ActivityDrawer from "@/Pages/Cases/Partials/ActivityDrawer.vue";
import ConfirmationModal from '@/Components/ConfirmationModal.vue'; import ConfirmationModal from "@/Components/ConfirmationModal.vue";
const props = defineProps({ const props = defineProps({
client: Object, client: Object,
@ -26,29 +26,51 @@ const props = defineProps({
activities: Array, activities: Array,
}); });
const viewer = reactive({ open: false, src: '', title: '' }); const viewer = reactive({ open: false, src: "", title: "" });
function openViewer(doc) { function openViewer(doc) {
const kind = classifyDocument(doc); const kind = classifyDocument(doc);
const isContractDoc = (doc?.documentable_type || '').toLowerCase().includes('contract'); const isContractDoc = (doc?.documentable_type || "").toLowerCase().includes("contract");
if (kind === 'preview') { if (kind === "preview") {
const url = isContractDoc && doc.contract_uuid const url =
? route('contract.document.view', { contract: doc.contract_uuid, document: doc.uuid }) isContractDoc && doc.contract_uuid
: route('clientCase.document.view', { client_case: props.client_case.uuid, document: doc.uuid }); ? route("contract.document.view", {
viewer.open = true; viewer.src = url; viewer.title = doc.original_name || doc.name; contract: doc.contract_uuid,
document: doc.uuid,
})
: route("clientCase.document.view", {
client_case: props.client_case.uuid,
document: doc.uuid,
});
viewer.open = true;
viewer.src = url;
viewer.title = doc.original_name || doc.name;
} else { } else {
const url = isContractDoc && doc.contract_uuid const url =
? route('contract.document.download', { contract: doc.contract_uuid, document: doc.uuid }) isContractDoc && doc.contract_uuid
: route('clientCase.document.download', { client_case: props.client_case.uuid, document: doc.uuid }); ? route("contract.document.download", {
contract: doc.contract_uuid,
document: doc.uuid,
})
: route("clientCase.document.download", {
client_case: props.client_case.uuid,
document: doc.uuid,
});
window.location.href = url; window.location.href = url;
} }
} }
function closeViewer() { viewer.open = false; viewer.src = ''; } function closeViewer() {
viewer.open = false;
viewer.src = "";
}
function formatAmount(val) { function formatAmount(val) {
if (val === null || val === undefined) return '0,00'; if (val === null || val === undefined) return "0,00";
const num = typeof val === 'number' ? val : parseFloat(val); const num = typeof val === "number" ? val : parseFloat(val);
if (Number.isNaN(num)) return String(val); if (Number.isNaN(num)) return String(val);
return num.toLocaleString('sl-SI', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return num.toLocaleString("sl-SI", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
} }
// Activity drawer state // Activity drawer state
@ -58,40 +80,51 @@ const openDrawerAddActivity = (c = null) => {
activityContractUuid.value = c?.uuid ?? null; activityContractUuid.value = c?.uuid ?? null;
drawerAddActivity.value = true; drawerAddActivity.value = true;
}; };
const closeDrawer = () => { drawerAddActivity.value = false; }; const closeDrawer = () => {
drawerAddActivity.value = false;
};
// Document upload state // Document upload state
const docDialogOpen = ref(false); const docDialogOpen = ref(false);
const docForm = useForm({ const docForm = useForm({
file: null, file: null,
name: '', name: "",
description: '', description: "",
is_public: true, is_public: true,
contract_uuid: null, contract_uuid: null,
}); });
const onPickDocument = (e) => { const onPickDocument = (e) => {
const f = e?.target?.files?.[0]; const f = e?.target?.files?.[0];
if (f) { docForm.file = f; } if (f) {
docForm.file = f;
}
}; };
const openDocDialog = (c = null) => { const openDocDialog = (c = null) => {
docForm.contract_uuid = c?.uuid ?? null; docForm.contract_uuid = c?.uuid ?? null;
docDialogOpen.value = true; docDialogOpen.value = true;
}; };
const closeDocDialog = () => { docDialogOpen.value = false; }; const closeDocDialog = () => {
docDialogOpen.value = false;
};
const submitDocument = () => { const submitDocument = () => {
if (!docForm.file) { return; } if (!docForm.file) {
docForm.post(route('clientCase.document.store', { client_case: props.client_case.uuid }), { return;
forceFormData: true, }
onSuccess: () => { docForm.post(
closeDocDialog(); route("clientCase.document.store", { client_case: props.client_case.uuid }),
docForm.reset('file', 'name', 'description', 'is_public', 'contract_uuid'); {
}, forceFormData: true,
}); onSuccess: () => {
closeDocDialog();
docForm.reset("file", "name", "description", "is_public", "contract_uuid");
},
}
);
}; };
const selectedContract = computed(() => { const selectedContract = computed(() => {
if (!docForm.contract_uuid) return null; if (!docForm.contract_uuid) return null;
return props.contracts?.find(c => c.uuid === docForm.contract_uuid) || null; return props.contracts?.find((c) => c.uuid === docForm.contract_uuid) || null;
}); });
// Complete flow // Complete flow
@ -100,8 +133,10 @@ const submitComplete = () => {
// POST to phone.case.complete and redirect handled by server // POST to phone.case.complete and redirect handled by server
// Use a small form post via Inertia // Use a small form post via Inertia
const form = useForm({}); const form = useForm({});
form.post(route('phone.case.complete', { client_case: props.client_case.uuid }), { form.post(route("phone.case.complete", { client_case: props.client_case.uuid }), {
onFinish: () => { confirmComplete.value = false; }, onFinish: () => {
confirmComplete.value = false;
},
}); });
}; };
</script> </script>
@ -111,15 +146,23 @@ const submitComplete = () => {
<template #header> <template #header>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<a :href="route('phone.index')" class="text-sm text-blue-600 hover:underline shrink-0"> Nazaj</a> <a
<h2 class="font-semibold text-xl text-gray-800 truncate">{{ client_case?.person?.full_name }}</h2> :href="route('phone.index')"
class="text-sm text-blue-600 hover:underline shrink-0"
> Nazaj</a
>
<h2 class="font-semibold text-xl text-gray-800 truncate">
{{ client_case?.person?.full_name }}
</h2>
</div> </div>
<div class="shrink-0"> <div class="shrink-0">
<button <button
type="button" type="button"
class="px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700" class="px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700"
@click="confirmComplete = true" @click="confirmComplete = true"
>Zaključi</button> >
Zaključi
</button>
</div> </div>
</div> </div>
</template> </template>
@ -133,7 +176,11 @@ const submitComplete = () => {
<template #title>Stranka</template> <template #title>Stranka</template>
</SectionTitle> </SectionTitle>
<div class="mt-2"> <div class="mt-2">
<PersonDetailPhone :types="types" :person="client.person" default-tab="phones" /> <PersonDetailPhone
:types="types"
:person="client.person"
default-tab="phones"
/>
</div> </div>
</div> </div>
</div> </div>
@ -145,7 +192,11 @@ const submitComplete = () => {
<template #title>Primer - oseba</template> <template #title>Primer - oseba</template>
</SectionTitle> </SectionTitle>
<div class="mt-2"> <div class="mt-2">
<PersonDetailPhone :types="types" :person="client_case.person" default-tab="phones" /> <PersonDetailPhone
:types="types"
:person="client_case.person"
default-tab="phones"
/>
</div> </div>
</div> </div>
</div> </div>
@ -165,81 +216,48 @@ const submitComplete = () => {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="font-medium text-gray-900">{{ c.reference || c.uuid }}</p> <p class="font-medium text-gray-900">{{ c.reference || c.uuid }}</p>
<p class="text-sm text-gray-600">Tip: {{ c.type?.name || '—' }}</p> <p class="text-sm text-gray-600">Tip: {{ c.type?.name || "—" }}</p>
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="space-y-2"> <div class="space-y-2">
<p v-if="c.account" class="text-sm text-gray-700">Odprto: {{ formatAmount(c.account.balance_amount) }} </p> <p v-if="c.account" class="text-sm text-gray-700">
<button Odprto: {{ formatAmount(c.account.balance_amount) }}
type="button" </p>
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 w-full sm:w-auto" <button
@click="openDrawerAddActivity(c)" type="button"
>+ Aktivnost</button> class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 w-full sm:w-auto"
<button @click="openDrawerAddActivity(c)"
type="button" >
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 w-full sm:w-auto" + Aktivnost
@click="openDocDialog(c)" </button>
>+ Dokument</button> <button
</div> type="button"
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 w-full sm:w-auto"
@click="openDocDialog(c)"
>
+ Dokument
</button>
</div> </div>
</div>
<div v-if="c.last_object" class="mt-2 text-sm text-gray-700">
<p class="font-medium">Predmet:</p>
<p>
<span class="text-gray-900">{{ c.last_object.name || c.last_object.reference }}</span>
<span v-if="c.last_object.type" class="ml-2 text-gray-500">({{ c.last_object.type }})</span>
</p>
<p v-if="c.last_object.description" class="text-gray-600 mt-1">{{ c.last_object.description }}</p>
</div>
</div>
<p v-if="!contracts?.length" class="text-sm text-gray-600">Ni pogodbenih obveznosti dodeljenih vam za ta primer.</p>
</div>
</div>
</div>
<!-- Documents (case + assigned contracts) -->
<div class="mt-4 sm:mt-6 bg-white rounded-lg shadow border overflow-hidden">
<div class="p-3 sm:p-4">
<div class="flex items-center justify-between">
<SectionTitle>
<template #title>Dokumenti</template>
</SectionTitle>
<button
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
@click="openDocDialog()"
>Dodaj</button>
</div>
<div class="mt-3 divide-y">
<div
v-for="d in documents"
:key="d.uuid || d.id"
class="py-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="font-medium text-gray-900 truncate">{{ d.name || d.original_name }}</div>
<div class="text-xs text-gray-500 mt-0.5">
<span v-if="d.contract_reference">Pogodba: {{ d.contract_reference }}</span>
<span v-else>Primer</span>
<span v-if="d.created_at" class="ml-2">· {{ new Date(d.created_at).toLocaleDateString('sl-SI') }}</span>
</div>
<div v-if="d.description" class="text-gray-600 text-sm mt-1 line-clamp-2">{{ d.description }}</div>
</div>
<div class="shrink-0 flex flex-col items-end gap-2">
<button
type="button"
class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-800"
@click="openViewer(d)"
>Ogled</button>
<a
class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-800"
:href="(() => { const isC = (d?.documentable_type || '').toLowerCase().includes('contract'); return isC && d.contract_uuid ? route('contract.document.download', { contract: d.contract_uuid, document: d.uuid }) : route('clientCase.document.download', { client_case: client_case.uuid, document: d.uuid }); })()"
>Prenesi</a>
</div> </div>
</div> </div>
<div v-if="c.last_object" class="mt-2 text-sm text-gray-700">
<p class="font-medium">Predmet:</p>
<p>
<span class="text-gray-900">{{
c.last_object.name || c.last_object.reference
}}</span>
<span v-if="c.last_object.type" class="ml-2 text-gray-500"
>({{ c.last_object.type }})</span
>
</p>
<p v-if="c.last_object.description" class="text-gray-600 mt-1">
{{ c.last_object.description }}
</p>
</div>
</div> </div>
<div v-if="!documents?.length" class="text-gray-600 text-sm py-2">Ni dokumentov.</div> <p v-if="!contracts?.length" class="text-sm text-gray-600">
Ni pogodbenih obveznosti dodeljenih vam za ta primer.
</p>
</div> </div>
</div> </div>
</div> </div>
@ -254,44 +272,154 @@ const submitComplete = () => {
<button <button
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
@click="openDrawerAddActivity()" @click="openDrawerAddActivity()"
>Nova</button> >
Nova
</button>
</div> </div>
<div class="mt-2 divide-y"> <div class="mt-2 divide-y">
<div v-for="a in activities" :key="a.id" class="py-2 text-sm"> <div v-for="a in activities" :key="a.id" class="py-2 text-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-gray-800">{{ a.action?.name }}<span v-if="a.decision"> {{ a.decision?.name }}</span></div> <div class="text-gray-800">
{{ a.action?.name
}}<span v-if="a.decision"> {{ a.decision?.name }}</span>
</div>
<div class="text-right text-gray-500"> <div class="text-right text-gray-500">
<div v-if="a.contract">Pogodba: {{ a.contract.reference }}</div> <div v-if="a.contract">Pogodba: {{ a.contract.reference }}</div>
<div class="text-xs" v-if="a.created_at || a.user || a.user_name"> <div class="text-xs" v-if="a.created_at || a.user || a.user_name">
<span v-if="a.created_at">{{ new Date(a.created_at).toLocaleDateString('sl-SI') }}</span> <span v-if="a.created_at">{{
<span v-if="(a.user && a.user.name) || a.user_name" class="ml-1">· {{ a.user?.name || a.user_name }}</span> new Date(a.created_at).toLocaleDateString("sl-SI")
}}</span>
<span v-if="(a.user && a.user.name) || a.user_name" class="ml-1"
>· {{ a.user?.name || a.user_name }}</span
>
</div> </div>
</div> </div>
</div> </div>
<div v-if="a.note" class="text-gray-600">{{ a.note }}</div> <div v-if="a.note" class="text-gray-600">{{ a.note }}</div>
<div class="text-gray-500"> <div class="text-gray-500">
<span v-if="a.due_date">Zapadlost: {{ a.due_date }}</span> <span v-if="a.due_date">Zapadlost: {{ a.due_date }}</span>
<span v-if="a.amount != null" class="ml-2">Znesek: {{ formatAmount(a.amount) }} </span> <span v-if="a.amount != null" class="ml-2"
>Znesek: {{ formatAmount(a.amount) }} </span
>
</div> </div>
</div> </div>
<div v-if="!activities?.length" class="text-gray-600 py-2">Ni aktivnosti.</div> <div v-if="!activities?.length" class="text-gray-600 py-2">
Ni aktivnosti.
</div>
</div>
</div>
</div>
<!-- Documents (case + assigned contracts) -->
<div class="mt-4 sm:mt-6 bg-white rounded-lg shadow border overflow-hidden">
<div class="p-3 sm:p-4">
<div class="flex items-center justify-between">
<SectionTitle>
<template #title>Dokumenti</template>
</SectionTitle>
<button
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
@click="openDocDialog()"
>
Dodaj
</button>
</div>
<div class="mt-3 divide-y">
<div v-for="d in documents" :key="d.uuid || d.id" class="py-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="font-medium text-gray-900 truncate">
{{ d.name || d.original_name }}
</div>
<div class="text-xs text-gray-500 mt-0.5">
<span v-if="d.contract_reference"
>Pogodba: {{ d.contract_reference }}</span
>
<span v-else>Primer</span>
<span v-if="d.created_at" class="ml-2"
>· {{ new Date(d.created_at).toLocaleDateString("sl-SI") }}</span
>
</div>
<div
v-if="d.description"
class="text-gray-600 text-sm mt-1 line-clamp-2"
>
{{ d.description }}
</div>
</div>
<div class="shrink-0 flex flex-col items-end gap-2">
<button
type="button"
class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-800"
@click="openViewer(d)"
>
Ogled
</button>
<a
class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-800"
:href="
(() => {
const isC = (d?.documentable_type || '')
.toLowerCase()
.includes('contract');
return isC && d.contract_uuid
? route('contract.document.download', {
contract: d.contract_uuid,
document: d.uuid,
})
: route('clientCase.document.download', {
client_case: client_case.uuid,
document: d.uuid,
});
})()
"
>Prenesi</a
>
</div>
</div>
</div>
<div v-if="!documents?.length" class="text-gray-600 text-sm py-2">
Ni dokumentov.
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<DocumentViewerDialog :show="viewer.open" :src="viewer.src" :title="viewer.title" @close="closeViewer" /> <DocumentViewerDialog
<ActivityDrawer :show="drawerAddActivity" @close="closeDrawer" :client_case="client_case" :actions="actions" :contract-uuid="activityContractUuid" /> :show="viewer.open"
:src="viewer.src"
:title="viewer.title"
@close="closeViewer"
/>
<ActivityDrawer
:show="drawerAddActivity"
@close="closeDrawer"
:client_case="client_case"
:actions="actions"
:contract-uuid="activityContractUuid"
/>
<ConfirmationModal :show="confirmComplete" @close="confirmComplete = false"> <ConfirmationModal :show="confirmComplete" @close="confirmComplete = false">
<template #title>Potrditev</template> <template #title>Potrditev</template>
<template #content> <template #content> Ali ste prepričani da želite že zaključit stranko? </template>
Ali ste prepričani da želite že zaključit stranko?
</template>
<template #footer> <template #footer>
<button type="button" class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200" @click="confirmComplete = false">Prekliči</button> <button
<button type="button" class="px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700 ml-2" @click="submitComplete">Potrdi</button> type="button"
class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200"
@click="confirmComplete = false"
>
Prekliči
</button>
<button
type="button"
class="px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700 ml-2"
@click="submitComplete"
>
Potrdi
</button>
</template> </template>
</ConfirmationModal> </ConfirmationModal>
@ -301,22 +429,40 @@ const submitComplete = () => {
<template #content> <template #content>
<div class="space-y-4"> <div class="space-y-4">
<div v-if="selectedContract" class="text-sm text-gray-700"> <div v-if="selectedContract" class="text-sm text-gray-700">
Dokument bo dodan k pogodbi: <span class="font-medium">{{ selectedContract.reference || selectedContract.uuid }}</span> Dokument bo dodan k pogodbi:
<span class="font-medium">{{
selectedContract.reference || selectedContract.uuid
}}</span>
</div> </div>
<div> <div>
<InputLabel for="docFile" value="Datoteka" /> <InputLabel for="docFile" value="Datoteka" />
<input id="docFile" type="file" class="mt-1 block w-full" @change="onPickDocument" /> <input
<div v-if="docForm.errors.file" class="text-sm text-red-600 mt-1">{{ docForm.errors.file }}</div> id="docFile"
type="file"
class="mt-1 block w-full"
@change="onPickDocument"
/>
<div v-if="docForm.errors.file" class="text-sm text-red-600 mt-1">
{{ docForm.errors.file }}
</div>
</div> </div>
<div> <div>
<InputLabel for="docName" value="Ime" /> <InputLabel for="docName" value="Ime" />
<TextInput id="docName" v-model="docForm.name" class="mt-1 block w-full" /> <TextInput id="docName" v-model="docForm.name" class="mt-1 block w-full" />
<div v-if="docForm.errors.name" class="text-sm text-red-600 mt-1">{{ docForm.errors.name }}</div> <div v-if="docForm.errors.name" class="text-sm text-red-600 mt-1">
{{ docForm.errors.name }}
</div>
</div> </div>
<div> <div>
<InputLabel for="docDesc" value="Opis" /> <InputLabel for="docDesc" value="Opis" />
<TextInput id="docDesc" v-model="docForm.description" class="mt-1 block w-full" /> <TextInput
<div v-if="docForm.errors.description" class="text-sm text-red-600 mt-1">{{ docForm.errors.description }}</div> id="docDesc"
v-model="docForm.description"
class="mt-1 block w-full"
/>
<div v-if="docForm.errors.description" class="text-sm text-red-600 mt-1">
{{ docForm.errors.description }}
</div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input id="docPublic" type="checkbox" v-model="docForm.is_public" /> <input id="docPublic" type="checkbox" v-model="docForm.is_public" />
@ -326,14 +472,25 @@ const submitComplete = () => {
</template> </template>
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button type="button" class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200" @click="closeDocDialog">Prekliči</button> <button
<button type="button" class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" :disabled="docForm.processing || !docForm.file" @click="submitDocument">Naloži</button> type="button"
class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200"
@click="closeDocDialog"
>
Prekliči
</button>
<button
type="button"
class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
:disabled="docForm.processing || !docForm.file"
@click="submitDocument"
>
Naloži
</button>
</div> </div>
</template> </template>
</DialogModal> </DialogModal>
</AppPhoneLayout> </AppPhoneLayout>
</template> </template>
<style scoped> <style scoped></style>
</style>

View File

@ -53,33 +53,34 @@ function formatAmount(val) {
<div class="py-4 sm:py-8"> <div class="py-4 sm:py-8">
<div class="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">
<div class="mb-4 flex items-center gap-2"> <div class="mb-4 flex items-center gap-2">
<input <input v-model="search" type="text" placeholder="Išči po referenci ali imenu..."
v-model="search" class="flex-1 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
type="text" <button v-if="search" type="button" @click="search = ''"
placeholder="Išči po referenci ali imenu..." class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-600">Počisti</button>
class="flex-1 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
<button
v-if="search"
type="button"
@click="search = ''"
class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-600"
>Počisti</button>
</div> </div>
<div class="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3">
<template v-if="filteredJobs.length"> <template v-if="filteredJobs.length">
<div v-for="job in filteredJobs" :key="job.id" class="bg-white rounded-lg shadow border p-3 sm:p-4"> <div v-for="job in filteredJobs" :key="job.id" class="bg-white rounded-lg shadow border p-3 sm:p-4">
<div class="mb-4 flex gap-2">
<a :href="route('phone.case', { client_case: job.contract?.client_case?.uuid })"
class="inline-flex-1 flex-1 text-center px-3 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700">Odpri
primer</a>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="text-sm text-gray-500">Dodeljeno: <span class="font-medium text-gray-700">{{ formatDateDMY(job.assigned_at) }}</span></p> <p class="text-sm text-gray-500">Dodeljeno: <span class="font-medium text-gray-700">{{
<span v-if="job.priority" class="inline-block text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700">Prioriteta</span> formatDateDMY(job.assigned_at) }}</span></p>
<span v-if="job.priority"
class="inline-block text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700">Prioriteta</span>
</div> </div>
<div class="mt-2"> <div class="mt-2">
<p class="text-base sm:text-lg font-semibold text-gray-800"> <p class="text-base sm:text-lg font-semibold text-gray-800">
{{ job.contract?.client_case?.person?.full_name || '—' }} {{ job.contract?.client_case?.person?.full_name || '—' }}
</p> </p>
<p class="text-sm text-gray-600 truncate">Kontrakt: {{ job.contract?.reference || job.contract?.uuid }}</p> <p class="text-sm text-gray-600 truncate">Kontrakt: {{ job.contract?.reference || job.contract?.uuid }}
</p>
<p class="text-sm text-gray-600">Tip: {{ job.contract?.type?.name || '—' }}</p> <p class="text-sm text-gray-600">Tip: {{ job.contract?.type?.name || '—' }}</p>
<p class="text-sm text-gray-600" v-if="job.contract?.account && job.contract.account.balance_amount !== null && job.contract.account.balance_amount !== undefined"> <p class="text-sm text-gray-600"
v-if="job.contract?.account && job.contract.account.balance_amount !== null && job.contract.account.balance_amount !== undefined">
Odprto: {{ formatAmount(job.contract.account.balance_amount) }} Odprto: {{ formatAmount(job.contract.account.balance_amount) }}
</p> </p>
</div> </div>
@ -93,9 +94,7 @@ function formatAmount(val) {
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || '—' }} {{ job.contract?.client_case?.person?.phones?.[0]?.nu || '—' }}
</p> </p>
</div> </div>
<div class="mt-4 flex gap-2">
<a :href="route('phone.case', { client_case: job.contract?.client_case?.uuid })" class="inline-flex-1 flex-1 text-center px-3 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700">Odpri primer</a>
</div>
</div> </div>
</template> </template>
<div v-else class="col-span-full bg-white rounded-lg shadow border p-6 text-center text-gray-600"> <div v-else class="col-span-full bg-white rounded-lg shadow border p-6 text-center text-gray-600">

View File

@ -0,0 +1,107 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link } from "@inertiajs/vue3";
import { computed, ref } from "vue";
const props = defineProps({
segments: Array,
});
const search = ref("");
const filtered = computed(() => {
const q = (search.value || "").toLowerCase();
if (!q) {
return props.segments || [];
}
return (props.segments || []).filter((s) => {
return (
String(s.name || "")
.toLowerCase()
.includes(q) ||
String(s.description || "")
.toLowerCase()
.includes(q)
);
});
});
function formatCurrencyEUR(value) {
if (value === null || value === undefined) {
return "-";
}
const n = Number(value);
if (isNaN(n)) {
return String(value);
}
return (
n.toLocaleString("sl-SI", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +
" €"
);
}
</script>
<template>
<AppLayout title="Segmenti">
<template #header>Segmenti</template>
<div class="pt-12">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-4 mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Iskanje (segment ali opis)</label
>
<input
v-model="search"
type="text"
class="border rounded px-3 py-2 w-full max-w-xl"
placeholder="Išči po nazivu segmenta ali opisu"
/>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4">Aktivni segmenti</h2>
<div
v-if="filtered.length"
class="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
>
<div
v-for="s in filtered"
:key="s.id"
class="border rounded-lg p-4 shadow-sm hover:shadow transition bg-white"
>
<div class="flex items-start justify-between mb-2">
<h3 class="text-base font-semibold text-gray-900">
<Link :href="route('segments.show', s.id)" class="hover:underline">{{
s.name
}}</Link>
</h3>
<span
class="inline-flex items-center text-xs px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-100"
>
{{ s.contracts_count ?? 0 }} pogodb
</span>
</div>
<p class="text-sm text-gray-600 min-h-[1.25rem]">
{{ s.description || "—" }}
</p>
<div class="mt-4 flex items-center justify-between">
<div class="text-sm text-gray-500">Vsota stanj</div>
<div class="text-sm font-medium text-gray-900">
{{ formatCurrencyEUR(s.total_balance) }}
</div>
</div>
<div class="mt-3">
<Link
:href="route('segments.show', s.id)"
class="text-sm text-indigo-600 hover:underline"
>Odpri</Link
>
</div>
</div>
</div>
<div v-else class="text-gray-500">Ni aktivnih segmentov.</div>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@ -0,0 +1,156 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { debounce } from "lodash";
import { ref, watch, onUnmounted } from "vue";
const props = defineProps({
segment: Object,
contracts: Object, // LengthAwarePaginator payload from Laravel
});
const search = ref("");
const applySearch = debounce((v) => {
const params = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
if (v) {
params.search = v;
} else {
delete params.search;
}
// reset pagination when typing
delete params.page;
router.get(
route("segments.show", { segment: props.segment?.id ?? props.segment }),
params,
{ preserveState: true, replace: true, preserveScroll: true }
);
}, 300);
watch(search, (v) => applySearch(v));
onUnmounted(() => applySearch.cancel && applySearch.cancel());
function formatDate(value) {
if (!value) {
return "-";
}
const d = new Date(value);
if (isNaN(d)) return value;
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(
2,
"0"
)}.${d.getFullYear()}`;
}
function formatCurrency(value) {
if (value === null || value === undefined) return "-";
const n = Number(value);
if (isNaN(n)) return String(value);
return (
n.toLocaleString("sl-SI", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +
" €"
);
}
</script>
<template>
<AppLayout :title="`Segment: ${segment?.name || ''}`">
<template #header>
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold">Segment: {{ segment?.name }}</h1>
<Link
:href="route('segments.index')"
class="text-sm text-indigo-600 hover:underline"
>Nazaj na segmente</Link
>
</div>
</template>
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<div class="text-sm text-gray-600 mb-4">{{ segment?.description }}</div>
<div class="flex items-center justify-between mb-4 gap-3">
<input
v-model="search"
type="text"
placeholder="Iskanje po referenci ali imenu"
class="w-full sm:w-80 rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 text-sm"
/>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">Pogodba</th>
<th class="py-2 pr-4">Primer</th>
<th class="py-2 pr-4">Stranka</th>
<th class="py-2 pr-4">Vrsta</th>
<th class="py-2 pr-4">Začetek</th>
<th class="py-2 pr-4">Konec</th>
<th class="py-2 pr-4">Stanje</th>
</tr>
</thead>
<tbody>
<tr
v-for="c in contracts.data"
:key="c.uuid"
class="border-b last:border-0"
>
<td class="py-2 pr-4">{{ c.reference }}</td>
<td class="py-2 pr-4">
<Link
v-if="c.client_case?.uuid"
:href="
route('clientCase.show', {
client_case: c.client_case.uuid,
segment: segment.id,
})
"
class="text-indigo-600 hover:underline"
>
{{ c.client_case?.person?.full_name || "Primer stranke" }}
</Link>
<span v-else>{{ c.client_case?.person?.full_name || "-" }}</span>
</td>
<td class="py-2 pr-4">{{ c.client?.person?.full_name || "-" }}</td>
<td class="py-2 pr-4">{{ c.type?.name }}</td>
<td class="py-2 pr-4">{{ formatDate(c.start_date) }}</td>
<td class="py-2 pr-4">{{ formatDate(c.end_date) }}</td>
<td class="py-2 pr-4">{{ formatCurrency(c.account?.balance_amount) }}</td>
</tr>
<tr v-if="!contracts.data || contracts.data.length === 0">
<td colspan="7" class="py-4 text-gray-500">Ni pogodb v tem segmentu.</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div
class="flex items-center justify-between mt-4 text-sm text-gray-700"
v-if="contracts.total > 0"
>
<div>
Prikazano {{ contracts.from || 0 }}{{ contracts.to || 0 }} od
{{ contracts.total }}
</div>
<div class="flex items-center gap-2">
<Link
v-if="contracts.prev_page_url"
:href="contracts.prev_page_url"
class="px-2 py-1 border rounded hover:bg-gray-50"
>Prejšnja</Link
>
<span>Stran {{ contracts.current_page }} / {{ contracts.last_page }}</span>
<Link
v-if="contracts.next_page_url"
:href="contracts.next_page_url"
class="px-2 py-1 border rounded hover:bg-gray-50"
>Naslednja</Link
>
</div>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@ -157,6 +157,7 @@
Route::delete('client-cases/{client_case:uuid}/objects/{id}', [CaseObjectController::class, 'destroy'])->name('clientCase.object.delete'); Route::delete('client-cases/{client_case:uuid}/objects/{id}', [CaseObjectController::class, 'destroy'])->name('clientCase.object.delete');
// client-case / activity // client-case / activity
Route::post('client-cases/{client_case:uuid}/activity', [ClientCaseContoller::class, 'storeActivity'])->name('clientCase.activity.store'); Route::post('client-cases/{client_case:uuid}/activity', [ClientCaseContoller::class, 'storeActivity'])->name('clientCase.activity.store');
Route::delete('client-cases/{client_case:uuid}/activity/{activity}', [ClientCaseContoller::class, 'deleteActivity'])->name('clientCase.activity.delete');
// client-case / segments // client-case / segments
Route::post('client-cases/{client_case:uuid}/segments', [ClientCaseContoller::class, 'attachSegment'])->name('clientCase.segments.attach'); Route::post('client-cases/{client_case:uuid}/segments', [ClientCaseContoller::class, 'attachSegment'])->name('clientCase.segments.attach');
// client-case / documents // client-case / documents
@ -192,6 +193,10 @@
Route::delete('settings/actions/{id}', [WorkflowController::class, 'destroyAction'])->name('settings.actions.destroy'); Route::delete('settings/actions/{id}', [WorkflowController::class, 'destroyAction'])->name('settings.actions.destroy');
Route::delete('settings/decisions/{id}', [WorkflowController::class, 'destroyDecision'])->name('settings.decisions.destroy'); Route::delete('settings/decisions/{id}', [WorkflowController::class, 'destroyDecision'])->name('settings.decisions.destroy');
// segments index overview
Route::get('segments', [SegmentController::class, 'index'])->name('segments.index');
Route::get('segments/{segment}', [SegmentController::class, 'show'])->name('segments.show');
// imports // imports
Route::get('imports/create', [ImportController::class, 'create'])->name('imports.create'); Route::get('imports/create', [ImportController::class, 'create'])->name('imports.create');
Route::get('imports', [ImportController::class, 'index'])->name('imports.index'); Route::get('imports', [ImportController::class, 'index'])->name('imports.index');