This commit is contained in:
Simon Pocrnjič 2025-09-28 00:30:18 +02:00
parent 7227c888d4
commit a913cfc381
44 changed files with 2123 additions and 587 deletions

View File

@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
use App\Models\CaseObject;
use App\Models\ClientCase;
use App\Models\Contract;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
class CaseObjectController extends Controller
{
public function store(ClientCase $clientCase, string $uuid, Request $request)
{
$contract = Contract::where('uuid', $uuid)->where('client_case_id', $clientCase->id)->firstOrFail();
$validated = $request->validate([
'reference' => 'nullable|string|max:125',
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:255',
'type' => 'nullable|string|max:125',
]);
$contract->objects()->create($validated);
return to_route('clientCase.show', $clientCase)->with('success', 'Object created.');
}
public function update(ClientCase $clientCase, int $id, Request $request)
{
$object = CaseObject::where('id', $id)
->whereHas('contract', fn($q) => $q->where('client_case_id', $clientCase->id))
->firstOrFail();
$validated = $request->validate([
'reference' => 'nullable|string|max:125',
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:255',
'type' => 'nullable|string|max:125',
]);
$object->update($validated);
return to_route('clientCase.show', $clientCase)->with('success', 'Object updated.');
}
public function destroy(ClientCase $clientCase, int $id)
{
$object = CaseObject::where('id', $id)
->whereHas('contract', fn($q) => $q->where('client_case_id', $clientCase->id))
->firstOrFail();
$object->delete();
return to_route('clientCase.show', $clientCase)->with('success', 'Object deleted.');
}
}

View File

@ -98,13 +98,24 @@ public function storeContract(ClientCase $clientCase, Request $request)
\DB::transaction(function() use ($request, $clientCase){
//Create contract
$clientCase->contracts()->create([
// Create contract
$contract = $clientCase->contracts()->create([
'reference' => $request->input('reference'),
'start_date' => date('Y-m-d', strtotime($request->input('start_date'))),
'type_id' => $request->input('type_id')
'type_id' => $request->input('type_id'),
'description' => $request->input('description'),
]);
// Optionally create/update related account amounts
$initial = $request->input('initial_amount');
$balance = $request->input('balance_amount');
if (!is_null($initial) || !is_null($balance)) {
$contract->account()->create([
'initial_amount' => $initial ?? 0,
'balance_amount' => $balance ?? 0,
]);
}
});
return to_route('clientCase.show', $clientCase);
@ -117,9 +128,25 @@ public function updateContract(ClientCase $clientCase, String $uuid, Request $re
\DB::transaction(function() use ($request, $contract){
$contract->update([
'reference' => $request->input('reference'),
'type_id' => $request->input('type_id')
'type_id' => $request->input('type_id'),
'description' => $request->input('description'),
'start_date' => $request->filled('start_date') ? date('Y-m-d', strtotime($request->input('start_date'))) : $contract->start_date,
]);
$initial = $request->input('initial_amount');
$balance = $request->input('balance_amount');
if (!is_null($initial) || !is_null($balance)) {
$accountData = [
'initial_amount' => $initial ?? 0,
'balance_amount' => $balance ?? 0,
];
if ($contract->account) {
$contract->account->update($accountData);
} else {
$contract->account()->create($accountData);
}
}
});
return to_route('clientCase.show', $clientCase);
@ -132,11 +159,28 @@ public function storeActivity(ClientCase $clientCase, Request $request) {
'amount' => 'nullable|decimal:0,4',
'note' => 'nullable|string',
'action_id' => 'exists:\App\Models\Action,id',
'decision_id' => 'exists:\App\Models\Decision,id'
'decision_id' => 'exists:\App\Models\Decision,id',
'contract_uuid' => 'nullable|uuid',
]);
//Create activity
$row = $clientCase->activities()->create($attributes);
// Map contract_uuid to contract_id within the same client case, if provided
$contractId = null;
if (!empty($attributes['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail('id');
if ($contract) {
$contractId = $contract->id;
}
}
// Create activity
$row = $clientCase->activities()->create([
'due_date' => $attributes['due_date'] ?? null,
'amount' => $attributes['amount'] ?? null,
'note' => $attributes['note'] ?? null,
'action_id' => $attributes['action_id'],
'decision_id' => $attributes['decision_id'],
'contract_id' => $contractId,
]);
/*foreach ($activity->decision->events as $e) {
$class = '\\App\\Events\\' . $e->name;
event(new $class($clientCase));
@ -296,9 +340,9 @@ public function show(ClientCase $clientCase)
'client' => $case->client()->with('person', fn($q) => $q->with(['addresses', 'phones']))->firstOrFail(),
'client_case' => $case,
'contracts' => $case->contracts()
->with(['type'])
->with(['type', 'account', 'objects'])
->orderByDesc('created_at')->get(),
'activities' => $case->activities()->with(['action', 'decision'])
'activities' => $case->activities()->with(['action', 'decision', 'contract:id,uuid,reference'])
->orderByDesc('created_at')
->paginate(20, ['*'], 'activities'),
'documents' => $case->documents()->orderByDesc('created_at')->get(),

View File

@ -21,7 +21,11 @@ class ImportController extends Controller
public function index(Request $request)
{
$paginator = Import::query()
->with(['client:id,uuid', 'template:id,name'])
->with([
'client:id,uuid,person_id',
'client.person:id,uuid,full_name',
'template:id,name',
])
->orderByDesc('created_at')
->paginate(15);
@ -52,8 +56,15 @@ public function index(Request $request)
'original_name' => $imp->original_name,
'size' => $imp->size,
'status' => $imp->status,
'client' => $imp->client ? [ 'id' => $imp->client_id, 'uuid' => $imp->client->uuid ] : null,
'template' => $imp->template ? [ 'id' => $imp->import_template_id, 'name' => $imp->template->name ] : null,
'client' => $imp->client ? [
'id' => $imp->client_id,
'uuid' => $imp->client->uuid,
'person' => $imp->client->person ? [
'uuid' => $imp->client->person->uuid,
'full_name' => $imp->client->person->full_name,
] : null,
] : null,
'template' => $imp->template ? [ 'id' => $imp->template->id, 'name' => $imp->template->name ] : null,
];
}, $imports['data']);
@ -316,6 +327,7 @@ public function show(Import $import)
$templates = ImportTemplate::query()
->leftJoin('clients', 'clients.id', '=', 'import_templates.client_id')
->where('import_templates.is_active', true)
->where('import_templates.id', $import->import_template_id)
->orderBy('import_templates.name')
->get([
'import_templates.id',
@ -324,18 +336,30 @@ public function show(Import $import)
'import_templates.source_type',
'import_templates.default_record_type',
'import_templates.client_id',
DB::raw('clients.uuid as client_uuid'),
'clients.uuid as client_uuid',
]);
$clients = Client::query()
->join('person', 'person.id', '=', 'clients.person_id')
->orderBy('person.full_name')
->where('clients.id', $import->client_id)
->get([
'clients.id',
'clients.uuid',
DB::raw('person.full_name as name'),
'person.full_name as name'
]);
// Import client
$client = Client::query()
->join('person', 'person.id', '=', 'clients.person_id')
->where('clients.id', $import->client_id)
->firstOrFail([
'clients.uuid as uuid',
'person.full_name as name',
]);
// Render a dedicated page to continue the import
return Inertia::render('Imports/Import', [
'import' => [
@ -344,15 +368,17 @@ public function show(Import $import)
'status' => $import->status,
'meta' => $import->meta,
'client_id' => $import->client_id,
'client_uuid' => optional($client)->uuid,
'import_template_id' => $import->import_template_id,
'total_rows' => $import->total_rows,
'imported_rows' => $import->imported_rows,
'invalid_rows' => $import->invalid_rows,
'valid_rows' => $import->valid_rows,
'finished_at' => $import->finished_at,
'finished_at' => $import->finished_at
],
'templates' => $templates,
'clients' => $clients,
'client' => $client
]);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Person\Person;
use App\Models\BankAccount;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -78,6 +79,13 @@ public function updateAddress(Person $person, int $address_id, Request $request)
]);
}
public function deleteAddress(Person $person, int $address_id, Request $request)
{
$address = $person->addresses()->findOrFail($address_id);
$address->delete(); // soft delete
return response()->json(['status' => 'ok']);
}
public function createPhone(Person $person, Request $request)
{
$attributes = $request->validate([
@ -116,6 +124,13 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
]);
}
public function deletePhone(Person $person, int $phone_id, Request $request)
{
$phone = $person->phones()->findOrFail($phone_id);
$phone->delete(); // soft delete
return response()->json(['status' => 'ok']);
}
public function createEmail(Person $person, Request $request)
{
$attributes = $request->validate([
@ -160,4 +175,66 @@ public function updateEmail(Person $person, int $email_id, Request $request)
'email' => $email
]);
}
public function deleteEmail(Person $person, int $email_id, Request $request)
{
$email = $person->emails()->findOrFail($email_id);
$email->delete();
return response()->json(['status' => 'ok']);
}
// TRR (bank account) CRUD
public function createTrr(Person $person, Request $request)
{
$attributes = $request->validate([
'iban' => 'nullable|string|max:34',
'bank_name' => 'required|string|max:100',
'bic_swift' => 'nullable|string|max:11',
'account_number' => 'nullable|string|max:34',
'routing_number' => 'nullable|string|max:20',
'currency' => 'required|string|size:3',
'country_code' => 'nullable|string|size:2',
'holder_name' => 'nullable|string|max:125',
'notes' => 'nullable|string',
'meta' => 'nullable|array',
]);
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
$trr = $person->bankAccounts()->create($attributes);
return response()->json([
'trr' => BankAccount::findOrFail($trr->id)
]);
}
public function updateTrr(Person $person, int $trr_id, Request $request)
{
$attributes = $request->validate([
'iban' => 'nullable|string|max:34',
'bank_name' => 'required|string|max:100',
'bic_swift' => 'nullable|string|max:11',
'account_number' => 'nullable|string|max:34',
'routing_number' => 'nullable|string|max:20',
'currency' => 'required|string|size:3',
'country_code' => 'nullable|string|size:2',
'holder_name' => 'nullable|string|max:125',
'notes' => 'nullable|string',
'meta' => 'nullable|array',
'is_active' => 'sometimes|boolean',
]);
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->update($attributes);
return response()->json([
'trr' => $trr
]);
}
public function deleteTrr(Person $person, int $trr_id, Request $request)
{
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->delete();
return response()->json(['status' => 'ok']);
}
}

View File

@ -18,6 +18,7 @@ class Account extends Model
'contract_id',
'type_id',
'active',
'initial_amount',
'balance_amount',
];

View File

@ -19,7 +19,8 @@ class Activity extends Model
'note',
'action_id',
'user_id',
'decision_id'
'decision_id',
'contract_id'
];
protected $hidden = [

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class BankAccount extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'bank_accounts';
protected $fillable = [
'person_id',
'bank_name',
'iban',
'bic_swift',
'account_number',
'routing_number',
'currency',
'country_code',
'holder_name',
'is_active',
'notes',
'meta',
];
protected $casts = [
'is_active' => 'boolean',
'meta' => 'array',
];
public function person(): BelongsTo
{
return $this->belongsTo(\App\Models\Person\Person::class, 'person_id');
}
}

29
app/Models/CaseObject.php Normal file
View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class CaseObject extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'objects';
protected $fillable = [
'reference',
'name',
'description',
'type',
'contract_id',
];
public function contract(): BelongsTo
{
return $this->belongsTo(Contract::class, 'contract_id');
}
}

View File

@ -10,6 +10,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\SoftDeletes;
@ -53,4 +54,15 @@ public function segments(): BelongsToMany {
->wherePivot('active', true);
}
public function account(): HasOne
{
return $this->hasOne(\App\Models\Account::class)
->with('type');
}
public function objects(): HasMany
{
return $this->hasMany(\App\Models\CaseObject::class, 'contract_id');
}
}

View File

@ -4,9 +4,12 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Email extends Model
{
use SoftDeletes;
protected $fillable = [
'person_id',
'value',

View File

@ -100,6 +100,13 @@ public function emails(): HasMany
->orderBy('id');
}
public function bankAccounts(): HasMany
{
return $this->hasMany(\App\Models\BankAccount::class, 'person_id')
->where('is_active', '=', 1)
->orderBy('id');
}
public function group(): BelongsTo
{
return $this->belongsTo(\App\Models\Person\PersonGroup::class, 'group_id');

View File

@ -5,6 +5,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable;
class PersonAddress extends Model
@ -12,6 +13,7 @@ class PersonAddress extends Model
/** @use HasFactory<\Database\Factories\Person/PersonAddressFactory> */
use HasFactory;
use Searchable;
use SoftDeletes;
protected $fillable = [
'address',

View File

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable;
class PersonPhone extends Model
@ -13,6 +14,7 @@ class PersonPhone extends Model
/** @use HasFactory<\Database\Factories\Person/PersonPhoneFactory> */
use HasFactory;
use Searchable;
use SoftDeletes;
protected $fillable = [
'nu',

View File

@ -11,27 +11,21 @@
*/
public function up(): void
{
Schema::create('object_types', function (Blueprint $table) {
$table->id();
$table->string('name',50);
$table->string('description',125)->nullable();
$table->softDeletes();
$table->timestamps();
});
Schema::create('objects', function (Blueprint $table) {
$table->id();
$table->string('reference', 125)->nullable();
$table->string('name', 255);
$table->string('description', 255)->nullable();
// If you keep the column name as 'type_id', specify the table explicitly
$table->foreignId('type_id')->constrained('object_types')->nullOnDelete();
$table->string('type', 125)->nullable();
$table->foreignId('contract_id')->constrained('contracts')->cascadeOnDelete();
// Indexes for faster lookups
$table->softDeletes();
$table->timestamps();
$table->index('type');
$table->index('reference');
$table->index('type_id');
$table->index('contract_id');
});
}

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('objects');
Schema::dropIfExists('object_types');
Schema::create('objects', function (Blueprint $table) {
$table->id();
$table->string('reference', 125)->nullable();
$table->string('name', 255);
$table->string('description', 255)->nullable();
$table->string('type', 125)->nullable();
$table->foreignId('contract_id')->constrained('contracts')->cascadeOnDelete();
// Indexes for faster lookups
$table->softDeletes();
$table->timestamps();
$table->index('type');
$table->index('reference');
$table->index('contract_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('objects');
Schema::dropIfExists('object_types');
}
};

779
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,7 @@
"material-design-icons-iconfont": "^6.7.0",
"preline": "^2.7.0",
"tailwindcss-inner-border": "^0.2.0",
"v-calendar": "^3.1.2",
"vue-multiselect": "^3.1.0",
"vue-search-input": "^1.1.16",
"vue3-apexcharts": "^1.7.0",

View File

@ -9,3 +9,6 @@
[x-cloak] {
display: none;
}
/* Ensure dropdowns/menus render above dialog overlays when appended to body */
.multiselect__content-wrapper { z-index: 2147483647 !important; }

View File

@ -23,6 +23,16 @@ const props = defineProps({
options: {
type: Object,
default: {}
},
// Deprecated: fixed height. Prefer bodyMaxHeight (e.g., 'max-h-96').
bodyHeight: {
type: String,
default: 'h-96'
},
// Preferred: control scrollable body max-height (Tailwind class), e.g., 'max-h-96', 'max-h-[600px]'
bodyMaxHeight: {
type: String,
default: 'max-h-96'
}
});
@ -105,7 +115,7 @@ const remove = () => {
<p v-if="description" class="mt-1 text-sm text-gray-600">{{ description }}</p>
</div>
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
<div :class="['relative rounded-lg border border-gray-200 bg-white shadow-sm overflow-x-auto overflow-y-auto', bodyMaxHeight]">
<FwbTable hoverable striped class="text-sm">
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur supports-[backdrop-filter]:bg-gray-50/80 border-b border-gray-200 shadow-sm">
<FwbTableHeadCell v-for="(h, hIndex) in header" :key="hIndex" class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6">{{ h.data }}</FwbTableHeadCell>

View File

@ -0,0 +1,38 @@
<script setup>
import DialogModal from './DialogModal.vue';
import PrimaryButton from './PrimaryButton.vue';
const props = defineProps({
show: { type: Boolean, default: false },
title: { type: String, default: 'Potrditev' },
message: { type: String, default: 'Ali ste prepričani?' },
confirmText: { type: String, default: 'Potrdi' },
cancelText: { type: String, default: 'Prekliči' },
danger: { type: Boolean, default: false },
});
const emit = defineEmits(['close', 'confirm']);
const onClose = () => emit('close');
const onConfirm = () => emit('confirm');
</script>
<template>
<DialogModal :show="show" @close="onClose">
<template #title>
{{ title }}
</template>
<template #content>
<p class="text-sm text-gray-700">{{ message }}</p>
<div class="mt-6 flex items-center justify-end gap-3">
<button type="button" class="px-4 py-2 rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50" @click="onClose">
{{ cancelText }}
</button>
<PrimaryButton :class="danger ? 'bg-red-600 hover:bg-red-700 focus:ring-red-500' : ''" @click="onConfirm">
{{ confirmText }}
</PrimaryButton>
</div>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,103 @@
<script setup>
import InputLabel from './InputLabel.vue'
import InputError from './InputError.vue'
import { computed } from 'vue'
/*
DatePickerField (v-calendar)
- A thin wrapper around <VDatePicker> with a label and error support.
- Uses v-calendar which handles popovers/teleport well inside modals.
API: kept compatible with previous usage where possible.
Props:
- modelValue: Date | string | number | null
- id: string
- label: string
- format: string (default 'dd.MM.yyyy')
- enableTimePicker: boolean (default false)
- inline: boolean (default false) // When true, keeps the popover visible
- placeholder: string
- error: string | string[]
Note: Props like teleportTarget/autoPosition/menuClassName/fixed/closeOn... were for the old picker
and are accepted for compatibility but are not used by v-calendar.
*/
const props = defineProps({
modelValue: { type: [Date, String, Number, null], default: null },
id: { type: String, default: undefined },
label: { type: String, default: undefined },
format: { type: String, default: 'dd.MM.yyyy' },
enableTimePicker: { type: Boolean, default: false },
inline: { type: Boolean, default: false },
// legacy/unused in v-calendar (kept to prevent breaking callers)
autoApply: { type: Boolean, default: false },
teleportTarget: { type: [Boolean, String], default: 'body' },
autoPosition: { type: Boolean, default: true },
menuClassName: { type: String, default: 'dp-over-modal' },
fixed: { type: Boolean, default: true },
closeOnAutoApply: { type: Boolean, default: true },
closeOnScroll: { type: Boolean, default: true },
placeholder: { type: String, default: '' },
error: { type: [String, Array], default: undefined },
})
const emit = defineEmits(['update:modelValue', 'change'])
const valueProxy = computed({
get: () => props.modelValue,
set: (val) => {
emit('update:modelValue', val)
emit('change', val)
},
})
// Convert common date mask from lowercase tokens to v-calendar tokens
const inputMask = computed(() => {
let m = props.format || 'dd.MM.yyyy'
return m
.replace(/yyyy/g, 'YYYY')
.replace(/dd/g, 'DD')
.replace(/MM/g, 'MM')
+ (props.enableTimePicker ? ' HH:mm' : '')
})
const popoverCfg = computed(() => ({
visibility: props.inline ? 'visible' : 'click',
placement: 'bottom-start',
}))
</script>
<template>
<div class="col-span-6 sm:col-span-4">
<InputLabel v-if="label" :for="id" :value="label" />
<!-- VCalendar DatePicker with custom input to keep Tailwind styling -->
<VDatePicker
v-model="valueProxy"
:mode="enableTimePicker ? 'dateTime' : 'date'"
:masks="{ input: inputMask }"
:popover="popoverCfg"
:is24hr="true"
>
<template #default="{ inputValue, inputEvents }">
<input
:id="id"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
:placeholder="placeholder"
:value="inputValue"
v-on="inputEvents"
/>
</template>
</VDatePicker>
<template v-if="error">
<InputError v-if="Array.isArray(error)" v-for="(e, idx) in error" :key="idx" :message="e" />
<InputError v-else :message="error" />
</template>
</div>
</template>
<style>
/* Ensure the date picker menu overlays modals/dialogs */
</style>

View File

@ -1,14 +1,17 @@
<script setup>
import { FwbTable, FwbTableHead, FwbTableHeadCell, FwbTableBody, FwbTableRow, FwbTableCell, FwbBadge } from 'flowbite-vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faFilePdf, faFileWord, faFileExcel, faFileLines, faFileImage, faFile, faCircleInfo } from '@fortawesome/free-solid-svg-icons'
import { faFilePdf, faFileWord, faFileExcel, faFileLines, faFileImage, faFile, faCircleInfo, faEllipsisVertical, faDownload } from '@fortawesome/free-solid-svg-icons'
import { ref } from 'vue'
import Dropdown from '@/Components/Dropdown.vue'
const props = defineProps({
documents: { type: Array, default: () => [] },
viewUrlBuilder: { type: Function, default: null },
// Optional: build a direct download URL for a document; if not provided, a 'download' event will be emitted
downloadUrlBuilder: { type: Function, default: null },
})
const emit = defineEmits(['view'])
const emit = defineEmits(['view', 'download'])
const formatSize = (bytes) => {
if (bytes == null) return '-'
@ -78,6 +81,29 @@ const toggleDesc = (doc, i) => {
const key = rowKey(doc, i)
expandedDescKey.value = expandedDescKey.value === key ? null : key
}
const resolveDownloadUrl = (doc) => {
if (typeof props.downloadUrlBuilder === 'function') return props.downloadUrlBuilder(doc)
// If no builder provided, parent can handle via emitted event
return null
}
const handleDownload = (doc) => {
const url = resolveDownloadUrl(doc)
if (url) {
// Trigger a navigation to a direct-download endpoint; server should set Content-Disposition: attachment
const a = document.createElement('a')
a.href = url
a.target = '_self'
a.rel = 'noopener'
// In many browsers, simply setting href is enough
a.click()
} else {
emit('download', doc)
}
closeActions()
}
</script>
<template>
@ -120,7 +146,28 @@ const toggleDesc = (doc, i) => {
</button>
</FwbTableCell>
<FwbTableCell class="text-right whitespace-nowrap">
<!-- future actions: download/delete -->
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
:title="'Actions'"
>
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4 text-gray-700" />
</button>
</template>
<template #content>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="handleDownload(doc)"
>
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" />
<span>Download file</span>
</button>
<!-- future actions can be slotted here -->
</template>
</Dropdown>
</FwbTableCell>
</FwbTableRow>
<!-- Expanded description row directly below the item -->

View File

@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps({
align: {
@ -17,6 +17,9 @@ const props = defineProps({
});
let open = ref(false);
const triggerEl = ref(null);
const panelEl = ref(null);
const panelStyle = ref({ top: '0px', left: '0px' });
const closeOnEscape = (e) => {
if (open.value && e.key === 'Escape') {
@ -24,8 +27,54 @@ const closeOnEscape = (e) => {
}
};
const updatePosition = () => {
const t = triggerEl.value;
const p = panelEl.value;
if (!t || !p) return;
const rect = t.getBoundingClientRect();
// Ensure we have updated width
const pw = p.offsetWidth || 0;
const ph = p.offsetHeight || 0;
const margin = 8; // small spacing from trigger
let left = rect.left;
if (props.align === 'right') {
left = rect.right - pw;
} else if (props.align === 'left') {
left = rect.left;
}
// Clamp within viewport
const maxLeft = Math.max(0, window.innerWidth - pw - margin);
left = Math.min(Math.max(margin, left), maxLeft);
let top = rect.bottom + margin;
// If not enough space below, place above the trigger
if (top + ph > window.innerHeight) {
top = Math.max(margin, rect.top - ph - margin);
}
panelStyle.value = { top: `${top}px`, left: `${left}px` };
};
const onWindowChange = () => {
updatePosition();
};
watch(open, async (val) => {
if (val) {
await nextTick();
updatePosition();
window.addEventListener('resize', onWindowChange);
window.addEventListener('scroll', onWindowChange, true);
} else {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
}
});
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape);
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
});
const widthClass = computed(() => {
return {
@ -47,33 +96,35 @@ const alignmentClasses = computed(() => {
</script>
<template>
<div class="relative">
<div class="relative" ref="triggerEl">
<div @click="open = ! open">
<slot name="trigger" />
</div>
<!-- Full Screen Dropdown Overlay -->
<div v-show="open" class="fixed inset-0 z-40" @click="open = false" />
<teleport to="body">
<!-- Full Screen Dropdown Overlay at body level -->
<div v-show="open" class="fixed inset-0 z-[2147483646]" @click="open = false" />
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-show="open"
class="absolute z-50 mt-2 rounded-md shadow-lg"
:class="[widthClass, alignmentClasses]"
style="display: none;"
@click="open = false"
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div class="rounded-md ring-1 ring-black ring-opacity-5" :class="contentClasses">
<slot name="content" />
<div
v-show="open"
ref="panelEl"
class="fixed z-[2147483647] rounded-md shadow-lg"
:class="[widthClass]"
:style="[panelStyle]"
>
<div class="rounded-md ring-1 ring-black ring-opacity-5" :class="contentClasses" @click="open = false">
<slot name="content" />
</div>
</div>
</div>
</transition>
</transition>
</teleport>
</div>
</template>

View File

@ -0,0 +1,120 @@
<script setup>
import { ref, watch } from 'vue';
import DialogModal from './DialogModal.vue';
import InputLabel from './InputLabel.vue';
import SectionTitle from './SectionTitle.vue';
import TextInput from './TextInput.vue';
import InputError from './InputError.vue';
import PrimaryButton from './PrimaryButton.vue';
import axios from 'axios';
/*
EmailCreateForm / Email editor
- Props mirror Phone/Address forms for consistency
- Routes assumed: person.email.create, person.email.update
- Adjust route names/fields to match your backend if different
*/
const props = defineProps({
show: { type: Boolean, default: false },
person: { type: Object, required: true },
edit: { type: Boolean, default: false },
id: { type: Number, default: 0 },
});
const processing = ref(false);
const errors = ref({});
const emit = defineEmits(['close']);
const close = () => {
emit('close');
setTimeout(() => { errors.value = {}; }, 300);
};
const form = ref({
value: '',
label: ''
});
const resetForm = () => {
form.value = { value: '', label: '' };
};
const create = async () => {
processing.value = true; errors.value = {};
try {
const { data } = await axios.post(route('person.email.create', props.person), form.value);
if (!Array.isArray(props.person.emails)) props.person.emails = [];
props.person.emails.push(data.email);
processing.value = false; close(); resetForm();
} catch (e) {
errors.value = e?.response?.data?.errors || {}; processing.value = false;
}
};
const update = async () => {
processing.value = true; errors.value = {};
try {
const { data } = await axios.put(route('person.email.update', { person: props.person, email_id: props.id }), form.value);
if (!Array.isArray(props.person.emails)) props.person.emails = [];
const idx = props.person.emails.findIndex(e => e.id === data.email.id);
if (idx !== -1) props.person.emails[idx] = data.email;
processing.value = false; close(); resetForm();
} catch (e) {
errors.value = e?.response?.data?.errors || {}; processing.value = false;
}
};
watch(
() => props.id,
(id) => {
if (props.edit && id) {
const current = (props.person.emails || []).find(e => e.id === id);
if (current) {
form.value = {
value: current.value || current.email || current.address || '',
label: current.label || ''
};
return;
}
}
resetForm();
},
{ immediate: true }
);
const submit = () => (props.edit ? update() : create());
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>
<span v-if="edit">Spremeni email</span>
<span v-else>Dodaj email</span>
</template>
<template #content>
<form @submit.prevent="submit">
<SectionTitle class="border-b mb-4">
<template #title>Email</template>
</SectionTitle>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="em_value" value="E-pošta" />
<TextInput id="em_value" v-model="form.value" type="email" class="mt-1 block w-full" autocomplete="email" />
<InputError v-if="errors.value" v-for="err in errors.value" :key="err" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="em_label" value="Oznaka (neobvezno)" />
<TextInput id="em_label" v-model="form.label" type="text" class="mt-1 block w-full" autocomplete="off" />
<InputError v-if="errors.label" v-for="err in errors.label" :key="err" :message="err" />
</div>
<div class="flex justify-end mt-4">
<PrimaryButton :class="{ 'opacity-25': processing }" :disabled="processing">Shrani</PrimaryButton>
</div>
</form>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,15 @@
<script setup>
// This component reuses EmailCreateForm's logic via props.edit=true
import EmailCreateForm from './EmailCreateForm.vue';
const props = defineProps({
show: { type: Boolean, default: false },
person: { type: Object, required: true },
types: { type: Array, default: () => [] },
id: { type: Number, default: 0 },
});
</script>
<template>
<EmailCreateForm :show="show" :person="person" :types="types" :edit="true" :id="id" @close="$emit('close')" />
</template>

View File

@ -92,7 +92,7 @@ const maxWidthClass = computed(() => {
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div v-show="show" class="mb-6 bg-white rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full sm:mx-auto" :class="maxWidthClass">
<div v-show="show" class="mb-6 bg-white rounded-lg overflow-visible shadow-xl transform transition-all sm:w-full sm:mx-auto" :class="maxWidthClass">
<slot v-if="showSlot"/>
</div>
</transition>

View File

@ -1,12 +1,18 @@
<script setup>
import { FwbBadge } from 'flowbite-vue';
import { EditIcon, PlusIcon, UserEditIcon } from '@/Utilities/Icons';
import { EditIcon, PlusIcon, UserEditIcon, TrashBinIcon } from '@/Utilities/Icons';
import CusTab from './CusTab.vue';
import CusTabs from './CusTabs.vue';
import { provide, ref, watch } from 'vue';
import axios from 'axios';
import PersonUpdateForm from './PersonUpdateForm.vue';
import AddressCreateForm from './AddressCreateForm.vue';
import PhoneCreateForm from './PhoneCreateForm.vue';
import EmailCreateForm from './EmailCreateForm.vue';
import EmailUpdateForm from './EmailUpdateForm.vue';
import TrrCreateForm from './TrrCreateForm.vue';
import TrrUpdateForm from './TrrUpdateForm.vue';
import ConfirmDialog from './ConfirmDialog.vue';
const props = defineProps({
@ -32,12 +38,38 @@ const props = defineProps({
const drawerUpdatePerson = ref(false);
const drawerAddAddress = ref(false);
const drawerAddPhone = ref(false);
const drawerAddEmail = ref(false);
const drawerAddTrr = ref(false);
const editAddress = ref(false);
const editAddressId = ref(0);
const editPhone = ref(false);
const editPhoneId = ref(0);
const editEmail = ref(false);
const editEmailId = ref(0);
const editTrr = ref(false);
const editTrrId = ref(0);
// Confirm dialog state
const confirm = ref({
show: false,
title: 'Potrditev brisanja',
message: '',
type: '', // 'email' | 'trr' | 'address' | 'phone'
id: 0,
});
const openConfirm = (type, id, label = '') => {
confirm.value = {
show: true,
title: 'Potrditev brisanja',
message: label ? `Ali res želite izbrisati “${label}”?` : 'Ali res želite izbrisati izbran element?',
type,
id,
};
}
const closeConfirm = () => { confirm.value.show = false; };
const getMainAddress = (adresses) => {
const addr = adresses.filter( a => a.type.id === 1 )[0] ?? '';
@ -77,6 +109,70 @@ const operDrawerAddPhone = (edit = false, id = 0) => {
editPhoneId.value = id;
}
const openDrawerAddEmail = (edit = false, id = 0) => {
drawerAddEmail.value = true;
editEmail.value = edit;
editEmailId.value = id;
}
const openDrawerAddTrr = (edit = false, id = 0) => {
drawerAddTrr.value = true;
editTrr.value = edit;
editTrrId.value = id;
}
// Delete handlers (expects routes: person.email.delete, person.trr.delete)
const deleteEmail = async (emailId, label = '') => {
if (!emailId) return;
openConfirm('email', emailId, label || 'email');
}
const deleteTrr = async (trrId, label = '') => {
if (!trrId) return;
openConfirm('trr', trrId, label || 'TRR');
}
const onConfirmDelete = async () => {
const { type, id } = confirm.value;
try {
if (type === 'email') {
await axios.delete(route('person.email.delete', { person: props.person, email_id: id }));
const list = props.person.emails || [];
const idx = list.findIndex(e => e.id === id);
if (idx !== -1) list.splice(idx, 1);
} else if (type === 'trr') {
await axios.delete(route('person.trr.delete', { person: props.person, trr_id: id }));
let list = props.person.trrs || props.person.bank_accounts || props.person.accounts || props.person.bankAccounts || [];
const idx = list.findIndex(a => a.id === id);
if (idx !== -1) list.splice(idx, 1);
} else if (type === 'address') {
await axios.delete(route('person.address.delete', { person: props.person, address_id: id }));
const list = props.person.addresses || [];
const idx = list.findIndex(a => a.id === id);
if (idx !== -1) list.splice(idx, 1);
} else if (type === 'phone') {
await axios.delete(route('person.phone.delete', { person: props.person, phone_id: id }));
const list = props.person.phones || [];
const idx = list.findIndex(p => p.id === id);
if (idx !== -1) list.splice(idx, 1);
}
closeConfirm();
} catch (e) {
console.error('Delete failed', e?.response || e);
closeConfirm();
}
}
// Safe accessors for optional collections
const getEmails = (p) => Array.isArray(p?.emails) ? p.emails : []
const getTRRs = (p) => {
if (Array.isArray(p?.trrs)) return p.trrs
if (Array.isArray(p?.bank_accounts)) return p.bank_accounts
if (Array.isArray(p?.accounts)) return p.accounts
if (Array.isArray(p?.bankAccounts)) return p.bankAccounts
return []
}
</script>
<template>
@ -133,7 +229,10 @@ const operDrawerAddPhone = (edit = false, id = 0) => {
<FwbBadge type="yellow">{{ address.country }}</FwbBadge>
<FwbBadge>{{ address.type.name }}</FwbBadge>
</div>
<button><EditIcon @click="openDrawerAddAddress(true, address.id)" size="md" css="text-gray-500 hover:text-gray-800" /></button>
<div class="flex items-center gap-2">
<button><EditIcon @click="openDrawerAddAddress(true, address.id)" size="md" css="text-gray-500 hover:text-gray-800" /></button>
<button @click="openConfirm('address', address.id, address.address)"><TrashBinIcon size="md" css="text-red-600 hover:text-red-700" /></button>
</div>
</div>
<p class="text-sm md:text-base leading-7 text-gray-900">{{ address.address }}</p>
</div>
@ -152,12 +251,72 @@ const operDrawerAddPhone = (edit = false, id = 0) => {
<FwbBadge title type="yellow">+{{ phone.country_code }}</FwbBadge>
<FwbBadge>{{ phone.type.name }}</FwbBadge>
</div>
<button><EditIcon @click="operDrawerAddPhone(true, phone.id)" size="md" css="text-gray-500 hover:text-gray-800" /></button>
<div class="flex items-center gap-2">
<button><EditIcon @click="operDrawerAddPhone(true, phone.id)" size="md" css="text-gray-500 hover:text-gray-800" /></button>
<button @click="openConfirm('phone', phone.id, phone.nu)"><TrashBinIcon size="md" css="text-red-600 hover:text-red-700" /></button>
</div>
</div>
<p class="text-sm md:text-base leading-7 text-gray-900">{{ phone.nu }}</p>
</div>
</div>
</CusTab>
<CusTab name="emails" title="Email">
<div class="flex justify-end mb-2">
<span class="border-b-2 border-gray-500 hover:border-gray-800">
<button><PlusIcon @click="openDrawerAddEmail(false, 0)" size="lg" css="text-gray-500 hover:text-gray-800" /></button>
</span>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
<template v-if="getEmails(person).length">
<div class="rounded p-2 shadow" v-for="(email, idx) in getEmails(person)" :key="idx">
<div class="text-sm leading-5 md:text-sm text-gray-500 flex justify-between">
<div class="flex gap-2">
<FwbBadge v-if="email?.label">{{ email.label }}</FwbBadge>
<FwbBadge v-else type="indigo">Email</FwbBadge>
</div>
<div class="flex items-center gap-2">
<button><EditIcon @click="openDrawerAddEmail(true, email.id)" size="md" css="text-gray-500 hover:text-gray-800" /></button>
<button @click="deleteEmail(email.id, email?.value || email?.email || email?.address)"><TrashBinIcon size="md" css="text-red-600 hover:text-red-700" /></button>
</div>
</div>
<p class="text-sm md:text-base leading-7 text-gray-900">
{{ email?.value || email?.email || email?.address || '-' }}
</p>
<p v-if="email?.note" class="mt-1 text-xs text-gray-500 whitespace-pre-wrap">{{ email.note }}</p>
</div>
</template>
<p v-else class="p-2 text-sm text-gray-500">Ni e-poštnih naslovov.</p>
</div>
</CusTab>
<CusTab name="trr" title="TRR">
<div class="flex justify-end mb-2">
<span class="border-b-2 border-gray-500 hover:border-gray-800">
<button><PlusIcon @click="openDrawerAddTrr(false, 0)" size="lg" css="text-gray-500 hover:text-gray-800" /></button>
</span>
</div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
<template v-if="getTRRs(person).length">
<div class="rounded p-2 shadow" v-for="(acc, idx) in getTRRs(person)" :key="idx">
<div class="text-sm leading-5 md:text-sm text-gray-500 flex justify-between">
<div class="flex gap-2">
<FwbBadge v-if="acc?.bank_name">{{ acc.bank_name }}</FwbBadge>
<FwbBadge v-if="acc?.holder_name" type="indigo">{{ acc.holder_name }}</FwbBadge>
<FwbBadge v-if="acc?.currency" type="yellow">{{ acc.currency }}</FwbBadge>
</div>
<div class="flex items-center gap-2">
<button><EditIcon @click="openDrawerAddTrr(true, acc.id)" size="md" css="text-gray-500 hover:text-gray-800" /></button>
<button @click="deleteTrr(acc.id, acc?.iban || acc?.account_number)"><TrashBinIcon size="md" css="text-red-600 hover:text-red-700" /></button>
</div>
</div>
<p class="text-sm md:text-base leading-7 text-gray-900">
{{ acc?.iban || acc?.account_number || acc?.account || acc?.nu || acc?.number || '-' }}
</p>
<p v-if="acc?.notes" class="mt-1 text-xs text-gray-500 whitespace-pre-wrap">{{ acc.notes }}</p>
</div>
</template>
<p v-else class="p-2 text-sm text-gray-500">Ni TRR računov.</p>
</div>
</CusTab>
<CusTab name="other" title="Drugo">
ssss4
</CusTab>
@ -185,5 +344,50 @@ const operDrawerAddPhone = (edit = false, id = 0) => {
:id="editPhoneId"
:edit="editPhone"
/>
<!-- Email dialogs -->
<EmailCreateForm
:show="drawerAddEmail && !editEmail"
@close="drawerAddEmail = false"
:person="person"
:types="types.email_types ?? []"
/>
<EmailUpdateForm
:show="drawerAddEmail && editEmail"
@close="drawerAddEmail = false"
:person="person"
:types="types.email_types ?? []"
:id="editEmailId"
/>
<!-- TRR dialogs -->
<TrrCreateForm
:show="drawerAddTrr && !editTrr"
@close="drawerAddTrr = false"
:person="person"
:types="types.trr_types ?? []"
:banks="types.banks ?? []"
:currencies="types.currencies ?? ['EUR']"
/>
<TrrUpdateForm
:show="drawerAddTrr && editTrr"
@close="drawerAddTrr = false"
:person="person"
:types="types.trr_types ?? []"
:banks="types.banks ?? []"
:currencies="types.currencies ?? ['EUR']"
:id="editTrrId"
/>
<!-- Confirm deletion dialog -->
<ConfirmDialog
:show="confirm.show"
:title="confirm.title"
:message="confirm.message"
confirm-text="Izbriši"
cancel-text="Prekliči"
:danger="true"
@close="closeConfirm"
@confirm="onConfirmDelete"
/>
</template>

View File

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

View File

@ -0,0 +1,17 @@
<script setup>
// Thin wrapper to reuse TrrCreateForm with edit=true
import TrrCreateForm from './TrrCreateForm.vue';
const props = defineProps({
show: { type: Boolean, default: false },
person: { type: Object, required: true },
types: { type: Array, default: () => [] },
banks: { type: Array, default: () => [] },
currencies: { type: Array, default: () => ['EUR'] },
id: { type: Number, default: 0 },
});
</script>
<template>
<TrrCreateForm :show="show" :person="person" :types="types" :banks="banks" :currencies="currencies" :edit="true" :id="id" @close="$emit('close')" />
</template>

View File

@ -122,8 +122,10 @@ watch(
<aside :class="[
sidebarCollapsed ? 'w-16' : 'w-64',
'bg-white border-r border-gray-200 transition-all duration-200 z-50',
// Off-canvas behavior on mobile
isMobile ? 'fixed inset-y-0 left-0 transform ' + (mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full') : 'relative translate-x-0'
// Off-canvas behavior on mobile; sticky fixed-like sidebar on desktop
isMobile
? ('fixed inset-y-0 left-0 transform ' + (mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'))
: 'sticky top-0 h-screen overflow-y-auto'
]">
<div class="h-16 px-4 flex items-center justify-between border-b">
<Link :href="route('dashboard')" class="flex items-center gap-2">
@ -197,7 +199,7 @@ watch(
<!-- Main column -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Top bar -->
<div class="h-16 bg-white border-b border-gray-100 px-4 flex items-center justify-between">
<div class="h-16 bg-white border-b border-gray-100 px-4 flex items-center justify-between sticky top-0 z-30">
<div class="flex items-center gap-2">
<!-- Sidebar toggle -->
<button

View File

@ -3,6 +3,7 @@ import ActionMessage from '@/Components/ActionMessage.vue';
import BasicButton from '@/Components/buttons/BasicButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import DatePickerField from '@/Components/DatePickerField.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
import TextInput from '@/Components/TextInput.vue';
@ -18,7 +19,9 @@ const props = defineProps({
default: false
},
client_case: Object,
actions: Array
actions: Array,
// optionally pre-select a contract to attach the activity to
contractUuid: { type: String, default: null },
});
const decisions = ref(props.actions[0].decisions);
@ -36,7 +39,8 @@ const form = useForm({
amount: null,
note: '',
action_id: props.actions[0].id,
decision_id: props.actions[0].decisions[0].id
decision_id: props.actions[0].decisions[0].id,
contract_uuid: props.contractUuid,
});
watch(
@ -52,14 +56,20 @@ watch(
(due_date) => {
if (due_date) {
let date = new Date(form.due_date).toISOString().split('T')[0];
console.table({old: due_date, new: date});
console.table({ old: due_date, new: date });
}
}
);
// keep contract_uuid synced if the prop changes while the drawer is open
watch(
() => props.contractUuid,
(cu) => { form.contract_uuid = cu || null; }
);
const store = () => {
console.table({
console.table({
due_date: form.due_date,
action_id: form.action_id,
decision_id: form.decision_id,
@ -68,10 +78,10 @@ const store = () => {
});
form.post(route('clientCase.activity.store', props.client_case), {
onBefore: () => {
if(form.due_date) {
if (form.due_date) {
form.due_date = new Date(form.due_date).toISOString().split('T')[0];
}
},
},
onSuccess: () => {
close();
form.reset();
@ -87,75 +97,46 @@ const store = () => {
</script>
<template>
<DialogModal
:show="show"
@close="close"
>
<DialogModal :show="show" @close="close">
<template #title>Dodaj aktivnost</template>
<template #content>
<form @submit.prevent="store">
<div class="col-span-6 sm:col-span-4">
<InputLabel for="activityAction" value="Akcija"/>
<select
<InputLabel for="activityAction" value="Akcija" />
<select
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
id="activityAction"
ref="activityActionSelect"
v-model="form.action_id"
>
id="activityAction" ref="activityActionSelect" v-model="form.action_id">
<option v-for="a in actions" :value="a.id">{{ a.name }}</option>
<!-- ... -->
<!-- ... -->
</select>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="activityDecision" value="Odločitev"/>
<select
<InputLabel for="activityDecision" value="Odločitev" />
<select
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
id="activityDecision"
ref="activityDecisionSelect"
v-model="form.decision_id"
>
id="activityDecision" ref="activityDecisionSelect" v-model="form.decision_id">
<option v-for="d in decisions" :value="d.id">{{ d.name }}</option>
<!-- ... -->
<!-- ... -->
</select>
</div>
<div class="col-span-6 sm:col-span-4">
<FwbTextarea
label="Opomba"
id="activityNote"
ref="activityNoteTextarea"
v-model="form.note"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
/>
<FwbTextarea label="Opomba" id="activityNote" ref="activityNoteTextarea" v-model="form.note"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" />
</div>
<DatePickerField id="activityDueDate" label="Datum zapadlosti" v-model="form.due_date"
format="dd.MM.yyyy" :enable-time-picker="false" :auto-position="true" :teleport-target="'body'"
:inline="false" :auto-apply="false" :fixed="false" :close-on-auto-apply="true"
:close-on-scroll="true" />
<div class="col-span-6 sm:col-span-4">
<InputLabel for="activityDueDate" value="Datum zapadlosti"/>
<vue-date-picker
id="activityDueDate"
:enable-time-picker="false"
format="dd.MM.yyyy"
class="mt-1 block w-full"
v-model="form.due_date"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="activityAmount" value="Znesek"/>
<TextInput
id="activityAmount"
ref="activityAmountinput"
v-model="form.amount"
type="number"
class="mt-1 block w-full"
autocomplete="0.00"
/>
<InputLabel for="activityAmount" value="Znesek" />
<TextInput id="activityAmount" ref="activityAmountinput" v-model="form.amount" type="number"
class="mt-1 block w-full" autocomplete="0.00" />
</div>
<div class="flex justify-end mt-4">
<ActionMessage :on="form.recentlySuccessful" class="me-3">
Shranjuje.
</ActionMessage>
<BasicButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
<BasicButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
Shrani
</BasicButton>
</div>

View File

@ -10,6 +10,7 @@ const props = defineProps({
let header = [
C_TD.make('Pogodba', 'header'),
C_TD.make('Datum', 'header'),
C_TD.make('Akcija', 'header'),
C_TD.make('Odločitev', 'header'),
@ -26,6 +27,7 @@ const createBody = (data) => {
const dueDate = (p.due_date) ? new Date().toLocaleDateString('de') : null;
const cols = [
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'),

View File

@ -0,0 +1,65 @@
<script setup>
import DialogModal from '@/Components/DialogModal.vue'
import InputLabel from '@/Components/InputLabel.vue'
import TextInput from '@/Components/TextInput.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import SectionTitle from '@/Components/SectionTitle.vue'
import { useForm } from '@inertiajs/vue3'
const props = defineProps({
show: { type: Boolean, default: false },
client_case: { type: Object, required: true },
contract: { type: Object, required: true },
})
const emit = defineEmits(['close', 'created'])
const close = () => emit('close')
const form = useForm({
reference: '',
name: '',
description: '',
type: '',
})
const submit = () => {
form.post(route('clientCase.contract.object.store', { client_case: props.client_case.uuid, uuid: props.contract.uuid }), {
preserveScroll: true,
onSuccess: () => { emit('created'); form.reset(); close() },
})
}
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>Dodaj premet</template>
<template #content>
<form @submit.prevent="submit">
<SectionTitle class="mt-2 border-b mb-4">
<template #title>Premet</template>
</SectionTitle>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<InputLabel for="objRef" value="Referenca" />
<TextInput id="objRef" v-model="form.reference" type="text" class="mt-1 block w-full" />
</div>
<div>
<InputLabel for="objType" value="Tip" />
<TextInput id="objType" v-model="form.type" type="text" class="mt-1 block w-full" />
</div>
</div>
<div class="mt-4">
<InputLabel for="objName" value="Naziv" />
<TextInput id="objName" v-model="form.name" type="text" class="mt-1 block w-full" required />
</div>
<div class="mt-4">
<InputLabel for="objDesc" value="Opis" />
<textarea id="objDesc" v-model="form.description" class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" rows="3" />
</div>
<div class="flex justify-end mt-6">
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">Shrani</PrimaryButton>
</div>
</form>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,52 @@
<script setup>
import DialogModal from '@/Components/DialogModal.vue'
const props = defineProps({
show: { type: Boolean, default: false },
client_case: { type: Object, required: true },
contract: { type: Object, default: null },
})
const emit = defineEmits(['close'])
const close = () => emit('close')
const items = () => Array.isArray(props.contract?.objects) ? props.contract.objects : []
</script>
<template>
<DialogModal :show="show" @close="close">
<template #title>
Premeti
<span v-if="contract" class="ml-2 text-sm text-gray-500">(Pogodba: {{ contract.reference }})</span>
</template>
<template #content>
<div class="mt-1 max-h-[60vh] overflow-y-auto">
<div v-if="items().length > 0" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="o in items()" :key="o.id" class="rounded-lg border border-gray-200 bg-white shadow-sm">
<div class="p-4">
<div class="flex items-start justify-between">
<div>
<div class="text-xs uppercase text-gray-500">Ref.</div>
<div class="font-semibold text-gray-900">{{ o.reference || '-' }}</div>
</div>
<span class="ml-3 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700">{{ o.type || '—' }}</span>
</div>
<div class="mt-3">
<div class="text-xs uppercase text-gray-500">Naziv</div>
<div class="text-gray-900">{{ o.name || '-' }}</div>
</div>
<div class="mt-3">
<div class="text-xs uppercase text-gray-500">Opis</div>
<div class="text-gray-700 whitespace-pre-wrap">{{ o.description || '' }}</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center text-gray-500 py-3">Ni predmetov.</div>
</div>
<div class="mt-4 flex justify-end">
<button type="button" class="px-4 py-2 rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50" @click="close">Zapri</button>
</div>
</template>
</DialogModal>
</template>

View File

@ -5,15 +5,16 @@ import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
import TextInput from '@/Components/TextInput.vue';
import DatePickerField from '@/Components/DatePickerField.vue';
import { useForm } from '@inertiajs/vue3';
import { watch } from 'vue';
const props = defineProps({
client_case: Object,
show: {
type: Boolean,
default: false
},
types: Array
show: { type: Boolean, default: false },
types: Array,
// Optional: when provided, drawer acts as edit mode
contract: { type: Object, default: null },
});
console.log(props.types);
@ -24,25 +25,59 @@ const close = () => {
emit('close');
}
//store contract
// form state for create or edit
const formContract = useForm({
client_case_uuid: props.client_case.uuid,
reference: '',
start_date: new Date().toISOString(),
type_id: props.types[0].id
uuid: props.contract?.uuid ?? null,
reference: props.contract?.reference ?? '',
start_date: props.contract?.start_date ?? new Date().toISOString(),
type_id: (props.contract?.type_id ?? props.contract?.type?.id) ?? props.types[0].id,
description: props.contract?.description ?? '',
// nested account fields, if exists
initial_amount: props.contract?.account?.initial_amount ?? null,
balance_amount: props.contract?.account?.balance_amount ?? null,
});
const storeContract = () => {
formContract.post(route('clientCase.contract.store', props.client_case), {
// keep form in sync when switching between create and edit
const applyContract = (c) => {
formContract.uuid = c?.uuid ?? null
formContract.reference = c?.reference ?? ''
formContract.start_date = c?.start_date ?? new Date().toISOString()
formContract.type_id = (c?.type_id ?? c?.type?.id) ?? props.types[0].id
formContract.description = c?.description ?? ''
formContract.initial_amount = c?.account?.initial_amount ?? null
formContract.balance_amount = c?.account?.balance_amount ?? null
}
watch(() => props.contract, (c) => {
applyContract(c)
})
watch(() => props.show, (open) => {
if (open && !props.contract) {
// reset for create
applyContract(null)
}
})
const storeOrUpdate = () => {
const isEdit = !!formContract.uuid
const options = {
onBefore: () => {
formContract.start_date = formContract.start_date;
formContract.start_date = formContract.start_date
},
onSuccess: () => {
close();
formContract.reset();
close()
// keep state clean; reset to initial
if (!isEdit) formContract.reset()
},
preserveScroll: true
});
preserveScroll: true,
}
if (isEdit) {
formContract.put(route('clientCase.contract.update', { client_case: props.client_case.uuid, uuid: formContract.uuid }), options)
} else {
formContract.post(route('clientCase.contract.store', props.client_case), options)
}
}
</script>
@ -52,9 +87,9 @@ const storeContract = () => {
:show="show"
@close="close"
>
<template #title>Dodaj pogodbo</template>
<template #title>{{ formContract.uuid ? 'Uredi pogodbo' : 'Dodaj pogodbo' }}</template>
<template #content>
<form @submit.prevent="storeContract">
<form @submit.prevent="storeOrUpdate">
<SectionTitle class="mt-4 border-b mb-4">
<template #title>
Pogodba
@ -71,10 +106,13 @@ const storeContract = () => {
autocomplete="contract-reference"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="contractStartDate" value="Datum pričetka"/>
<vue-date-picker id="contractStartDate" :enable-time-picker="false" format="dd.MM.yyyy" class="mt-1 block w-full" v-model="formContract.start_date"></vue-date-picker>
</div>
<DatePickerField
id="contractStartDate"
label="Datum pričetka"
v-model="formContract.start_date"
format="dd.MM.yyyy"
:enable-time-picker="false"
/>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="contractTypeSelect" value="Tip"/>
<select
@ -86,13 +124,51 @@ const storeContract = () => {
<!-- ... -->
</select>
</div>
<div class="col-span-6 sm:col-span-4 mt-4">
<InputLabel for="contractDescription" value="Opis"/>
<textarea
id="contractDescription"
v-model="formContract.description"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
rows="3"
/>
</div>
<SectionTitle class="mt-6 border-b mb-4">
<template #title>
Račun
</template>
</SectionTitle>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<InputLabel for="initialAmount" value="Predani znesek"/>
<TextInput
id="initialAmount"
v-model.number="formContract.initial_amount"
type="number"
step="0.01"
class="mt-1 block w-full"
autocomplete="off"
/>
</div>
<div>
<InputLabel for="balanceAmount" value="Odprti znesek"/>
<TextInput
id="balanceAmount"
v-model.number="formContract.balance_amount"
type="number"
step="0.01"
class="mt-1 block w-full"
autocomplete="off"
/>
</div>
</div>
<div class="flex justify-end mt-4">
<ActionMessage :on="formContract.recentlySuccessful" class="me-3">
Shranjuje.
</ActionMessage>
<PrimaryButton :class="{ 'opacity-25': formContract.processing }" :disabled="formContract.processing">
Shrani
{{ formContract.uuid ? 'Posodobi' : 'Shrani' }}
</PrimaryButton>
</div>
</form>

View File

@ -1,87 +1,164 @@
<script setup>
import BasicTable from '@/Components/BasicTable.vue';
import { LinkOptions as C_LINK, TableColumn as C_TD, TableRow as C_TR} from '@/Shared/AppObjects';
import { FwbTable, FwbTableHead, FwbTableHeadCell, FwbTableBody, FwbTableRow, FwbTableCell } from 'flowbite-vue'
import Dropdown from '@/Components/Dropdown.vue'
import CaseObjectCreateDialog from './CaseObjectCreateDialog.vue'
import CaseObjectsDialog from './CaseObjectsDialog.vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faCircleInfo, faEllipsisVertical, faPenToSquare, faTrash, faListCheck, faPlus } from '@fortawesome/free-solid-svg-icons'
const props = defineProps({
client_case: Object,
contract_types: Array,
contracts: Array
});
contracts: { type: Array, default: () => [] },
})
//Contract table
let tableContractHeader = [
C_TD.make('Ref.', 'header'),
C_TD.make('Datum začetka', 'header'),
C_TD.make('Tip', 'header')
];
const emit = defineEmits(['edit', 'delete', 'add-activity'])
const tableOptions = {
editor_data: {
form: {
route: {name: 'clientCase.contract.update', params: {uuid: props.client_case.uuid}},
route_remove: {name: 'clientCase.contract.delete', params: {uuid: props.client_case.uuid}},
values: {
uuid: null,
reference: '',
type_id: 1
},
key: 'uuid',
index: {uuid: null},
el: [
{
id: 'contractRefU',
ref: 'contractRefUInput',
bind: 'reference',
type: 'text',
label: 'Referenca',
autocomplete: 'contract-reference'
},
{
id: 'contractTypeU',
ref: 'contractTypeSelectU',
bind: 'type_id',
type: 'select',
label: 'Tip',
selectOptions: props.contract_types.map(item => new Object({val: item.id, desc: item.name}))
}
]
},
title: 'contract'
}
const formatDate = (d) => {
if (!d) return '-'
const dt = new Date(d)
return isNaN(dt.getTime()) ? '-' : dt.toLocaleDateString('de')
}
const createContractTableBody = (data) => {
let tableContractBody = [];
data.forEach((p) => {
let startDate = new Date(p.start_date).toLocaleDateString('de');
const cols = [
C_TD.make(p.reference, 'body' ),
C_TD.make(startDate, 'body' ),
C_TD.make(p.type.name, 'body' ),
];
tableContractBody.push(
C_TR.make(
cols,
{
class: '',
title: p.reference,
edit: true,
ref: {key: 'uuid', val: p.uuid},
editable: {
reference: p.reference,
type_id: p.type.id
}
}
)
)
});
return tableContractBody;
const hasDesc = (c) => {
const d = c?.description
return typeof d === 'string' && d.trim().length > 0
}
const onEdit = (c) => emit('edit', c)
const onDelete = (c) => emit('delete', c)
const onAddActivity = (c) => emit('add-activity', c)
// CaseObject dialog state
import { ref } from 'vue'
const showObjectDialog = ref(false)
const showObjectsList = ref(false)
const selectedContract = ref(null)
const openObjectDialog = (c) => { selectedContract.value = c; showObjectDialog.value = true }
const closeObjectDialog = () => { showObjectDialog.value = false; selectedContract.value = null }
const openObjectsList = (c) => { selectedContract.value = c; showObjectsList.value = true }
const closeObjectsList = () => { showObjectsList.value = false; selectedContract.value = null }
</script>
<template>
<BasicTable :options="tableOptions" :header="tableContractHeader" :editor="true" :body="createContractTableBody(contracts)" />
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
<FwbTable hoverable striped class="text-sm">
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm">
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Ref.</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Datum začetka</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Tip</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-right">Predano</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-right">Odprto</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-center">Opis</FwbTableHeadCell>
<FwbTableHeadCell class="w-px" />
</FwbTableHead>
<FwbTableBody>
<template v-for="(c, i) in contracts" :key="c.uuid || i">
<FwbTableRow>
<FwbTableCell>{{ c.reference }}</FwbTableCell>
<FwbTableCell>{{ formatDate(c.start_date) }}</FwbTableCell>
<FwbTableCell>{{ c?.type?.name }}</FwbTableCell>
<FwbTableCell class="text-right">{{ Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(c?.account?.initial_amount ?? 0) }}</FwbTableCell>
<FwbTableCell class="text-right">{{ Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(c?.account?.balance_amount ?? 0) }}</FwbTableCell>
<FwbTableCell class="text-center">
<Dropdown v-if="hasDesc(c)" width="64" align="left">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
:title="'Pokaži opis'"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-700" />
</button>
</template>
<template #content>
<div class="max-w-sm px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap">
{{ c.description }}
</div>
</template>
</Dropdown>
<button
v-else
type="button"
disabled
class="inline-flex items-center justify-center h-8 w-8 rounded-full text-gray-400 cursor-not-allowed"
:title="'Ni opisa'"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4" />
</button>
</FwbTableCell>
<FwbTableCell class="text-right whitespace-nowrap">
<Dropdown align="right" width="56">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
:title="'Actions'"
>
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4 text-gray-700" />
</button>
</template>
<template #content>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="onEdit(c)"
>
<FontAwesomeIcon :icon="faPenToSquare" class="h-4 w-4 text-gray-600" />
<span>Edit</span>
</button>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="openObjectsList(c)"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
<span>Predmeti</span>
</button>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="openObjectDialog(c)"
>
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" />
<span>Premet</span>
</button>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-red-700 hover:bg-red-50 flex items-center gap-2"
@click="onDelete(c)"
>
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4 text-red-600" />
<span>Briši</span>
</button>
<div class="my-1 border-t border-gray-100" />
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="onAddActivity(c)"
>
<FontAwesomeIcon :icon="faListCheck" class="h-4 w-4 text-gray-600" />
<span>Aktivnost</span>
</button>
</template>
</Dropdown>
</FwbTableCell>
</FwbTableRow>
</template>
</FwbTableBody>
</FwbTable>
<div v-if="!contracts || contracts.length === 0" class="p-6 text-center text-sm text-gray-500">No contracts.</div>
</div>
<CaseObjectCreateDialog
:show="showObjectDialog"
@close="closeObjectDialog"
:client_case="client_case"
:contract="selectedContract"
/>
<CaseObjectsDialog
:show="showObjectsList"
@close="closeObjectsList"
:client_case="client_case"
:contract="selectedContract"
/>
</template>

View File

@ -15,6 +15,7 @@ import { classifyDocument } from "@/Services/documents";
import { router } from '@inertiajs/vue3';
import { AngleDownIcon, AngleUpIcon } from "@/Utilities/Icons";
import Pagination from "@/Components/Pagination.vue";
import ConfirmDialog from "@/Components/ConfirmDialog.vue";
const props = defineProps({
client: Object,
@ -49,22 +50,42 @@ const openViewer = (doc) => {
};
const closeViewer = () => { viewer.value.open = false; viewer.value.src = ''; };
const clientDetails = ref(true);
const clientDetails = ref(false);
//Drawer add new contract
// Contract drawer (create/edit)
const drawerCreateContract = ref(false);
const contractEditing = ref(null);
const openDrawerCreateContract = () => {
contractEditing.value = null;
drawerCreateContract.value = true;
};
const openDrawerEditContract = (c) => {
contractEditing.value = c;
drawerCreateContract.value = true;
};
//Drawer add new activity
const drawerAddActivity = ref(false);
const activityContractUuid = ref(null);
const openDrawerAddActivity = () => {
const openDrawerAddActivity = (c = null) => {
activityContractUuid.value = c?.uuid ?? null;
drawerAddActivity.value = true;
};
// delete confirmation
const confirmDelete = ref({ show: false, contract: null })
const requestDeleteContract = (c) => { confirmDelete.value = { show: true, contract: c } }
const closeConfirmDelete = () => { confirmDelete.value.show = false; confirmDelete.value.contract = null }
const doDeleteContract = () => {
const c = confirmDelete.value.contract
if (!c) return closeConfirmDelete()
router.delete(route('clientCase.contract.delete', { client_case: props.client_case.uuid, uuid: c.uuid }), {
preserveScroll: true,
onFinish: () => closeConfirmDelete(),
})
}
//Close drawer (all)
const closeDrawer = () => {
drawerCreateContract.value = false;
@ -154,34 +175,15 @@ const hideClietnDetails = () => {
:client_case="client_case"
:contracts="contracts"
:contract_types="contract_types"
@edit="openDrawerEditContract"
@delete="requestDeleteContract"
@add-activity="openDrawerAddActivity"
/>
</div>
</div>
</div>
</div>
<!-- Documents section -->
<div class="pt-12">
<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">
<div class="mx-auto max-w-4x1">
<div class="flex justify-between p-4">
<SectionTitle>
<template #title>Dokumenti</template>
</SectionTitle>
<FwbButton @click="openUpload">Dodaj</FwbButton>
</div>
<DocumentsTable :documents="documents" @view="openViewer" />
</div>
</div>
</div>
</div>
<DocumentUploadDialog
:show="showUpload"
@close="closeUpload"
@uploaded="onUploaded"
:post-url="route('clientCase.document.store', client_case)"
/>
<DocumentViewerDialog :show="viewer.open" :src="viewer.src" :title="viewer.title" @close="closeViewer" />
<div class="pt-12 pb-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4">
@ -199,17 +201,55 @@ const hideClietnDetails = () => {
</div>
</div>
</div>
<!-- Documents section -->
<div class="pt-12">
<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">
<div class="mx-auto max-w-4x1">
<div class="flex justify-between p-4">
<SectionTitle>
<template #title>Dokumenti</template>
</SectionTitle>
<FwbButton @click="openUpload">Dodaj</FwbButton>
</div>
<DocumentsTable
:documents="documents"
@view="openViewer"
:download-url-builder="doc => route('clientCase.document.download', { client_case: client_case.uuid, document: doc.uuid })"
/>
</div>
</div>
</div>
</div>
<DocumentUploadDialog
:show="showUpload"
@close="closeUpload"
@uploaded="onUploaded"
:post-url="route('clientCase.document.store', client_case)"
/>
<DocumentViewerDialog :show="viewer.open" :src="viewer.src" :title="viewer.title" @close="closeViewer" />
</AppLayout>
<ContractDrawer
:show="drawerCreateContract"
@close="closeDrawer"
:types="contract_types"
:client_case="client_case"
:contract="contractEditing"
/>
<ActivityDrawer
:show="drawerAddActivity"
@close="closeDrawer"
:client_case="client_case"
:actions="actions"
:contract-uuid="activityContractUuid"
/>
<ConfirmDialog
:show="confirmDelete.show"
title="Izbriši pogodbo"
message="Ali ste prepričani, da želite izbrisati pogodbo?"
confirm-text="Izbriši"
:danger="true"
@close="closeConfirmDelete"
@confirm="doDeleteContract"
/>
</template>

View File

@ -133,7 +133,7 @@ async function uploadAndPreview() {
processResult.value = null;
const fd = new window.FormData();
fd.append('file', form.file);
if (Number.isFinite(form.import_template_id)) {
if (form.import_template_id !== null && form.import_template_id !== undefined && String(form.import_template_id).trim() !== '') {
fd.append('import_template_id', String(form.import_template_id));
}
if (form.client_uuid) {

View File

@ -8,6 +8,7 @@ const props = defineProps({
import: Object,
templates: Array,
clients: Array,
client: Object
});
const importId = ref(props.import?.id || null);
@ -227,13 +228,13 @@ const fieldOptionsByEntity = {
// Local state for selects
const form = ref({
client_uuid: null,
client_uuid: props.client.uuid,
import_template_id: props.import?.import_template_id || null,
});
// Initialize client_uuid from numeric client_id using provided clients list
if (props.import?.client_id) {
const found = (props.clients || []).find(c => c.id === props.import.client_id);
// Initialize client_uuid from import.client_uuid (preferred) using provided clients list
if (props.import?.client_uuid) {
const found = (props.clients || []).find(c => c.uuid === props.import.client_uuid);
form.value.client_uuid = found ? found.uuid : null;
}

View File

@ -47,7 +47,7 @@ function statusBadge(status) {
<td class="p-2 whitespace-nowrap">{{ new Date(imp.created_at).toLocaleString() }}</td>
<td class="p-2">{{ imp.original_name }}</td>
<td class="p-2"><span :class="['px-2 py-0.5 rounded text-xs', statusBadge(imp.status)]">{{ imp.status }}</span></td>
<td class="p-2">{{ imp.client?.uuid ?? '—' }}</td>
<td class="p-2">{{ imp.client?.person?.full_name ?? '—' }}</td>
<td class="p-2">{{ imp.template?.name ?? '—' }}</td>
<td class="p-2 space-x-2">
<Link :href="route('imports.continue', { import: imp.uuid })" class="px-2 py-1 rounded bg-gray-200 text-gray-800 text-xs">Poglej</Link>

View File

@ -181,6 +181,7 @@ const store = () => {
:searchable="true"
:taggable="false"
placeholder="Izberi segment"
:append-to-body="true"
:custom-label="(opt) => (segmentOptions.find(s=>s.id===opt)?.name || '')"
/>
</div>
@ -196,6 +197,7 @@ const store = () => {
track-by="id"
:taggable="true"
placeholder="Dodaj odločitev"
:append-to-body="true"
label="name"
/>
</div>
@ -256,6 +258,7 @@ const store = () => {
:searchable="true"
:taggable="false"
placeholder="Izberi segment"
:append-to-body="true"
:custom-label="(opt) => (segmentOptions.find(s=>s.id===opt)?.name || '')"
/>
</div>
@ -270,6 +273,7 @@ const store = () => {
track-by="id"
:taggable="true"
placeholder="Dodaj odločitev"
:append-to-body="true"
label="name"
/>
</div>

View File

@ -159,6 +159,7 @@ const store = () => {
track-by="id"
:taggable="true"
placeholder="Dodaj akcijo"
:append-to-body="true"
label="name"
/>
</div>
@ -219,6 +220,7 @@ const store = () => {
track-by="id"
:taggable="true"
placeholder="Dodaj akcijo"
:append-to-body="true"
label="name"
/>
</div>

View File

@ -9,6 +9,8 @@ import VueApexCharts from 'vue3-apexcharts';
import VueDatePicker from '@vuepic/vue-datepicker';
import '@vuepic/vue-datepicker/dist/main.css';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import VCalendar from 'v-calendar';
import 'v-calendar/style.css';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@ -19,6 +21,8 @@ createInertiaApp({
return createApp({ render: () => h(App, props) })
.use(plugin)
.use(ZiggyVue)
// Register v-calendar with a 'V' prefix so we get <VDatePicker> and <VCalendar>
.use(VCalendar, { componentPrefix: 'V' })
.use(VueApexCharts)
.component('vue-date-picker', VueDatePicker)
.component('FontAwesomeIcon', FontAwesomeIcon)

View File

@ -8,6 +8,7 @@
use App\Http\Controllers\SettingController;
use App\Http\Controllers\ImportController;
use App\Http\Controllers\ImportTemplateController;
use App\Http\Controllers\CaseObjectController;
use App\Models\Person\Person;
use Illuminate\Http\Request;
use ArielMejiaDev\LarapexCharts\LarapexChart;
@ -94,10 +95,17 @@
Route::put('person/{person:uuid}', [PersonController::class, 'update'])->name('person.update');
Route::post('person/{person:uuid}/address', [PersonController::class, 'createAddress'])->name('person.address.create');
Route::put('person/{person:uuid}/address/{address_id}', [PersonController::class, 'updateAddress'])->name('person.address.update');
Route::delete('person/{person:uuid}/address/{address_id}', [PersonController::class, 'deleteAddress'])->name('person.address.delete');
Route::post('person/{person:uuid}/phone', [PersonController::class, 'createPhone'])->name('person.phone.create');
Route::put('person/{person:uuid}/phone/{phone_id}', [PersonController::class, 'updatePhone'])->name('person.phone.update');
Route::delete('person/{person:uuid}/phone/{phone_id}', [PersonController::class, 'deletePhone'])->name('person.phone.delete');
Route::post('person/{person:uuid}/email', [PersonController::class, 'createEmail'])->name('person.email.create');
Route::put('person/{person:uuid}/email/{email_id}', [PersonController::class, 'updateEmail'])->name('person.email.update');
Route::delete('person/{person:uuid}/email/{email_id}', [PersonController::class, 'deleteEmail'])->name('person.email.delete');
// TRR (Bank account) endpoints
Route::post('person/{person:uuid}/trr', [PersonController::class, 'createTrr'])->name('person.trr.create');
Route::put('person/{person:uuid}/trr/{trr_id}', [PersonController::class, 'updateTrr'])->name('person.trr.update');
Route::delete('person/{person:uuid}/trr/{trr_id}', [PersonController::class, 'deleteTrr'])->name('person.trr.delete');
//client
Route::get('clients', [ClientController::class, 'index'])->name('client');
Route::get('clients/{client:uuid}', [ClientController::class, 'show'])->name('client.show');
@ -112,6 +120,10 @@
Route::post('client-cases/{client_case:uuid}/contract', [ClientCaseContoller::class, 'storeContract'])->name('clientCase.contract.store');
Route::put('client-cases/{client_case:uuid}/contract/{uuid}', [ClientCaseContoller::class, 'updateContract'])->name('clientCase.contract.update');
Route::delete('client-cases/{client_case:uuid}/contract/{uuid}', [ClientCaseContoller::class, 'deleteContract'])->name('clientCase.contract.delete');
// client-case / contract / objects
Route::post('client-cases/{client_case:uuid}/contract/{uuid}/objects', [CaseObjectController::class, 'store'])->name('clientCase.contract.object.store');
Route::put('client-cases/{client_case:uuid}/objects/{id}', [CaseObjectController::class, 'update'])->name('clientCase.object.update');
Route::delete('client-cases/{client_case:uuid}/objects/{id}', [CaseObjectController::class, 'destroy'])->name('clientCase.object.delete');
//client-case / activity
Route::post('client-cases/{client_case:uuid}/activity', [ClientCaseContoller::class, 'storeActivity'])->name('clientCase.activity.store');
//client-case / documents