From 12de0186cf7638eb4c56b3c5dcec8e282f8d0ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Thu, 2 Oct 2025 22:09:05 +0200 Subject: [PATCH] added option to import payments from csv file --- .../Controllers/AccountPaymentController.php | 25 +- app/Http/Controllers/ClientCaseContoller.php | 32 +- app/Http/Controllers/ImportController.php | 4 +- .../Controllers/ImportEntityController.php | 34 +- .../Controllers/ImportTemplateController.php | 55 +- app/Models/Payment.php | 28 +- app/Services/ImportProcessor.php | 342 ++++++- ...0000_change_payments_amount_to_decimal.php | 52 + ..._000010_add_balance_before_to_payments.php | 26 + ...nique_index_payments_account_reference.php | 41 + ...que_index_payments_ignore_soft_deletes.php | 58 ++ database/seeders/ImportEntitySeeder.php | 53 +- .../seeders/PaymentsImportTemplateSeeder.php | 88 ++ resources/examples/payments_sample.csv | 5 + resources/js/Layouts/AppLayout.vue | 786 ++++++++++----- .../js/Pages/Cases/Partials/ContractTable.vue | 204 ++-- .../Pages/Cases/Partials/PaymentsDialog.vue | 152 +++ .../Cases/Partials/ViewPaymentsDialog.vue | 141 +++ resources/js/Pages/Imports/Create.vue | 358 ++++--- .../js/Pages/Imports/Templates/Create.vue | 269 ++++-- resources/js/Pages/Imports/Templates/Edit.vue | 899 ++++++++++++++---- 21 files changed, 2828 insertions(+), 824 deletions(-) create mode 100644 database/migrations/2025_10_02_000000_change_payments_amount_to_decimal.php create mode 100644 database/migrations/2025_10_02_000010_add_balance_before_to_payments.php create mode 100644 database/migrations/2025_10_02_000020_add_unique_index_payments_account_reference.php create mode 100644 database/migrations/2025_10_02_000021_update_unique_index_payments_ignore_soft_deletes.php create mode 100644 database/seeders/PaymentsImportTemplateSeeder.php create mode 100644 resources/examples/payments_sample.csv create mode 100644 resources/js/Pages/Cases/Partials/PaymentsDialog.vue create mode 100644 resources/js/Pages/Cases/Partials/ViewPaymentsDialog.vue diff --git a/app/Http/Controllers/AccountPaymentController.php b/app/Http/Controllers/AccountPaymentController.php index 6332aa6..778b9a8 100644 --- a/app/Http/Controllers/AccountPaymentController.php +++ b/app/Http/Controllers/AccountPaymentController.php @@ -8,8 +8,8 @@ use App\Models\Booking; use App\Models\Payment; use App\Models\PaymentSetting; -use Illuminate\Http\RedirectResponse; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -20,11 +20,12 @@ public function index(Account $account): Response $payments = Payment::query() ->where('account_id', $account->id) ->orderByDesc('paid_at') - ->get(['id', 'amount_cents', 'currency', 'reference', 'paid_at', 'created_at']) + ->get(['id', 'amount', 'balance_before', 'currency', 'reference', 'paid_at', 'created_at']) ->map(function (Payment $p) { return [ 'id' => $p->id, - 'amount' => $p->amount, // accessor divides cents + 'amount' => (float) $p->amount, + 'balance_before' => (float) ($p->balance_before ?? 0), 'currency' => $p->currency, 'reference' => $p->reference, 'paid_at' => $p->paid_at, @@ -43,11 +44,12 @@ public function list(Account $account): JsonResponse $payments = Payment::query() ->where('account_id', $account->id) ->orderByDesc('paid_at') - ->get(['id', 'amount_cents', 'currency', 'reference', 'paid_at', 'created_at']) + ->get(['id', 'amount', 'balance_before', 'currency', 'reference', 'paid_at', 'created_at']) ->map(function (Payment $p) { return [ 'id' => $p->id, - 'amount' => $p->amount, + 'amount' => (float) $p->amount, + 'balance_before' => (float) ($p->balance_before ?? 0), 'currency' => $p->currency, 'reference' => $p->reference, 'paid_at' => optional($p->paid_at)?->toDateString(), @@ -76,7 +78,8 @@ public function store(StorePaymentRequest $request, Account $account): RedirectR $payment = Payment::query()->create([ 'account_id' => $account->id, - 'amount_cents' => $amountCents, + 'balance_before' => (float) ($account->balance_amount ?? 0), + 'amount' => (float) $validated['amount'], 'currency' => strtoupper($validated['currency'] ?? $defaultCurrency), 'reference' => $validated['reference'] ?? null, 'paid_at' => $validated['paid_at'] ?? now(), @@ -94,10 +97,17 @@ public function store(StorePaymentRequest $request, Account $account): RedirectR 'booked_at' => $payment->paid_at ?? now(), ]); - // Optionally create an activity entry with default decision/action + // Optionally create an activity entry with default decision/action if ($settings && ($settings->create_activity_on_payment ?? false)) { $note = $settings->activity_note_template ?? 'Prejeto plačilo'; $note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $payment->currency], $note); + + // Append balance context and cause + $account->refresh(); + $beforeStr = number_format((float) ($payment->balance_before ?? 0), 2, ',', '.').' '.$payment->currency; + $afterStr = number_format((float) ($account->balance_amount ?? 0), 2, ',', '.').' '.$payment->currency; + $note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: plačilo)"; + $account->loadMissing('contract'); $clientCaseId = $account->contract?->client_case_id; if ($clientCaseId) { @@ -139,6 +149,7 @@ public function destroy(Account $account, Payment $payment): RedirectResponse|Js if (request()->wantsJson()) { $account->refresh(); + return response()->json([ 'ok' => true, 'balance_amount' => $account->balance_amount, diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index 6eb8207..8494737 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -152,6 +152,7 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ // Preserve segment filter if present $segment = request('segment'); + return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]); } @@ -172,6 +173,8 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr $shouldUpsertAccount = (! is_null($initial)) || (! is_null($balance)) || $request->has('account_type_id'); if ($shouldUpsertAccount) { $accountData = []; + // Track old balance before applying changes + $oldBalance = (float) optional($contract->account)->balance_amount; if (! is_null($initial)) { $accountData['initial_amount'] = $initial; } @@ -189,12 +192,37 @@ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContr $accountData = array_merge(['initial_amount' => 0, 'balance_amount' => 0], $accountData); $contract->account()->create($accountData); } + + // After update/create, if balance_amount changed (and not through a payment), log an activity with before/after + if (array_key_exists('balance_amount', $accountData)) { + $newBalance = (float) optional($contract->account)->fresh()->balance_amount; + if ($newBalance !== $oldBalance) { + try { + $currency = optional(\App\Models\PaymentSetting::query()->first())->default_currency ?? 'EUR'; + $beforeStr = number_format($oldBalance, 2, ',', '.').' '.$currency; + $afterStr = number_format($newBalance, 2, ',', '.').' '.$currency; + $note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)'; + \App\Models\Activity::create([ + 'due_date' => null, + 'amount' => null, + 'note' => $note, + 'action_id' => null, + 'decision_id' => null, + 'client_case_id' => $contract->client_case_id, + 'contract_id' => $contract->id, + ]); + } catch (\Throwable $e) { + // non-fatal + } + } + } } }); // Preserve segment filter if present $segment = request('segment'); + return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]); } @@ -210,7 +238,6 @@ public function storeActivity(ClientCase $clientCase, Request $request) 'contract_uuid' => 'nullable|uuid', ]); - // Map contract_uuid to contract_id within the same client case, if provided $contractId = null; if (! empty($attributes['contract_uuid'])) { @@ -276,6 +303,7 @@ public function deleteContract(ClientCase $clientCase, string $uuid, Request $re // Preserve segment filter if present $segment = request('segment'); + return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]); } @@ -1048,11 +1076,13 @@ public function show(ClientCase $clientCase) } }); } + return $q->paginate(20, ['*'], 'activities')->withQueryString(); })(), function ($p) { $p->getCollection()->transform(function ($a) { $a->setAttribute('user_name', optional($a->user)->name); + return $a; }); } diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index 6c8facd..0b51aa5 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -255,10 +255,10 @@ public function saveMappings(Request $request, Import $import) $data = $request->validate([ 'mappings' => 'required|array', 'mappings.*.source_column' => 'required|string', - 'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases', + 'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'mappings.*.target_field' => 'required|string', 'mappings.*.transform' => 'nullable|string|in:trim,upper,lower,decimal,ref', - 'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both', + 'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'mappings.*.options' => 'nullable|array', ]); diff --git a/app/Http/Controllers/ImportEntityController.php b/app/Http/Controllers/ImportEntityController.php index fb69dd5..0446bd5 100644 --- a/app/Http/Controllers/ImportEntityController.php +++ b/app/Http/Controllers/ImportEntityController.php @@ -77,7 +77,13 @@ public function suggest(Request $request) if (! is_array($cols)) { $cols = []; } - $entities = ImportEntity::all(); + // Optional filter: only suggest for specific entity keys (e.g., template meta.entities) + $only = $request->input('only_entities'); + $query = ImportEntity::query()->orderByRaw("(ui->>'order')::int nulls last"); + if (is_array($only) && ! empty($only)) { + $query->whereIn('key', array_values(array_filter($only, fn ($v) => is_string($v) && $v !== ''))); + } + $entities = $query->get(); $suggestions = []; foreach ($cols as $col) { $s = $this->suggestFor($col, $entities); @@ -92,6 +98,8 @@ public function suggest(Request $request) private function suggestFor(string $source, $entities): ?array { $s = trim(mb_strtolower($source)); + $best = null; + $bestRank = PHP_INT_MAX; foreach ($entities as $ent) { $rules = (array) ($ent->rules ?? []); foreach ($rules as $rule) { @@ -101,15 +109,27 @@ private function suggestFor(string $source, $entities): ?array continue; } if (@preg_match($pattern, $s)) { - return [ - 'entity' => $ent->key, // UI key (plural except person) - 'field' => $field, - 'canonical_root' => $ent->canonical_root, - ]; + // Rank preferences: payments over address for amount/date-like terms + $rank = (int) ($ent->ui['order'] ?? 999); + $isPayment = ($ent->canonical_root ?? null) === 'payment'; + $isAddress = ($ent->canonical_root ?? null) === 'address'; + if ($isPayment) { + $rank -= 10; // boost payments + } elseif ($isAddress && in_array($field, ['amount', 'payment_date', 'payment_nu', 'reference'])) { + $rank += 10; // demote addresses for these + } + if ($rank < $bestRank) { + $bestRank = $rank; + $best = [ + 'entity' => $ent->key, + 'field' => $field, + 'canonical_root' => $ent->canonical_root, + ]; + } } } } - return null; + return $best; } } diff --git a/app/Http/Controllers/ImportTemplateController.php b/app/Http/Controllers/ImportTemplateController.php index 9e8fd48..c28ed5b 100644 --- a/app/Http/Controllers/ImportTemplateController.php +++ b/app/Http/Controllers/ImportTemplateController.php @@ -110,19 +110,21 @@ public function store(Request $request) 'client_id' => 'nullable|integer|exists:clients,id', 'is_active' => 'boolean', 'entities' => 'nullable|array', - 'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts', + 'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'mappings' => 'array', 'mappings.*.source_column' => 'required|string', - 'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases', + 'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'mappings.*.target_field' => 'nullable|string', 'mappings.*.transform' => 'nullable|string|max:50', - 'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both', + 'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'mappings.*.options' => 'nullable|array', 'mappings.*.position' => 'nullable|integer', 'meta' => 'nullable|array', 'meta.segment_id' => 'nullable|integer|exists:segments,id', 'meta.decision_id' => 'nullable|integer|exists:decisions,id', 'meta.action_id' => 'nullable|integer|exists:actions,id', + 'meta.payments_import' => 'nullable|boolean', + 'meta.contract_key_mode' => 'nullable|string|in:reference', ])->validate(); // Ensure decision belongs to action if both provided in meta @@ -138,6 +140,11 @@ public function store(Request $request) } $template = null; DB::transaction(function () use (&$template, $request, $data) { + $paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false); + $entities = $data['entities'] ?? []; + if ($paymentsImport) { + $entities = ['contracts', 'accounts', 'payments']; + } $template = ImportTemplate::create([ 'uuid' => (string) Str::uuid(), 'name' => $data['name'], @@ -149,10 +156,12 @@ public function store(Request $request) 'client_id' => $data['client_id'] ?? null, 'is_active' => $data['is_active'] ?? true, 'meta' => array_filter([ - 'entities' => $data['entities'] ?? [], + 'entities' => $entities, 'segment_id' => data_get($data, 'meta.segment_id'), 'decision_id' => data_get($data, 'meta.decision_id'), 'action_id' => data_get($data, 'meta.action_id'), + 'payments_import' => $paymentsImport ?: null, + 'contract_key_mode' => data_get($data, 'meta.contract_key_mode'), ], fn ($v) => ! is_null($v) && $v !== ''), ]); @@ -232,10 +241,10 @@ public function addMapping(Request $request, ImportTemplate $template) } $data = validator($raw, [ 'source_column' => 'required|string', - 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases', + 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'target_field' => 'nullable|string', 'transform' => 'nullable|string|in:trim,upper,lower', - 'apply_mode' => 'nullable|string|in:insert,update,both', + 'apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'options' => 'nullable|array', 'position' => 'nullable|integer', ])->validate(); @@ -295,6 +304,8 @@ public function update(Request $request, ImportTemplate $template) 'meta.segment_id' => 'nullable|integer|exists:segments,id', 'meta.decision_id' => 'nullable|integer|exists:decisions,id', 'meta.action_id' => 'nullable|integer|exists:actions,id', + 'meta.payments_import' => 'nullable|boolean', + 'meta.contract_key_mode' => 'nullable|string|in:reference', ])->validate(); // Validate decision/action consistency on update as well @@ -316,7 +327,7 @@ public function update(Request $request, ImportTemplate $template) if (array_key_exists('delimiter', $newMeta) && (! is_string($newMeta['delimiter']) || trim((string) $newMeta['delimiter']) === '')) { unset($newMeta['delimiter']); } - foreach (['segment_id', 'decision_id', 'action_id'] as $k) { + foreach (['segment_id', 'decision_id', 'action_id', 'payments_import', 'contract_key_mode'] as $k) { if (array_key_exists($k, $newMeta) && ($newMeta[$k] === '' || is_null($newMeta[$k]))) { unset($newMeta[$k]); } @@ -331,7 +342,16 @@ public function update(Request $request, ImportTemplate $template) 'client_id' => $data['client_id'] ?? null, 'is_active' => $data['is_active'] ?? $template->is_active, 'sample_headers' => $data['sample_headers'] ?? $template->sample_headers, - 'meta' => $newMeta, + 'meta' => (function () use ($newMeta) { + // If payments import mode is enabled, force entities sequence in meta + $meta = $newMeta; + $payments = (bool) ($meta['payments_import'] ?? false); + if ($payments) { + $meta['entities'] = ['contracts', 'accounts', 'payments']; + } + + return $meta; + })(), ]); return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) @@ -348,14 +368,21 @@ public function bulkAddMappings(Request $request, ImportTemplate $template) } $data = validator($raw, [ 'sources' => 'required|string', // comma and/or newline separated - 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases', + 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'default_field' => 'nullable|string', // if provided, used as the field name for all entries - 'apply_mode' => 'nullable|string|in:insert,update,both', + 'apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'transform' => 'nullable|string|in:trim,upper,lower', ])->validate(); - $list = preg_split('/\r?\n|,/', $data['sources']); - $list = array_values(array_filter(array_map(fn ($s) => trim($s), $list), fn ($s) => $s !== '')); + // Accept commas, semicolons, and newlines; strip surrounding quotes/apostrophes and whitespace + $list = preg_split('/[\r\n,;]+/', $data['sources']); + $list = array_values(array_filter(array_map(function ($s) { + $s = trim((string) $s); + // remove surrounding double/single quotes if present + $s = preg_replace('/^([\"\'])|([\"\'])$/u', '', $s) ?? $s; + + return $s; + }, $list), fn ($s) => $s !== '')); if (empty($list)) { return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) @@ -437,10 +464,10 @@ public function updateMapping(Request $request, ImportTemplate $template, Import } $data = validator($raw, [ 'source_column' => 'required|string', - 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts', + 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'target_field' => 'nullable|string', 'transform' => 'nullable|string|in:trim,upper,lower', - 'apply_mode' => 'nullable|string|in:insert,update,both', + 'apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'options' => 'nullable|array', 'position' => 'nullable|integer', ])->validate(); diff --git a/app/Models/Payment.php b/app/Models/Payment.php index b3fe440..0be46a7 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -2,13 +2,11 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; -use App\Models\Activity; class Payment extends Model { @@ -17,7 +15,8 @@ class Payment extends Model protected $fillable = [ 'account_id', - 'amount_cents', + 'amount', + 'balance_before', 'currency', 'reference', 'paid_at', @@ -31,7 +30,8 @@ protected function casts(): array return [ 'paid_at' => 'datetime', 'meta' => 'array', - 'amount_cents' => 'integer', + 'amount' => 'decimal:4', + 'balance_before' => 'decimal:4', ]; } @@ -55,23 +55,5 @@ public function type(): BelongsTo return $this->belongsTo(\App\Models\PaymentType::class); } - /** - * Accessor to expose decimal amount for JSON serialization and UI convenience. - */ - protected function amount(): Attribute - { - return Attribute::get(function () { - $cents = (int) ($this->attributes['amount_cents'] ?? 0); - - return $cents / 100; - }); - } - - /** - * Mutator to set amount via decimal; stores in cents. - */ - public function setAmountAttribute($value): void - { - $this->attributes['amount_cents'] = (int) round(((float) $value) * 100); - } + // amount is stored as decimal(20,4); default Eloquent get/set is sufficient } diff --git a/app/Services/ImportProcessor.php b/app/Services/ImportProcessor.php index e3ad8ea..41279f5 100644 --- a/app/Services/ImportProcessor.php +++ b/app/Services/ImportProcessor.php @@ -15,6 +15,7 @@ use App\Models\ImportEntity; use App\Models\ImportEvent; use App\Models\ImportRow; +use App\Models\Payment; use App\Models\Person\AddressType; use App\Models\Person\Person; use App\Models\Person\PersonAddress; @@ -68,6 +69,10 @@ public function process(Import $import, ?Authenticatable $user = null): array $this->validateMappingRoots($mappings, $validRoots); $header = $import->meta['columns'] ?? null; + // Template meta flags + $tplMeta = optional($import->template)->meta ?? []; + $paymentsImport = (bool) ($tplMeta['payments_import'] ?? false); + $contractKeyMode = $tplMeta['contract_key_mode'] ?? null; // Prefer explicitly chosen delimiter, then template meta, else detected $delimiter = $import->meta['forced_delimiter'] ?? optional($import->template)->meta['delimiter'] @@ -102,6 +107,43 @@ public function process(Import $import, ?Authenticatable $user = null): array 'level' => 'info', 'message' => 'Processing started.', ]); + // Preflight recommendations for payments-import + if ($paymentsImport) { + $hasContractRef = $this->mappingIncludes($mappings, 'contract.reference'); + $hasPaymentAmount = $this->mappingIncludes($mappings, 'payment.amount'); + $hasPaymentDate = $this->mappingIncludes($mappings, 'payment.payment_date') || $this->mappingIncludes($mappings, 'payment.paid_at'); + $hasPaymentRef = $this->mappingIncludes($mappings, 'payment.reference'); + $hasPaymentNu = $this->mappingIncludes($mappings, 'payment.payment_nu'); + $missing = []; + if (! $hasContractRef) { + $missing[] = 'contract.reference'; + } + if (! $hasPaymentAmount) { + $missing[] = 'payment.amount'; + } + if (! $hasPaymentDate) { + $missing[] = 'payment.payment_date'; + } + if (! empty($missing)) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'payments_mapping_recommendation', + 'level' => 'warning', + 'message' => 'Payments import: recommended mappings missing.', + 'context' => ['missing' => $missing], + ]); + } + if (! $hasPaymentRef && ! $hasPaymentNu) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'payments_idempotency_recommendation', + 'level' => 'warning', + 'message' => 'For idempotency, map payment.reference (preferred) or payment.payment_nu.', + ]); + } + } $rowNum = 0; if ($hasHeader) { @@ -177,7 +219,32 @@ public function process(Import $import, ?Authenticatable $user = null): array // Contracts $contractResult = null; if (isset($mapped['contract'])) { - $contractResult = $this->upsertContractChain($import, $mapped, $mappings); + // In payments-import with contract_key_mode=reference, treat contract.reference as a keyref only + if ($paymentsImport && $contractKeyMode === 'reference') { + $ref = $mapped['contract']['reference'] ?? null; + if (is_string($ref)) { + $ref = preg_replace('/\s+/', '', trim($ref)); + } + if ($ref) { + $q = Contract::query() + ->when($import->client_id, function ($q2, $clientId) { + $q2->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId); + }) + ->where('contracts.reference', $ref) + ->select('contracts.*'); + $found = $q->first(); + if ($found) { + $contractResult = ['action' => 'resolved', 'contract' => $found]; + } else { + $contractResult = null; // let requireContract logic flag invalid later + } + } else { + $contractResult = null; + } + } else { + $contractResult = $this->upsertContractChain($import, $mapped, $mappings); + } if ($contractResult['action'] === 'skipped') { // Even if no contract fields were updated, we may still need to apply template meta // like attaching a segment or creating an activity. Do that if we have the contract. @@ -330,6 +397,233 @@ public function process(Import $import, ?Authenticatable $user = null): array $ccId = $contractResult['contract']->client_case_id; $personIdForRow = ClientCase::where('id', $ccId)->value('person_id'); } + + // Payments: when present, require account resolution and create payment + if (isset($mapped['payment'])) { + // If no account yet, try to resolve via contract + first account + $accountIdForPayment = $accountResult['account']->id ?? null; + + // If payments-import mode with contract_key_mode=reference and we have contract.reference mapped, resolve by reference only + $tplMeta = optional($import->template)->meta ?? []; + $paymentsImport = (bool) ($tplMeta['payments_import'] ?? false); + $contractKeyMode = $tplMeta['contract_key_mode'] ?? null; + if (! $accountIdForPayment && $paymentsImport && $contractKeyMode === 'reference') { + $contractRef = $mapped['contract']['reference'] ?? null; + if ($contractRef) { + $contract = \App\Models\Contract::query() + ->when($import->client_id, function ($q, $clientId) { + $q->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId); + }) + ->where('contracts.reference', $contractRef) + ->select('contracts.id') + ->first(); + if ($contract) { + $accountIdForPayment = \App\Models\Account::where('contract_id', $contract->id)->value('id'); + } + } + } + + if (! $accountIdForPayment) { + $invalid++; + $importRow->update(['status' => 'invalid', 'errors' => ['Payment requires an account. Could not resolve account for payment.']]); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_invalid', + 'level' => 'error', + 'message' => 'Payment requires an account (not resolved).', + ]); + + continue; + } + + // Build payment payload + $p = $mapped['payment']; + // Normalize reference and payment number (if provided) + $refVal = isset($p['reference']) ? (is_string($p['reference']) ? trim($p['reference']) : $p['reference']) : null; + $nuVal = isset($p['payment_nu']) ? (is_string($p['payment_nu']) ? trim($p['payment_nu']) : $p['payment_nu']) : null; + $payload = [ + 'account_id' => $accountIdForPayment, + 'reference' => $p['reference'] ?? null, + 'paid_at' => $p['payment_date'] ?? ($p['paid_at'] ?? null), + 'currency' => $p['currency'] ?? 'EUR', + 'created_by' => $user?->getAuthIdentifier(), + ]; + // Attach payment_nu into meta for idempotency if provided + $meta = []; + if (is_array($p['meta'] ?? null)) { + $meta = $p['meta']; + } + if (! empty($nuVal)) { + $meta['payment_nu'] = $nuVal; + } + if (! empty($meta)) { + $payload['meta'] = $meta; + } + // Amount: accept either amount (preferred) or legacy amount_cents; convert cents -> decimal + if (array_key_exists('amount', $p)) { + $payload['amount'] = is_string($p['amount']) ? (float) $this->normalizeDecimal($p['amount']) : (float) $p['amount']; + } elseif (array_key_exists('amount_cents', $p)) { + $payload['amount'] = ((int) $p['amount_cents']) / 100.0; + } + + // Idempotency: skip creating if a payment with same (account_id, reference) already exists + if (! empty($refVal)) { + $exists = Payment::query() + ->where('account_id', $accountIdForPayment) + ->where('reference', $refVal) + ->exists(); + if ($exists) { + $skipped++; + $importRow->update(['status' => 'skipped']); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'payment_duplicate_skipped', + 'level' => 'info', + 'message' => 'Skipped duplicate payment (by reference).', + 'context' => [ + 'account_id' => $accountIdForPayment, + 'reference' => $refVal, + ], + ]); + + continue; + } + } + + $payment = new Payment; + $payment->fill($payload); + // Save the account balance before applying this payment + $accForBal = Account::find($accountIdForPayment); + if ($accForBal) { + $payment->balance_before = (float) ($accForBal->balance_amount ?? 0); + } + // If amount not in payload yet but provided, set it directly + if (! array_key_exists('amount', $payload) && isset($p['amount'])) { + $payment->amount = (float) $this->normalizeDecimal($p['amount']); + } + try { + $payment->save(); + } catch (\Throwable $e) { + // Gracefully skip if unique index on (account_id, reference) is violated due to race conditions + if (str_contains($e->getMessage(), 'payments_account_id_reference_unique')) { + $skipped++; + $importRow->update(['status' => 'skipped']); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'payment_duplicate_skipped_db', + 'level' => 'info', + 'message' => 'Skipped duplicate payment due to database unique constraint (account_id, reference).', + 'context' => [ + 'account_id' => $accountIdForPayment, + 'reference' => $refVal, + ], + ]); + + continue; + } + throw $e; + } + + // Option A: create a credit booking so account balance updates via booking events + try { + if (isset($payment->amount)) { + \App\Models\Booking::query()->create([ + 'account_id' => $accountIdForPayment, + 'payment_id' => $payment->id, + 'amount_cents' => (int) round(((float) $payment->amount) * 100), + 'type' => 'credit', + 'description' => $payment->reference ? ('Plačilo '.$payment->reference) : 'Plačilo', + 'booked_at' => $payment->paid_at ?? now(), + ]); + } + } catch (\Throwable $e) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'booking_create_failed', + 'level' => 'warning', + 'message' => 'Failed to create booking for payment: '.$e->getMessage(), + ]); + } + + // Optionally create an activity entry for this payment + try { + $settings = \App\Models\PaymentSetting::query()->first(); + if ($settings && ($settings->create_activity_on_payment ?? false)) { + $amountCents = (int) round(((float) $payment->amount) * 100); + $note = $settings->activity_note_template ?? 'Prejeto plačilo'; + $note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $payment->currency ?? 'EUR'], $note); + + // Append balance context (before/after) and mark cause as payment + // At this point, booking has been created so the account balance should reflect the new amount + $accountAfter = Account::find($accountIdForPayment); + $beforeStr = number_format((float) ($payment->balance_before ?? 0), 2, ',', '.').' '.($payment->currency ?? 'EUR'); + $afterStr = number_format((float) ($accountAfter?->balance_amount ?? 0), 2, ',', '.').' '.($payment->currency ?? 'EUR'); + $note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: plačilo)"; + + // Resolve client_case_id via account->contract + $accountForActivity = $accForBal ?: Account::find($accountIdForPayment); + $accountForActivity?->loadMissing('contract'); + $contractId = $accountForActivity?->contract_id; + $clientCaseId = $accountForActivity?->contract?->client_case_id; + + if ($clientCaseId) { + $activity = \App\Models\Activity::query()->create([ + 'due_date' => null, + 'amount' => $amountCents / 100, + 'note' => $note, + 'action_id' => $settings->default_action_id, + 'decision_id' => $settings->default_decision_id, + 'client_case_id' => $clientCaseId, + 'contract_id' => $contractId, + ]); + $payment->update(['activity_id' => $activity->id]); + } else { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'payment_activity_skipped', + 'level' => 'info', + 'message' => 'Skipped creating activity for payment due to missing client_case_id on contract.', + ]); + } + } + } catch (\Throwable $e) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'payment_activity_failed', + 'level' => 'warning', + 'message' => 'Failed to create activity for payment: '.$e->getMessage(), + ]); + } + + $imported++; + $importRow->update([ + 'status' => 'imported', + 'entity_type' => Payment::class, + 'entity_id' => $payment->id, + ]); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_imported', + 'level' => 'info', + 'message' => 'Inserted payment', + 'context' => ['id' => $payment->id], + ]); + } // If we have a contract reference, resolve existing contract for this client and derive person if (! $personIdForRow && $import->client_id && ! empty($mapped['contract']['reference'] ?? null)) { $existingContract = Contract::query() @@ -709,10 +1003,16 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array continue; } $value = $acc[$field] ?? null; - if (in_array($field, ['balance_amount','initial_amount'], true) && is_string($value)) { + if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) { $value = $this->normalizeDecimal($value); } $mode = $map->apply_mode ?? 'both'; + if ($mode === 'keyref') { + // treat as insert-only field (lookup + create), never update + $applyInsert[$field] = $value; + + continue; + } $applyModeByField[$field] = $mode; if (in_array($mode, ['insert', 'both'])) { $applyInsert[$field] = $value; @@ -731,9 +1031,39 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array if (empty($changes)) { return ['action' => 'skipped', 'message' => 'No non-null changes']; } + // Track balance change + $oldBalance = (float) ($existing->balance_amount ?? 0); $existing->fill($changes); $existing->save(); + // If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after + if (array_key_exists('balance_amount', $changes)) { + $newBalance = (float) ($existing->balance_amount ?? 0); + if ($newBalance !== $oldBalance) { + try { + $contractId = $existing->contract_id; + $clientCaseId = Contract::where('id', $contractId)->value('client_case_id'); + $currency = optional(\App\Models\PaymentSetting::query()->first())->default_currency ?? 'EUR'; + $beforeStr = number_format($oldBalance, 2, ',', '.').' '.$currency; + $afterStr = number_format($newBalance, 2, ',', '.').' '.$currency; + $note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)'; + if ($clientCaseId) { + Activity::create([ + 'due_date' => null, + 'amount' => null, + 'note' => $note, + 'action_id' => null, + 'decision_id' => null, + 'client_case_id' => $clientCaseId, + 'contract_id' => $contractId, + ]); + } + } catch (\Throwable $e) { + // Non-fatal: ignore activity creation failures + } + } + } + // also include contract hints for downstream contact resolution return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId]; } else { @@ -743,7 +1073,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array if ((! array_key_exists('initial_amount', $applyInsert) || is_null($applyInsert['initial_amount'] ?? null)) && array_key_exists('balance_amount', $applyInsert) && ($applyInsert['balance_amount'] !== null && $applyInsert['balance_amount'] !== '') - && ($initMode === null || in_array($initMode, ['insert','both'], true))) { + && ($initMode === null || in_array($initMode, ['insert', 'both'], true))) { $applyInsert['initial_amount'] = $applyInsert['balance_amount']; } if (empty($applyInsert)) { @@ -902,6 +1232,12 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): $value = preg_replace('/\s+/', '', trim((string) $value)); } $mode = $map->apply_mode ?? 'both'; + // keyref: used as lookup and applied on insert, but not on update + if ($mode === 'keyref') { + $applyInsert[$field] = $value; + + continue; + } if (in_array($mode, ['insert', 'both'])) { $applyInsert[$field] = $value; } diff --git a/database/migrations/2025_10_02_000000_change_payments_amount_to_decimal.php b/database/migrations/2025_10_02_000000_change_payments_amount_to_decimal.php new file mode 100644 index 0000000..5446a43 --- /dev/null +++ b/database/migrations/2025_10_02_000000_change_payments_amount_to_decimal.php @@ -0,0 +1,52 @@ +decimal('amount', 20, 4)->nullable()->after('account_id'); + } + }); + + // Backfill amount from amount_cents if present + if (Schema::hasColumn('payments', 'amount_cents')) { + DB::statement('UPDATE payments SET amount = CASE WHEN amount_cents IS NOT NULL THEN amount_cents / 100.0 ELSE amount END'); + } + + // Make amount non-null if desired; keeping nullable to avoid breaking existing rows + // Drop amount_cents column + Schema::table('payments', function (Blueprint $table): void { + if (Schema::hasColumn('payments', 'amount_cents')) { + $table->dropColumn('amount_cents'); + } + }); + } + + public function down(): void + { + // Recreate amount_cents and backfill from amount, then drop amount + Schema::table('payments', function (Blueprint $table): void { + if (! Schema::hasColumn('payments', 'amount_cents')) { + $table->integer('amount_cents')->nullable()->after('account_id'); + } + }); + + if (Schema::hasColumn('payments', 'amount')) { + DB::statement('UPDATE payments SET amount_cents = CASE WHEN amount IS NOT NULL THEN ROUND(amount * 100) ELSE amount_cents END'); + } + + Schema::table('payments', function (Blueprint $table): void { + if (Schema::hasColumn('payments', 'amount')) { + $table->dropColumn('amount'); + } + }); + } +}; diff --git a/database/migrations/2025_10_02_000010_add_balance_before_to_payments.php b/database/migrations/2025_10_02_000010_add_balance_before_to_payments.php new file mode 100644 index 0000000..6f7bee3 --- /dev/null +++ b/database/migrations/2025_10_02_000010_add_balance_before_to_payments.php @@ -0,0 +1,26 @@ +decimal('balance_before', 20, 4)->nullable()->after('amount'); + } + }); + } + + public function down(): void + { + Schema::table('payments', function (Blueprint $table): void { + if (Schema::hasColumn('payments', 'balance_before')) { + $table->dropColumn('balance_before'); + } + }); + } +}; diff --git a/database/migrations/2025_10_02_000020_add_unique_index_payments_account_reference.php b/database/migrations/2025_10_02_000020_add_unique_index_payments_account_reference.php new file mode 100644 index 0000000..4d869b1 --- /dev/null +++ b/database/migrations/2025_10_02_000020_add_unique_index_payments_account_reference.php @@ -0,0 +1,41 @@ +hasUniqueIndex('payments', 'payments_account_id_reference_unique')) { + $table->unique(['account_id', 'reference'], 'payments_account_id_reference_unique'); + } + }); + } + + public function down(): void + { + Schema::table('payments', function (Blueprint $table): void { + if ($this->hasUniqueIndex('payments', 'payments_account_id_reference_unique')) { + $table->dropUnique('payments_account_id_reference_unique'); + } + }); + } + + private function hasUniqueIndex(string $table, string $indexName): bool + { + // Works for both SQLite and others by checking existing indexes from connection schema manager when available. + try { + $connection = Schema::getConnection(); + $schemaManager = $connection->getDoctrineSchemaManager(); + $indexes = $schemaManager->listTableIndexes($table); + + return array_key_exists($indexName, $indexes); + } catch (\Throwable $e) { + // Fallback: attempt dropping/creating blindly in migration operations + return false; + } + } +}; diff --git a/database/migrations/2025_10_02_000021_update_unique_index_payments_ignore_soft_deletes.php b/database/migrations/2025_10_02_000021_update_unique_index_payments_ignore_soft_deletes.php new file mode 100644 index 0000000..4853f5f --- /dev/null +++ b/database/migrations/2025_10_02_000021_update_unique_index_payments_ignore_soft_deletes.php @@ -0,0 +1,58 @@ +unique(['account_id', 'reference'], 'payments_account_id_reference_unique'); + } catch (\Throwable $e) { + // ignore if already exists + } + }); + } + } + + public function down(): void + { + $driver = DB::getDriverName(); + if ($driver === 'pgsql') { + DB::statement('DROP INDEX IF EXISTS uniq_payments_account_reference_not_deleted'); + // Recreate a standard unique index (non-partial) with the original name + DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS payments_account_id_reference_unique ON payments (account_id, reference)'); + } else { + // For other drivers, nothing special beyond ensuring the standard index exists + Schema::table('payments', function ($table): void { + try { + $table->unique(['account_id', 'reference'], 'payments_account_id_reference_unique'); + } catch (\Throwable $e) { + // ignore + } + }); + } + } +}; diff --git a/database/seeders/ImportEntitySeeder.php b/database/seeders/ImportEntitySeeder.php index 8e536cb..5367898 100644 --- a/database/seeders/ImportEntitySeeder.php +++ b/database/seeders/ImportEntitySeeder.php @@ -37,11 +37,25 @@ public function run(): void 'key' => 'person_addresses', 'canonical_root' => 'address', 'label' => 'Person Addresses', - 'fields' => ['address', 'country', 'type_id', 'description'], - 'aliases' => ['address', 'person_addresses'], + 'fields' => ['address', 'city', 'postal_code', 'country', 'type_id', 'description'], + 'field_aliases' => [ + 'ulica' => 'address', + 'naslov' => 'address', + 'mesto' => 'city', + 'posta' => 'postal_code', + 'pošta' => 'postal_code', + 'zip' => 'postal_code', + 'drzava' => 'country', + 'država' => 'country', + 'opis' => 'description', + ], + 'aliases' => ['person_addresses', 'address', 'addresses'], 'rules' => [ ['pattern' => '/^(naslov|ulica|address)\b/i', 'field' => 'address'], + ['pattern' => '/^(mesto|city|kraj)\b/i', 'field' => 'city'], + ['pattern' => '/^(posta|pošta|zip|postal)\b/i', 'field' => 'postal_code'], ['pattern' => '/^(drzava|država|country)\b/i', 'field' => 'country'], + ['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'], ], 'ui' => ['order' => 2], ], @@ -106,6 +120,41 @@ public function run(): void ], 'ui' => ['order' => 7], ], + [ + 'key' => 'payments', + 'canonical_root' => 'payment', + 'label' => 'Payments', + // include common fields and helpful references for mapping + 'fields' => [ + 'reference', + 'payment_nu', + 'payment_date', + 'amount', + 'type_id', + 'active', + // optional helpers for mapping by related records + 'debt_id', + 'account_id', + 'account_reference', + 'contract_reference', + ], + 'field_aliases' => [ + 'date' => 'payment_date', + 'datum' => 'payment_date', + 'paid_at' => 'payment_date', + 'number' => 'payment_nu', + 'znesek' => 'amount', + 'value' => 'amount', + ], + 'aliases' => ['payment', 'payments', 'placila', 'plačila'], + 'rules' => [ + ['pattern' => '/^(sklic|reference|ref)\b/i', 'field' => 'reference'], + ['pattern' => '/^(stevilka|številka|number|payment\s*no\.?|payment\s*nu)\b/i', 'field' => 'payment_nu'], + ['pattern' => '/^(datum|date|paid\s*at|payment\s*date)\b/i', 'field' => 'payment_date'], + ['pattern' => '/^(znesek|amount|vplacilo|vplačilo|placilo|plačilo)\b/i', 'field' => 'amount'], + ], + 'ui' => ['order' => 8], + ], ]; foreach ($defs as $d) { diff --git a/database/seeders/PaymentsImportTemplateSeeder.php b/database/seeders/PaymentsImportTemplateSeeder.php new file mode 100644 index 0000000..869acb1 --- /dev/null +++ b/database/seeders/PaymentsImportTemplateSeeder.php @@ -0,0 +1,88 @@ +firstOrCreate([ + 'name' => 'Payments CSV (reference)', + ], [ + 'uuid' => (string) Str::uuid(), + 'description' => 'Payments import by contract reference: contract.reference lookup + payment fields', + 'source_type' => 'csv', + 'default_record_type' => 'payments', + 'sample_headers' => [ + 'Pogodba sklic', + 'Številka plačila', + 'Datum', + 'Znesek', + 'Sklic plačila', + ], + 'is_active' => true, + 'meta' => [ + 'delimiter' => ',', + 'enclosure' => '"', + 'escape' => '\\', + 'payments_import' => true, + 'contract_key_mode' => 'reference', + ], + ]); + + $mappings = [ + [ + 'entity' => 'contracts', + 'source_column' => 'Pogodba sklic', + 'target_field' => 'contract.reference', + 'transform' => 'trim', + 'position' => 1, + ], + [ + 'entity' => 'payments', + 'source_column' => 'Številka plačila', + 'target_field' => 'payment.payment_nu', + 'transform' => 'trim', + 'position' => 2, + ], + [ + 'entity' => 'payments', + 'source_column' => 'Datum', + 'target_field' => 'payment.payment_date', + 'transform' => null, + 'position' => 3, + ], + [ + 'entity' => 'payments', + 'source_column' => 'Znesek', + 'target_field' => 'payment.amount', + 'transform' => 'decimal', + 'position' => 4, + ], + [ + 'entity' => 'payments', + 'source_column' => 'Sklic plačila', + 'target_field' => 'payment.reference', + 'transform' => 'trim', + 'position' => 5, + ], + ]; + + foreach ($mappings as $map) { + ImportTemplateMapping::firstOrCreate([ + 'import_template_id' => $template->id, + 'source_column' => $map['source_column'], + ], [ + 'entity' => $map['entity'], + 'target_field' => $map['target_field'], + 'transform' => $map['transform'], + 'position' => $map['position'], + ]); + } + } +} diff --git a/resources/examples/payments_sample.csv b/resources/examples/payments_sample.csv new file mode 100644 index 0000000..b1626cd --- /dev/null +++ b/resources/examples/payments_sample.csv @@ -0,0 +1,5 @@ +"Pogodba sklic","Številka plačila","Datum","Znesek","Sklic plačila" +"5362030581","P-2025-0001","2025-09-01","120.50","REF-53620-A" +"5362017358","P-2025-0002","2025-09-15","80.00","REF-53620-B" +"5362011838","P-2025-0201","2025-09-03","300.00","REF-53413-A" +"5362017783","P-2025-0202","2025-09-20","150.00","REF-53413-B" \ No newline at end of file diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index f2971cc..0c2cc64 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -1,18 +1,18 @@ diff --git a/resources/js/Pages/Cases/Partials/ContractTable.vue b/resources/js/Pages/Cases/Partials/ContractTable.vue index c953025..8b9b221 100644 --- a/resources/js/Pages/Cases/Partials/ContractTable.vue +++ b/resources/js/Pages/Cases/Partials/ContractTable.vue @@ -12,6 +12,7 @@ import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue"; import CaseObjectsDialog from "./CaseObjectsDialog.vue"; import PaymentDialog from "./PaymentDialog.vue"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; +import ViewPaymentsDialog from "./ViewPaymentsDialog.vue"; import { faCircleInfo, faClock, @@ -102,6 +103,15 @@ const contractActiveSegment = (c) => { return arr.find((s) => s.pivot?.active) || arr[0] || null; }; const segmentName = (id) => props.segments.find((s) => s.id === id)?.name || ""; +// Sorted segment lists for dropdowns +const sortedSegments = computed(() => { + const list = Array.isArray(props.segments) ? [...props.segments] : []; + return list.sort((a, b) => a.name.localeCompare(b.name, "sl", { sensitivity: "base" })); +}); +const sortedAllSegments = computed(() => { + const list = Array.isArray(props.all_segments) ? [...props.all_segments] : []; + return list.sort((a, b) => a.name.localeCompare(b.name, "sl", { sensitivity: "base" })); +}); const confirmChange = ref({ show: false, contract: null, @@ -171,55 +181,25 @@ const submitPayment = () => { return; } const accountId = paymentContract.value.account.id; - paymentForm.post(route("accounts.payments.store", { account: accountId }), { - preserveScroll: true, - onSuccess: () => { - closePaymentDialog(); - // Reload contracts and activities (new payment may create an activity) - router.reload({ only: ["contracts", "activities"] }); + paymentForm.post(route("accounts.payments.store", { account: accountId }), { + preserveScroll: true, + onSuccess: () => { + closePaymentDialog(); + // Reload contracts and activities (new payment may create an activity) + router.reload({ only: ["contracts", "activities"] }); }, }); }; -// View Payments dialog state and logic +// View Payments dialog state const showPaymentsDialog = ref(false); -const paymentsForContract = ref([]); -const paymentsLoading = ref(false); -const openPaymentsDialog = async (c) => { +const openPaymentsDialog = (c) => { selectedContract.value = c; showPaymentsDialog.value = true; - await loadPayments(); }; const closePaymentsDialog = () => { showPaymentsDialog.value = false; selectedContract.value = null; - paymentsForContract.value = []; -}; -const loadPayments = async () => { - if (!selectedContract.value?.account?.id) return; - paymentsLoading.value = true; - try { - const { data } = await axios.get(route("accounts.payments.list", { account: selectedContract.value.account.id })); - paymentsForContract.value = data.payments || []; - } finally { - paymentsLoading.value = false; - } -}; -const deletePayment = (paymentId) => { - if (!selectedContract.value?.account?.id) return; - const accountId = selectedContract.value.account.id; - router.delete(route("accounts.payments.destroy", { account: accountId, payment: paymentId }), { - preserveScroll: true, - preserveState: true, - only: ["contracts", "activities"], - onSuccess: async () => { - await loadPayments(); - }, - onError: async () => { - // Even if there is an error, try to refresh payments list - await loadPayments(); - }, - }); }; @@ -275,7 +255,7 @@ const deletePayment = (paymentId) => { {{ contractActiveSegment(c)?.name || "-" }} - + @@ -356,7 +336,11 @@ const deletePayment = (paymentId) => { class="inline-flex items-center justify-center h-5 w-5 rounded-full" :title="'Pokaži opis'" :disabled="!hasDesc(c)" - :class="hasDesc(c) ? 'hover:bg-gray-100 focus:outline-none' : text-gray-400" + :class=" + hasDesc(c) + ? 'hover:bg-gray-100 focus:outline-none' + : 'text-gray-400' + " > { - + @@ -556,54 +562,16 @@ const deletePayment = (paymentId) => { :contract="selectedContract" /> - + - -
-
-
-
- Plačila za pogodbo - {{ selectedContract?.reference }} -
- -
-
-
Nalaganje…
- -
-
- - -
-
-
+ diff --git a/resources/js/Pages/Cases/Partials/PaymentsDialog.vue b/resources/js/Pages/Cases/Partials/PaymentsDialog.vue new file mode 100644 index 0000000..f1ca75d --- /dev/null +++ b/resources/js/Pages/Cases/Partials/PaymentsDialog.vue @@ -0,0 +1,152 @@ + + + diff --git a/resources/js/Pages/Cases/Partials/ViewPaymentsDialog.vue b/resources/js/Pages/Cases/Partials/ViewPaymentsDialog.vue new file mode 100644 index 0000000..fb5e67f --- /dev/null +++ b/resources/js/Pages/Cases/Partials/ViewPaymentsDialog.vue @@ -0,0 +1,141 @@ + + + \ No newline at end of file diff --git a/resources/js/Pages/Imports/Create.vue b/resources/js/Pages/Imports/Create.vue index 79ef59f..a0e8ed2 100644 --- a/resources/js/Pages/Imports/Create.vue +++ b/resources/js/Pages/Imports/Create.vue @@ -1,9 +1,9 @@ -

Only global templates are shown until a client is selected.

+

+ Only global templates are shown until a client is selected. +

@@ -411,19 +481,26 @@ onMounted(async () => {
- +
- +
Upload a file first to enable saving mappings.
-
- Select an Entity and Field for at least one detected column (or uncheck Skip) and then click Save Mappings. +
+ Select an Entity and Field for at least one detected column (or uncheck Skip) + and then click Save Mappings.
-

Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})

+

+ Detected Columns ({{ detected.has_header ? "header" : "positional" }}) +

+ @click.prevent=" + (async () => { + await refreshSuggestions(detected.columns); + mappingRows.forEach((r) => applySuggestionToRow(r)); + })() + " + > + Auto map suggestions +
@@ -476,21 +573,38 @@ onMounted(async () => {
{{ row.source_column }}
Suggest: -
@@ -515,16 +629,22 @@ onMounted(async () => {
-
Mappings saved ({{ mappingSavedCount }}).
-
{{ mappingError }}
+
+ Mappings saved ({{ mappingSavedCount }}). +
+
+ {{ mappingError }} +

Import Result

-
{{ processResult }}
+
{{
+              processResult
+            }}
- \ No newline at end of file + diff --git a/resources/js/Pages/Imports/Templates/Create.vue b/resources/js/Pages/Imports/Templates/Create.vue index dfae43f..756756f 100644 --- a/resources/js/Pages/Imports/Templates/Create.vue +++ b/resources/js/Pages/Imports/Templates/Create.vue @@ -1,9 +1,9 @@