added option to import payments from csv file
This commit is contained in:
parent
971a9e89d1
commit
12de0186cf
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('payments', function (Blueprint $table): void {
|
||||
// Add new decimal column next to amount_cents temporarily for safe backfill
|
||||
if (! Schema::hasColumn('payments', 'amount')) {
|
||||
$table->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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('payments', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('payments', 'balance_before')) {
|
||||
$table->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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('payments', function (Blueprint $table): void {
|
||||
if (! $this->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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$driver = DB::getDriverName();
|
||||
if ($driver === 'pgsql') {
|
||||
// Drop constraint if exists (covers both constraint-created and index-created uniqueness)
|
||||
try {
|
||||
DB::statement('ALTER TABLE payments DROP CONSTRAINT IF EXISTS payments_account_id_reference_unique');
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
// Drop index if exists
|
||||
try {
|
||||
DB::statement('DROP INDEX IF EXISTS payments_account_id_reference_unique');
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Create a partial unique index that ignores soft-deleted rows, use a new name
|
||||
DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS uniq_payments_account_reference_not_deleted ON payments (account_id, reference) WHERE deleted_at IS NULL');
|
||||
} else {
|
||||
// Fallback for other drivers: ensure a regular unique index exists (no partial support)
|
||||
Schema::table('payments', function ($table): void {
|
||||
try {
|
||||
$table->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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
88
database/seeders/PaymentsImportTemplateSeeder.php
Normal file
88
database/seeders/PaymentsImportTemplateSeeder.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\ImportTemplate;
|
||||
use App\Models\ImportTemplateMapping;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaymentsImportTemplateSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$template = ImportTemplate::query()->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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
5
resources/examples/payments_sample.csv
Normal file
5
resources/examples/payments_sample.csv
Normal file
|
|
@ -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"
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, watch, computed } from 'vue';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/vue3';
|
||||
import ApplicationMark from '@/Components/ApplicationMark.vue';
|
||||
import Banner from '@/Components/Banner.vue';
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||
import Breadcrumbs from '@/Components/Breadcrumbs.vue';
|
||||
import GlobalSearch from './Partials/GlobalSearch.vue';
|
||||
import NotificationsBell from './Partials/NotificationsBell.vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faMobileScreenButton } from '@fortawesome/free-solid-svg-icons';
|
||||
import { onMounted, onUnmounted, ref, watch, computed } from "vue";
|
||||
import { Head, Link, router, usePage } from "@inertiajs/vue3";
|
||||
import ApplicationMark from "@/Components/ApplicationMark.vue";
|
||||
import Banner from "@/Components/Banner.vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import DropdownLink from "@/Components/DropdownLink.vue";
|
||||
import Breadcrumbs from "@/Components/Breadcrumbs.vue";
|
||||
import GlobalSearch from "./Partials/GlobalSearch.vue";
|
||||
import NotificationsBell from "./Partials/NotificationsBell.vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faMobileScreenButton } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
title: String,
|
||||
});
|
||||
|
||||
// Collapsible sidebar state (persisted when user explicitly toggles)
|
||||
|
|
@ -22,33 +22,35 @@ const hasSavedSidebarPref = ref(false);
|
|||
const isMobile = ref(false);
|
||||
const mobileSidebarOpen = ref(false);
|
||||
function applyAutoCollapse() {
|
||||
if (typeof window === 'undefined') return;
|
||||
isMobile.value = window.innerWidth < 1024; // Tailwind lg breakpoint
|
||||
sidebarCollapsed.value = isMobile.value;
|
||||
if (typeof window === "undefined") return;
|
||||
isMobile.value = window.innerWidth < 1024; // Tailwind lg breakpoint
|
||||
sidebarCollapsed.value = isMobile.value;
|
||||
}
|
||||
function handleResize() {
|
||||
if (typeof window !== 'undefined') {
|
||||
isMobile.value = window.innerWidth < 1024;
|
||||
if (!isMobile.value) mobileSidebarOpen.value = false; // close drawer when switching to desktop
|
||||
}
|
||||
if (!hasSavedSidebarPref.value) applyAutoCollapse();
|
||||
if (typeof window !== "undefined") {
|
||||
isMobile.value = window.innerWidth < 1024;
|
||||
if (!isMobile.value) mobileSidebarOpen.value = false; // close drawer when switching to desktop
|
||||
}
|
||||
if (!hasSavedSidebarPref.value) applyAutoCollapse();
|
||||
}
|
||||
onMounted(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('sidebarCollapsed');
|
||||
if (saved !== null) {
|
||||
hasSavedSidebarPref.value = true;
|
||||
sidebarCollapsed.value = saved === '1';
|
||||
} else {
|
||||
applyAutoCollapse();
|
||||
}
|
||||
} catch {}
|
||||
window.addEventListener('resize', handleResize);
|
||||
try {
|
||||
const saved = localStorage.getItem("sidebarCollapsed");
|
||||
if (saved !== null) {
|
||||
hasSavedSidebarPref.value = true;
|
||||
sidebarCollapsed.value = saved === "1";
|
||||
} else {
|
||||
applyAutoCollapse();
|
||||
}
|
||||
} catch {}
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
onUnmounted(() => window.removeEventListener('resize', handleResize));
|
||||
onUnmounted(() => window.removeEventListener("resize", handleResize));
|
||||
watch(sidebarCollapsed, (v) => {
|
||||
if (!hasSavedSidebarPref.value) return; // don't persist auto behavior
|
||||
try { localStorage.setItem('sidebarCollapsed', v ? '1' : '0'); } catch {}
|
||||
if (!hasSavedSidebarPref.value) return; // don't persist auto behavior
|
||||
try {
|
||||
localStorage.setItem("sidebarCollapsed", v ? "1" : "0");
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Global search modal state
|
||||
|
|
@ -58,265 +60,541 @@ const closeSearch = () => (searchOpen.value = false);
|
|||
|
||||
// Keyboard shortcut: Ctrl+K / Cmd+K to open search
|
||||
function onKeydown(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
openSearch();
|
||||
}
|
||||
if (e.key === 'Escape' && mobileSidebarOpen.value) {
|
||||
mobileSidebarOpen.value = false;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
openSearch();
|
||||
}
|
||||
if (e.key === "Escape" && mobileSidebarOpen.value) {
|
||||
mobileSidebarOpen.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown));
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKeydown));
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
function toggleSidebar() {
|
||||
hasSavedSidebarPref.value = true; // user explicitly chose
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
hasSavedSidebarPref.value = true; // user explicitly chose
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
}
|
||||
|
||||
function toggleMobileSidebar() {
|
||||
mobileSidebarOpen.value = !mobileSidebarOpen.value;
|
||||
mobileSidebarOpen.value = !mobileSidebarOpen.value;
|
||||
}
|
||||
|
||||
function handleSidebarToggleClick() {
|
||||
if (isMobile.value) toggleMobileSidebar();
|
||||
else toggleSidebar();
|
||||
if (isMobile.value) toggleMobileSidebar();
|
||||
else toggleSidebar();
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
router.post(route('logout'));
|
||||
router.post(route("logout"));
|
||||
};
|
||||
|
||||
// Flash toast notifications
|
||||
const page = usePage();
|
||||
const flash = computed(() => page.props.flash || {});
|
||||
const showToast = ref(false);
|
||||
const toastMessage = ref('');
|
||||
const toastType = ref('success');
|
||||
const toastMessage = ref("");
|
||||
const toastType = ref("success");
|
||||
watch(
|
||||
() => [flash.value.success, flash.value.error, flash.value.warning, flash.value.info],
|
||||
([s, e, w, i]) => {
|
||||
const message = s || e || w || i;
|
||||
const type = s ? 'success' : e ? 'error' : w ? 'warning' : i ? 'info' : null;
|
||||
if (message && type) {
|
||||
toastMessage.value = message;
|
||||
toastType.value = type;
|
||||
showToast.value = true;
|
||||
// auto-hide after 3s
|
||||
setTimeout(() => (showToast.value = false), 3000);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
() => [flash.value.success, flash.value.error, flash.value.warning, flash.value.info],
|
||||
([s, e, w, i]) => {
|
||||
const message = s || e || w || i;
|
||||
const type = s ? "success" : e ? "error" : w ? "warning" : i ? "info" : null;
|
||||
if (message && type) {
|
||||
toastMessage.value = message;
|
||||
toastType.value = type;
|
||||
showToast.value = true;
|
||||
// auto-hide after 3s
|
||||
setTimeout(() => (showToast.value = false), 3000);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// No automatic daily notifications
|
||||
|
||||
// Sidebar menu groups (sorted alphabetically within each group)
|
||||
const rawMenuGroups = [
|
||||
{
|
||||
label: "Glavno",
|
||||
items: [
|
||||
{
|
||||
key: "dashboard",
|
||||
title: "Nadzorna plošča",
|
||||
routeName: "dashboard",
|
||||
active: ["dashboard"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Stranke",
|
||||
items: [
|
||||
{
|
||||
key: "clients",
|
||||
title: "Naročniki",
|
||||
routeName: "client",
|
||||
active: ["client", "client.*"],
|
||||
},
|
||||
{
|
||||
key: "cases",
|
||||
title: "Primeri",
|
||||
routeName: "clientCase",
|
||||
active: ["clientCase", "clientCase.*"],
|
||||
},
|
||||
{
|
||||
key: "segments",
|
||||
title: "Segmenti",
|
||||
routeName: "segments.index",
|
||||
active: ["segments.index"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Uvoz",
|
||||
items: [
|
||||
{
|
||||
key: "imports",
|
||||
title: "Uvozi",
|
||||
routeName: "imports.index",
|
||||
active: ["imports.index", "imports.*"],
|
||||
},
|
||||
{
|
||||
key: "import-templates",
|
||||
title: "Uvozne predloge",
|
||||
routeName: "importTemplates.index",
|
||||
active: ["importTemplates.index"],
|
||||
},
|
||||
{
|
||||
key: "import-templates-new",
|
||||
title: "Nova uvozna predloga",
|
||||
routeName: "importTemplates.create",
|
||||
active: ["importTemplates.create"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Terensko",
|
||||
items: [
|
||||
{
|
||||
key: "fieldjobs",
|
||||
title: "Terenske naloge",
|
||||
routeName: "fieldjobs.index",
|
||||
active: ["fieldjobs.index"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Konfiguracija",
|
||||
items: [
|
||||
{
|
||||
key: "settings",
|
||||
title: "Nastavitve",
|
||||
routeName: "settings",
|
||||
active: ["settings", "settings.*"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const menuGroups = computed(() => {
|
||||
return rawMenuGroups.map((g) => ({
|
||||
label: g.label,
|
||||
items: [...g.items].sort((a, b) =>
|
||||
a.title.localeCompare(b.title, "sl", { sensitivity: "base" })
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
function isActive(patterns) {
|
||||
try {
|
||||
return patterns?.some((p) => route().current(p));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Head :title="title" />
|
||||
<div>
|
||||
<Head :title="title" />
|
||||
|
||||
<Banner />
|
||||
<Banner />
|
||||
|
||||
<div class="min-h-screen bg-gray-100 flex">
|
||||
<!-- Mobile backdrop -->
|
||||
<div v-if="isMobile && mobileSidebarOpen" class="fixed inset-0 z-40 bg-black/30" @click="mobileSidebarOpen=false"></div>
|
||||
<div class="min-h-screen bg-gray-100 flex">
|
||||
<!-- Mobile backdrop -->
|
||||
<div
|
||||
v-if="isMobile && mobileSidebarOpen"
|
||||
class="fixed inset-0 z-40 bg-black/30"
|
||||
@click="mobileSidebarOpen = false"
|
||||
></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<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; 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">
|
||||
<ApplicationMark class="h-8 w-auto" />
|
||||
<span v-if="!sidebarCollapsed" class="text-sm font-semibold">Teren</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav class="py-4">
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<Link :href="route('dashboard')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('dashboard') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nadzorna plošča">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.125 1.125 0 011.592 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.5c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v4.5h4.125c.621 0 1.125-.504 1.125-1.125V9.75" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed">Nadzorna plošča</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link :href="route('segments.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('segments.index') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Segmenti">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 3h7v7H3V3zm11 0h7v7h-7V3zM3 14h7v7H3v-7zm11 0h7v7h-7v-7z" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed">Segmenti</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link :href="route('client')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('client') || route().current('client.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Naročniki">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 003.745-.479 3.375 3.375 0 00-6.49-1.072M15 19.128V18a4.5 4.5 0 00-4.5-4.5H8.25A4.5 4.5 0 003.75 18v1.128M15 19.128V21m0-1.872V21M6.75 7.5a3 3 0 116 0 3 3 0 01-6 0z" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed">Naročniki</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link :href="route('clientCase')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('clientCase') || route().current('clientCase.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Primeri">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-6a2.25 2.25 0 00-2.25-2.25H8.25A2.25 2.25 0 006 8.25v7.5A2.25 2.25 0 008.25 18h9a2.25 2.25 0 002.25-2.25z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9h6m-6 3h6m-6 3h3" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed">Primeri</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link :href="route('imports.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', (route().current('imports.index') || route().current('imports.*')) ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Uvozi">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 10.5L12 6l4.5 4.5M12 6v12" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed">Uvozi</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link :href="route('importTemplates.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('importTemplates.index') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Uvozne predloge">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.5h5.25l1.5 2.25H20.25A1.5 1.5 0 0121.75 8.25v9A2.25 2.25 0 0119.5 19.5H4.5A2.25 2.25 0 012.25 17.25V6A1.5 1.5 0 013.75 4.5z" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed">Uvozne predloge</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link :href="route('importTemplates.create')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('importTemplates.create') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nova uvozna predloga">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed">Nova uvozna predloga</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link :href="route('fieldjobs.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('fieldjobs.index') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Terenske naloge">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.5-7.5 10.5-7.5 10.5S4.5 18 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed">Terenske naloge</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link :href="route('settings')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('settings') || route().current('settings.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nastavitve">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93l.8.334c.486.203.682.78.4 1.223l-.5.805c-.214.343-.17.784.108 1.09l.596.654c.36.395.37 1.002.024 1.41l-.657.76c-.285.33-.347.79-.158 1.182l.3.65c.216.468-.02 1.02-.507 1.21l-.89.345c-.4.155-.68.52-.74.94l-.12.89c-.08.55-.54.96-1.09.96h-1.09c-.55 0-1.01-.41-1.09-.96l-.12-.89c-.06-.42-.34-.785-.74-.94l-.89-.345c-.49-.19-.72-.74-.507-1.21l.3-.65c.19-.392.127-.852-.158-1.182l-.657-.76a1.125 1.125 0 01.033-1.58l.596-.654c.278-.306.322-.747.108-1.09l-.5-.805c-.282-.443-.086-1.02.4-1.223l.8-.334c.396-.166.71-.506.78-.93l.149-.894zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed">Nastavitve</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<!-- Sidebar -->
|
||||
<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; 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">
|
||||
<ApplicationMark class="h-8 w-auto" />
|
||||
<span v-if="!sidebarCollapsed" class="text-sm font-semibold">Teren</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav class="py-4">
|
||||
<ul class="space-y-3">
|
||||
<li v-for="group in menuGroups" :key="group.label">
|
||||
<div
|
||||
v-if="!sidebarCollapsed"
|
||||
class="px-4 py-1 text-[11px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="item in group.items" :key="item.key">
|
||||
<Link
|
||||
:href="route(item.routeName)"
|
||||
:class="[
|
||||
'flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100',
|
||||
isActive(item.active)
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-600',
|
||||
]"
|
||||
:title="item.title"
|
||||
>
|
||||
<!-- Icons -->
|
||||
<template v-if="item.key === 'dashboard'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 12l8.954-8.955a1.125 1.125 0 011.592 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.5c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v4.5h4.125c.621 0 1.125-.504 1.125-1.125V9.75"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'segments'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 3h7v7H3V3zm11 0h7v7h-7V3zM3 14h7v7H3v-7zm11 0h7v7h-7v-7z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'clients'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 003.745-.479 3.375 3.375 0 00-6.49-1.072M15 19.128V18a4.5 4.5 0 00-4.5-4.5H8.25A4.5 4.5 0 003.75 18v1.128M15 19.128V21m0-1.872V21M6.75 7.5a3 3 0 116 0 3 3 0 01-6 0z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'cases'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-6a2.25 2.25 0 00-2.25-2.25H8.25A2.25 2.25 0 006 8.25v7.5A2.25 2.25 0 008.25 18h9a2.25 2.25 0 002.25-2.25z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 9h6m-6 3h6m-6 3h3"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'imports'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 10.5L12 6l4.5 4.5M12 6v12"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'import-templates'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 4.5h5.25l1.5 2.25H20.25A1.5 1.5 0 0121.75 8.25v9A2.25 2.25 0 0119.5 19.5H4.5A2.25 2.25 0 012.25 17.25V6A1.5 1.5 0 013.75 4.5z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'import-templates-new'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'fieldjobs'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.5-7.5 10.5-7.5 10.5S4.5 18 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'settings'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93l.8.334c.486.203.682.78.4 1.223l-.5.805c-.214.343-.17.784.108 1.09l.596.654c.36.395.37 1.002.024 1.41l-.657.76c-.285.33-.347.79-.158 1.182l.3.65c.216.468-.02 1.02-.507 1.21l-.89.345c-.4.155-.68.52-.74.94l-.12.89c-.08.55-.54.96-1.09.96h-1.09c-.55 0-1.01-.41-1.09-.96l-.12-.89c-.06-.42-.34-.785-.74-.94l-.89-.345c-.49-.19-.72-.74-.507-1.21l.3-.65c.19-.392.127-.852-.158-1.182l-.657-.76a1.125 1.125 0 01-.033-1.58l.596-.654c.278-.306.322-.747.108-1.09l-.5-.805c-.282-.443-.086-1.02.4-1.223l.8-.334c.396-.166.71-.506.78-.93l.149-.894zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<!-- Title -->
|
||||
<span v-if="!sidebarCollapsed">{{ item.title }}</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 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 sticky top-0 z-30">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Sidebar toggle -->
|
||||
<button
|
||||
@click="handleSidebarToggleClick()"
|
||||
class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
:title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<!-- Hamburger (Bars) icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Search trigger -->
|
||||
<button @click="openSearch" class="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-md border border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Globalni iskalnik</span>
|
||||
<kbd class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border bg-gray-50">Ctrl K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Notifications + User drop menu --->
|
||||
<div class="flex items-center">
|
||||
<NotificationsBell class="mr-2" />
|
||||
<!-- Phone page quick access button -->
|
||||
<Link :href="route('phone.index')" class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 mr-2" title="Phone">
|
||||
<FontAwesomeIcon :icon="faMobileScreenButton" class="h-5 w-5" />
|
||||
</Link>
|
||||
<div class="ms-3 relative">
|
||||
<Dropdown align="right" width="48">
|
||||
<template #trigger>
|
||||
<button v-if="$page.props.jetstream.managesProfilePhotos" class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
|
||||
<img class="h-8 w-8 rounded-full object-cover" :src="$page.props.auth.user.profile_photo_url" :alt="$page.props.auth.user.name">
|
||||
</button>
|
||||
<!-- 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 sticky top-0 z-30"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Sidebar toggle -->
|
||||
<button
|
||||
@click="handleSidebarToggleClick()"
|
||||
class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
:title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<!-- Hamburger (Bars) icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Search trigger -->
|
||||
<button
|
||||
@click="openSearch"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-md border border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Globalni iskalnik</span>
|
||||
<kbd
|
||||
class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border bg-gray-50"
|
||||
>Ctrl K</kbd
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Notifications + User drop menu --->
|
||||
<div class="flex items-center">
|
||||
<NotificationsBell class="mr-2" />
|
||||
<!-- Phone page quick access button -->
|
||||
<Link
|
||||
:href="route('phone.index')"
|
||||
class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 mr-2"
|
||||
title="Phone"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faMobileScreenButton" class="h-5 w-5" />
|
||||
</Link>
|
||||
<div class="ms-3 relative">
|
||||
<Dropdown align="right" width="48">
|
||||
<template #trigger>
|
||||
<button
|
||||
v-if="$page.props.jetstream.managesProfilePhotos"
|
||||
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition"
|
||||
>
|
||||
<img
|
||||
class="h-8 w-8 rounded-full object-cover"
|
||||
:src="$page.props.auth.user.profile_photo_url"
|
||||
:alt="$page.props.auth.user.name"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span v-else class="inline-flex rounded-md">
|
||||
<button type="button" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150">
|
||||
{{ $page.props.auth.user.name }}
|
||||
<svg class="ms-2 -me-0.5 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="inline-flex rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150"
|
||||
>
|
||||
{{ $page.props.auth.user.name }}
|
||||
<svg
|
||||
class="ms-2 -me-0.5 h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="block px-4 py-2 text-xs text-gray-400">Nastavitve računa</div>
|
||||
<template #content>
|
||||
<div class="block px-4 py-2 text-xs text-gray-400">
|
||||
Nastavitve računa
|
||||
</div>
|
||||
|
||||
<DropdownLink :href="route('profile.show')">Profil</DropdownLink>
|
||||
<DropdownLink v-if="$page.props.jetstream.hasApiFeatures" :href="route('api-tokens.index')">API Tokens</DropdownLink>
|
||||
<DropdownLink :href="route('profile.show')">Profil</DropdownLink>
|
||||
<DropdownLink
|
||||
v-if="$page.props.jetstream.hasApiFeatures"
|
||||
:href="route('api-tokens.index')"
|
||||
>API Tokens</DropdownLink
|
||||
>
|
||||
|
||||
<div class="border-t border-gray-200" />
|
||||
<div class="border-t border-gray-200" />
|
||||
|
||||
<form @submit.prevent="logout">
|
||||
<DropdownLink as="button">Izpis</DropdownLink>
|
||||
</form>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page Heading -->
|
||||
<header v-if="$slots.header" class="bg-white border-b shadow-sm">
|
||||
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
|
||||
<Breadcrumbs v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length" :breadcrumbs="$page.props.breadcrumbs" />
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main class="p-4">
|
||||
<slot />
|
||||
</main>
|
||||
<form @submit.prevent="logout">
|
||||
<DropdownLink as="button">Izpis</DropdownLink>
|
||||
</form>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Search Modal -->
|
||||
<GlobalSearch :open="searchOpen" @update:open="(v)=>searchOpen=v" />
|
||||
<!-- Page Heading -->
|
||||
<header v-if="$slots.header" class="bg-white border-b shadow-sm">
|
||||
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
|
||||
<Breadcrumbs
|
||||
v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length"
|
||||
:breadcrumbs="$page.props.breadcrumbs"
|
||||
/>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Simple Toast -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="showToast"
|
||||
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-white"
|
||||
:class="{
|
||||
'bg-emerald-600': toastType==='success',
|
||||
'bg-red-600': toastType==='error',
|
||||
'bg-amber-500': toastType==='warning',
|
||||
'bg-blue-600': toastType==='info',
|
||||
}"
|
||||
>
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
</transition>
|
||||
<!-- Page Content -->
|
||||
<main class="p-4">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Search Modal -->
|
||||
<GlobalSearch :open="searchOpen" @update:open="(v) => (searchOpen = v)" />
|
||||
|
||||
<!-- Simple Toast -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="showToast"
|
||||
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-white"
|
||||
:class="{
|
||||
'bg-emerald-600': toastType === 'success',
|
||||
'bg-red-600': toastType === 'error',
|
||||
'bg-amber-500': toastType === 'warning',
|
||||
'bg-blue-600': toastType === 'info',
|
||||
}"
|
||||
>
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
@ -275,7 +255,7 @@ const deletePayment = (paymentId) => {
|
|||
<span class="text-gray-700">{{
|
||||
contractActiveSegment(c)?.name || "-"
|
||||
}}</span>
|
||||
<Dropdown width="64" align="left">
|
||||
<Dropdown align="left">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -286,8 +266,8 @@ const deletePayment = (paymentId) => {
|
|||
}"
|
||||
:title="
|
||||
segments && segments.length
|
||||
? 'Change segment'
|
||||
: 'No segments available for this case'
|
||||
? 'Spremeni segment'
|
||||
: 'Ni segmentov na voljo za ta primer'
|
||||
"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
|
|
@ -300,7 +280,7 @@ const deletePayment = (paymentId) => {
|
|||
<div class="py-1">
|
||||
<template v-if="segments && segments.length">
|
||||
<button
|
||||
v-for="s in segments"
|
||||
v-for="s in sortedSegments"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
|
||||
|
|
@ -315,7 +295,7 @@ const deletePayment = (paymentId) => {
|
|||
Ni segmentov v tem primeru. Dodaj in nastavi segment:
|
||||
</div>
|
||||
<button
|
||||
v-for="s in all_segments"
|
||||
v-for="s in sortedAllSegments"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
|
||||
|
|
@ -326,7 +306,7 @@ const deletePayment = (paymentId) => {
|
|||
</template>
|
||||
<template v-else>
|
||||
<div class="px-3 py-2 text-sm text-gray-500">
|
||||
No segments configured.
|
||||
Ni konfiguriranih segmentov.
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -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'
|
||||
"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faCircleInfo"
|
||||
|
|
@ -373,7 +357,7 @@ const deletePayment = (paymentId) => {
|
|||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
|
||||
|
||||
<!-- Promise date indicator -->
|
||||
<Dropdown width="64" align="left">
|
||||
<template #trigger>
|
||||
|
|
@ -431,7 +415,7 @@ const deletePayment = (paymentId) => {
|
|||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-5 w-5 rounded-full hover:bg-gray-100 focus:outline-none"
|
||||
:title="'Actions'"
|
||||
:title="'Dejanja'"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faEllipsisVertical"
|
||||
|
|
@ -440,6 +424,12 @@ const deletePayment = (paymentId) => {
|
|||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<!-- Urejanje -->
|
||||
<div
|
||||
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
Urejanje
|
||||
</div>
|
||||
<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"
|
||||
|
|
@ -449,15 +439,31 @@ const deletePayment = (paymentId) => {
|
|||
:icon="faPenToSquare"
|
||||
class="h-4 w-4 text-gray-600"
|
||||
/>
|
||||
<span>Edit</span>
|
||||
<span>Uredi</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="onAddActivity(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faListCheck" class="h-4 w-4 text-gray-600" />
|
||||
<span>Dodaj aktivnost</span>
|
||||
</button>
|
||||
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
<!-- Predmeti -->
|
||||
<div
|
||||
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
Predmeti
|
||||
</div>
|
||||
<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>
|
||||
<span>Seznam predmetov</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -465,35 +471,24 @@ const deletePayment = (paymentId) => {
|
|||
@click="openObjectDialog(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPlus" 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-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>
|
||||
<span>Dodaj predmet</span>
|
||||
</button>
|
||||
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
<!-- Plačila -->
|
||||
<div
|
||||
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
Plačila
|
||||
</div>
|
||||
<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="openPaymentsDialog(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
|
||||
<span>Plačila</span>
|
||||
<span>Pokaži plačila</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>
|
||||
<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"
|
||||
|
|
@ -502,6 +497,17 @@ const deletePayment = (paymentId) => {
|
|||
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" />
|
||||
<span>Dodaj plačilo</span>
|
||||
</button>
|
||||
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
<!-- Destruktivno -->
|
||||
<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>Izbriši</span>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</FwbTableCell>
|
||||
|
|
@ -556,54 +562,16 @@ const deletePayment = (paymentId) => {
|
|||
:contract="selectedContract"
|
||||
/>
|
||||
|
||||
<PaymentDialog :show="showPaymentDialog" :form="paymentForm" @close="closePaymentDialog" @submit="submitPayment" />
|
||||
<PaymentDialog
|
||||
:show="showPaymentDialog"
|
||||
:form="paymentForm"
|
||||
@close="closePaymentDialog"
|
||||
@submit="submitPayment"
|
||||
/>
|
||||
|
||||
<!-- View Payments Dialog -->
|
||||
<div v-if="showPaymentsDialog" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div class="bg-white rounded-lg shadow-lg p-4 w-full max-w-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-base font-medium text-gray-800">
|
||||
Plačila za pogodbo
|
||||
<span class="text-gray-600">{{ selectedContract?.reference }}</span>
|
||||
</div>
|
||||
<button type="button" class="text-sm text-gray-500 hover:text-gray-700" @click="closePaymentsDialog">Zapri</button>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div v-if="paymentsLoading" class="text-sm text-gray-500">Nalaganje…</div>
|
||||
<template v-else>
|
||||
<div v-if="paymentsForContract.length === 0" class="text-sm text-gray-500">Ni plačil.</div>
|
||||
<div v-else class="divide-y divide-gray-100 border rounded">
|
||||
<div v-for="p in paymentsForContract" :key="p.id" class="px-3 py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-gray-800">
|
||||
{{
|
||||
Intl.NumberFormat('de-DE', { style: 'currency', currency: p.currency || 'EUR' }).format(p.amount ?? 0)
|
||||
}}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<span>{{ formatDate(p.paid_at) }}</span>
|
||||
<span v-if="p.reference" class="ml-2">Sklic: {{ p.reference }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded text-red-700 hover:bg-red-50"
|
||||
@click="deletePayment(p.id)"
|
||||
title="Izbriši plačilo"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4 text-red-600" />
|
||||
<span class="text-sm">Briši</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button type="button" class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="loadPayments">Osveži</button>
|
||||
<button type="button" class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" @click="closePaymentsDialog">Zapri</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ViewPaymentsDialog
|
||||
:show="showPaymentsDialog"
|
||||
:contract="selectedContract"
|
||||
@close="closePaymentsDialog"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
152
resources/js/Pages/Cases/Partials/PaymentsDialog.vue
Normal file
152
resources/js/Pages/Cases/Partials/PaymentsDialog.vue
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<script setup>
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { ref, watch, computed } from "vue";
|
||||
import axios from "axios";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
contract: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const payments = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const accountId = computed(() => props.contract?.account?.id ?? null);
|
||||
const contractRef = computed(() => props.contract?.reference || "—");
|
||||
|
||||
async function loadPayments() {
|
||||
if (!accountId.value) {
|
||||
payments.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
route("accounts.payments.list", { account: accountId.value })
|
||||
);
|
||||
payments.value = data.payments || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return "—";
|
||||
const dt = new Date(d);
|
||||
return isNaN(dt.getTime()) ? "—" : dt.toLocaleDateString("de");
|
||||
}
|
||||
|
||||
function fmtMoney(amount, currency = "EUR") {
|
||||
const num = typeof amount === "string" ? Number(amount) : amount;
|
||||
return new Intl.NumberFormat("de-DE", { style: "currency", currency }).format(num ?? 0);
|
||||
}
|
||||
|
||||
async function deletePayment(paymentId) {
|
||||
if (!accountId.value) return;
|
||||
await router.delete(
|
||||
route("accounts.payments.destroy", { account: accountId.value, payment: paymentId }),
|
||||
{
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
only: ["contracts", "activities"],
|
||||
onSuccess: async () => {
|
||||
await loadPayments();
|
||||
},
|
||||
onError: async () => {
|
||||
await loadPayments();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (visible) => {
|
||||
if (visible) {
|
||||
await loadPayments();
|
||||
} else {
|
||||
payments.value = [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.contract?.account?.id,
|
||||
async () => {
|
||||
if (props.show) {
|
||||
await loadPayments();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
<template #title>
|
||||
Plačila za pogodbo <span class="text-gray-600">{{ contractRef }}</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div>
|
||||
<div v-if="loading" class="text-sm text-gray-500">Nalaganje…</div>
|
||||
<template v-else>
|
||||
<div v-if="payments.length === 0" class="text-sm text-gray-500">Ni plačil.</div>
|
||||
<div v-else class="divide-y divide-gray-100 border rounded">
|
||||
<div
|
||||
v-for="p in payments"
|
||||
:key="p.id"
|
||||
class="px-3 py-2 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm text-gray-800">
|
||||
{{ fmtMoney(p.amount, p.currency || "EUR") }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<span>{{ formatDate(p.paid_at) }}</span>
|
||||
<span v-if="p.reference" class="ml-2">Sklic: {{ p.reference }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded text-red-700 hover:bg-red-50"
|
||||
@click="deletePayment(p.id)"
|
||||
title="Izbriši plačilo"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4 text-red-600" />
|
||||
<span class="text-sm">Briši</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
||||
@click="loadPayments"
|
||||
>
|
||||
Osveži
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
@click="close"
|
||||
>
|
||||
Zapri
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
141
resources/js/Pages/Cases/Partials/ViewPaymentsDialog.vue
Normal file
141
resources/js/Pages/Cases/Partials/ViewPaymentsDialog.vue
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<script setup>
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
contract: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const payments = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const contractRef = computed(() => props.contract?.reference || "—");
|
||||
const accountId = computed(() => props.contract?.account?.id || null);
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return "-";
|
||||
const dt = new Date(d);
|
||||
return isNaN(dt.getTime()) ? "-" : dt.toLocaleDateString("de");
|
||||
}
|
||||
|
||||
async function loadPayments() {
|
||||
if (!accountId.value) {
|
||||
payments.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
route("accounts.payments.list", { account: accountId.value })
|
||||
);
|
||||
payments.value = data.payments || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("close");
|
||||
payments.value = [];
|
||||
}
|
||||
|
||||
function deletePayment(paymentId) {
|
||||
if (!accountId.value) return;
|
||||
router.delete(
|
||||
route("accounts.payments.destroy", { account: accountId.value, payment: paymentId }),
|
||||
{
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
only: ["contracts", "activities"],
|
||||
onSuccess: async () => {
|
||||
await loadPayments();
|
||||
},
|
||||
onError: async () => {
|
||||
await loadPayments();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (visible) => {
|
||||
if (visible) {
|
||||
await loadPayments();
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.contract?.account?.id,
|
||||
async () => {
|
||||
if (props.show) {
|
||||
await loadPayments();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
<template #title>
|
||||
Plačila za pogodbo <span class="text-gray-600">{{ contractRef }}</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div>
|
||||
<div v-if="loading" class="text-sm text-gray-500">Nalaganje…</div>
|
||||
<template v-else>
|
||||
<div v-if="payments.length === 0" class="text-sm text-gray-500">Ni plačil.</div>
|
||||
<div v-else class="divide-y divide-gray-100 border rounded">
|
||||
<div
|
||||
v-for="p in payments"
|
||||
:key="p.id"
|
||||
class="px-3 py-2 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm text-gray-800">
|
||||
{{
|
||||
Intl.NumberFormat('de-DE', { style: 'currency', currency: p.currency || 'EUR' }).format(p.amount ?? 0)
|
||||
}}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<span>{{ formatDate(p.paid_at) }}</span>
|
||||
<span v-if="p.reference" class="ml-2">Sklic: {{ p.reference }}</span>
|
||||
<span v-if="p.balance_before !== undefined" class="ml-2">
|
||||
Stanje pred: {{
|
||||
Intl.NumberFormat('de-DE', { style: 'currency', currency: p.currency || 'EUR' }).format(p.balance_before ?? 0)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded text-red-700 hover:bg-red-50"
|
||||
@click="deletePayment(p.id)"
|
||||
title="Izbriši plačilo"
|
||||
>
|
||||
<span class="text-sm">Briši</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<button type="button" class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="loadPayments">
|
||||
Osveži
|
||||
</button>
|
||||
<button type="button" class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" @click="close">
|
||||
Zapri
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { ref, watch, computed, onMounted } from 'vue';
|
||||
import { useForm, router } from '@inertiajs/vue3';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
import axios from 'axios';
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { ref, watch, computed, onMounted } from "vue";
|
||||
import { useForm, router } from "@inertiajs/vue3";
|
||||
import Multiselect from "vue-multiselect";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
templates: Array,
|
||||
|
|
@ -11,7 +11,7 @@ const props = defineProps({
|
|||
});
|
||||
|
||||
const hasHeader = ref(true);
|
||||
const detected = ref({ columns: [], delimiter: ',', has_header: true });
|
||||
const detected = ref({ columns: [], delimiter: ",", has_header: true });
|
||||
const importId = ref(null);
|
||||
const templateApplied = ref(false);
|
||||
const processing = ref(false);
|
||||
|
|
@ -19,15 +19,28 @@ const processResult = ref(null);
|
|||
const mappingRows = ref([]);
|
||||
const mappingSaved = ref(false);
|
||||
const mappingSavedCount = ref(0);
|
||||
const selectedMappingsCount = computed(() => mappingRows.value.filter(r => !r.skip && r.entity && r.field).length);
|
||||
const mappingError = ref('');
|
||||
const selectedMappingsCount = computed(
|
||||
() => mappingRows.value.filter((r) => !r.skip && r.entity && r.field).length
|
||||
);
|
||||
const mappingError = ref("");
|
||||
const savingMappings = ref(false);
|
||||
|
||||
// Dynamic entity definitions and suggestions from API
|
||||
const entityDefs = ref([]);
|
||||
const entityOptions = computed(() => entityDefs.value.map(e => ({ value: e.key, label: e.label || e.key })));
|
||||
const fieldOptionsByEntity = computed(() => Object.fromEntries(entityDefs.value.map(e => [e.key, (e.fields || []).map(f => ({ value: f, label: f }))])));
|
||||
const canonicalRootByKey = computed(() => Object.fromEntries(entityDefs.value.map(e => [e.key, e.canonical_root || e.key])));
|
||||
const entityOptions = computed(() =>
|
||||
entityDefs.value.map((e) => ({ value: e.key, label: e.label || e.key }))
|
||||
);
|
||||
const fieldOptionsByEntity = computed(() =>
|
||||
Object.fromEntries(
|
||||
entityDefs.value.map((e) => [
|
||||
e.key,
|
||||
(e.fields || []).map((f) => ({ value: f, label: f })),
|
||||
])
|
||||
)
|
||||
);
|
||||
const canonicalRootByKey = computed(() =>
|
||||
Object.fromEntries(entityDefs.value.map((e) => [e.key, e.canonical_root || e.key]))
|
||||
);
|
||||
const keyByCanonicalRoot = computed(() => {
|
||||
const m = {};
|
||||
for (const e of entityDefs.value) {
|
||||
|
|
@ -40,20 +53,24 @@ const keyByCanonicalRoot = computed(() => {
|
|||
const suggestions = ref({});
|
||||
async function loadEntityDefs() {
|
||||
try {
|
||||
const { data } = await axios.get('/api/import-entities');
|
||||
const { data } = await axios.get("/api/import-entities");
|
||||
entityDefs.value = data?.entities || [];
|
||||
} catch (e) {
|
||||
console.error('Failed to load import entity definitions', e);
|
||||
console.error("Failed to load import entity definitions", e);
|
||||
}
|
||||
}
|
||||
async function refreshSuggestions(columns) {
|
||||
const cols = Array.isArray(columns) ? columns : (detected.value.columns || []);
|
||||
if (!cols || cols.length === 0) { return; }
|
||||
const cols = Array.isArray(columns) ? columns : detected.value.columns || [];
|
||||
if (!cols || cols.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await axios.post('/api/import-entities/suggest', { columns: cols });
|
||||
// When a template is chosen and provides meta.entities, limit suggestions to those entities
|
||||
const only = (selectedTemplate.value?.meta?.entities || []);
|
||||
const { data } = await axios.post("/api/import-entities/suggest", { columns: cols, only_entities: only });
|
||||
suggestions.value = { ...suggestions.value, ...(data?.suggestions || {}) };
|
||||
} catch (e) {
|
||||
console.error('Failed to load suggestions', e);
|
||||
console.error("Failed to load suggestions", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,8 +81,12 @@ function applySuggestionToRow(row) {
|
|||
row.entity = s.entity;
|
||||
row.field = s.field;
|
||||
// default transform on if missing
|
||||
if (!row.transform) { row.transform = 'trim'; }
|
||||
if (!row.apply_mode) { row.apply_mode = 'both'; }
|
||||
if (!row.transform) {
|
||||
row.transform = "trim";
|
||||
}
|
||||
if (!row.apply_mode) {
|
||||
row.apply_mode = "both";
|
||||
}
|
||||
row.skip = false;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -84,11 +105,11 @@ const selectedClientOption = computed({
|
|||
get() {
|
||||
const cuuid = form.client_uuid;
|
||||
if (!cuuid) return null;
|
||||
return (props.clients || []).find(c => c.uuid === cuuid) || null;
|
||||
return (props.clients || []).find((c) => c.uuid === cuuid) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.client_uuid = val ? val.uuid : null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Bridge Template Multiselect to store only template id (number) in form
|
||||
|
|
@ -96,18 +117,18 @@ const selectedTemplateOption = computed({
|
|||
get() {
|
||||
const tid = form.import_template_id;
|
||||
if (tid == null) return null;
|
||||
return (props.templates || []).find(t => t.id === tid) || null;
|
||||
return (props.templates || []).find((t) => t.id === tid) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.import_template_id = val ? val.id : null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Helper: selected client's numeric id (fallback)
|
||||
const selectedClientId = computed(() => {
|
||||
const cuuid = form.client_uuid;
|
||||
if (!cuuid) return null;
|
||||
const c = (props.clients || []).find(x => x.uuid === cuuid);
|
||||
const c = (props.clients || []).find((x) => x.uuid === cuuid);
|
||||
return c ? c.id : null;
|
||||
});
|
||||
|
||||
|
|
@ -117,10 +138,12 @@ const filteredTemplates = computed(() => {
|
|||
const cuuid = form.client_uuid;
|
||||
const list = props.templates || [];
|
||||
if (!cuuid) {
|
||||
return list.filter(t => t.client_id == null);
|
||||
return list.filter((t) => t.client_id == null);
|
||||
}
|
||||
// When client is selected, only show that client's templates (no globals)
|
||||
return list.filter(t => t.client_uuid && t.client_uuid === cuuid);
|
||||
return list.filter(
|
||||
(t) => (t.client_uuid && t.client_uuid === cuuid) || t.client_id == null
|
||||
);
|
||||
});
|
||||
|
||||
function onFileChange(e) {
|
||||
|
|
@ -131,7 +154,7 @@ function onFileChange(e) {
|
|||
}
|
||||
|
||||
async function submitUpload() {
|
||||
await form.post(route('imports.store'), {
|
||||
await form.post(route("imports.store"), {
|
||||
forceFormData: true,
|
||||
onSuccess: (res) => {
|
||||
const data = res?.props || {};
|
||||
|
|
@ -141,29 +164,31 @@ async function submitUpload() {
|
|||
if (!form.recentlySuccessful) return;
|
||||
// Inertia doesn't expose JSON response directly with useForm; fallback to API call using fetch
|
||||
const fd = new FormData();
|
||||
fd.append('file', form.file);
|
||||
fd.append("file", form.file);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchColumns() {
|
||||
if (!importId.value) return;
|
||||
const url = route('imports.columns', { import: importId.value });
|
||||
const { data } = await axios.get(url, { params: { has_header: hasHeader.value ? 1 : 0 } });
|
||||
const url = route("imports.columns", { import: importId.value });
|
||||
const { data } = await axios.get(url, {
|
||||
params: { has_header: hasHeader.value ? 1 : 0 },
|
||||
});
|
||||
detected.value = {
|
||||
columns: data.columns || [],
|
||||
delimiter: data.detected_delimiter || ',',
|
||||
delimiter: data.detected_delimiter || ",",
|
||||
has_header: !!data.has_header,
|
||||
};
|
||||
// initialize simple mapping rows with defaults if none exist
|
||||
if (!mappingRows.value.length) {
|
||||
mappingRows.value = (detected.value.columns || []).map((c, idx) => ({
|
||||
source_column: c,
|
||||
entity: '',
|
||||
field: '',
|
||||
entity: "",
|
||||
field: "",
|
||||
skip: false,
|
||||
transform: 'trim',
|
||||
apply_mode: 'both',
|
||||
transform: "trim",
|
||||
apply_mode: "both",
|
||||
position: idx,
|
||||
}));
|
||||
}
|
||||
|
|
@ -180,22 +205,26 @@ async function uploadAndPreview() {
|
|||
templateApplied.value = false;
|
||||
processResult.value = null;
|
||||
const fd = new window.FormData();
|
||||
fd.append('file', form.file);
|
||||
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));
|
||||
fd.append("file", form.file);
|
||||
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) {
|
||||
fd.append('client_uuid', String(form.client_uuid));
|
||||
fd.append("client_uuid", String(form.client_uuid));
|
||||
}
|
||||
fd.append('has_header', hasHeader.value ? '1' : '0');
|
||||
fd.append("has_header", hasHeader.value ? "1" : "0");
|
||||
try {
|
||||
const { data } = await axios.post(route('imports.store'), fd, {
|
||||
headers: { Accept: 'application/json' },
|
||||
const { data } = await axios.post(route("imports.store"), fd, {
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
});
|
||||
// Redirect immediately to the continue page for this import
|
||||
if (data?.uuid) {
|
||||
router.visit(route('imports.continue', { import: data.uuid }));
|
||||
router.visit(route("imports.continue", { import: data.uuid }));
|
||||
} else if (data?.id) {
|
||||
// Fallback: if uuid not returned for some reason, fetch columns here (legacy)
|
||||
importId.value = data.id;
|
||||
|
|
@ -203,12 +232,12 @@ async function uploadAndPreview() {
|
|||
}
|
||||
} catch (e) {
|
||||
if (e.response) {
|
||||
console.error('Upload error', e.response.status, e.response.data);
|
||||
console.error("Upload error", e.response.status, e.response.data);
|
||||
if (e.response.data?.errors) {
|
||||
// Optionally you could surface errors in the UI; for now, log for visibility
|
||||
}
|
||||
} else {
|
||||
console.error('Upload error', e);
|
||||
console.error("Upload error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -219,19 +248,26 @@ async function uploadAndPreview() {
|
|||
async function applyTemplateToImport() {
|
||||
if (!importId.value || !form.import_template_id) return;
|
||||
try {
|
||||
await axios.post(route('importTemplates.apply', { template: form.import_template_id, import: importId.value }), {}, {
|
||||
headers: { Accept: 'application/json' },
|
||||
withCredentials: true,
|
||||
});
|
||||
await axios.post(
|
||||
route("importTemplates.apply", {
|
||||
template: form.import_template_id,
|
||||
import: importId.value,
|
||||
}),
|
||||
{},
|
||||
{
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
templateApplied.value = true;
|
||||
// Load mappings and auto-assign UI rows
|
||||
await loadImportMappings();
|
||||
} catch (e) {
|
||||
templateApplied.value = false;
|
||||
if (e.response) {
|
||||
console.error('Apply template error', e.response.status, e.response.data);
|
||||
console.error("Apply template error", e.response.status, e.response.data);
|
||||
} else {
|
||||
console.error('Apply template error', e);
|
||||
console.error("Apply template error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -239,33 +275,40 @@ async function applyTemplateToImport() {
|
|||
async function loadImportMappings() {
|
||||
if (!importId.value) return;
|
||||
try {
|
||||
const { data } = await axios.get(route('imports.mappings.get', { import: importId.value }), {
|
||||
headers: { Accept: 'application/json' },
|
||||
withCredentials: true,
|
||||
});
|
||||
const { data } = await axios.get(
|
||||
route("imports.mappings.get", { import: importId.value }),
|
||||
{
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
const rows = Array.isArray(data?.mappings) ? data.mappings : [];
|
||||
if (!rows.length) return;
|
||||
// Build a lookup by source_column
|
||||
const bySource = new Map(rows.map(r => [r.source_column, r]));
|
||||
const bySource = new Map(rows.map((r) => [r.source_column, r]));
|
||||
// Update mappingRows (detected columns) to reflect applied mappings
|
||||
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
|
||||
const m = bySource.get(r.source_column);
|
||||
if (!m) return r;
|
||||
// Parse target_field like 'person.first_name' into UI entity/field
|
||||
const [record, field] = String(m.target_field || '').split('.', 2);
|
||||
const entity = keyByCanonicalRoot.value[record] || record;
|
||||
// Parse target_field like 'person.first_name' into UI entity/field
|
||||
const [record, field] = String(m.target_field || "").split(".", 2);
|
||||
const entity = keyByCanonicalRoot.value[record] || record;
|
||||
return {
|
||||
...r,
|
||||
entity,
|
||||
field: field || '',
|
||||
transform: m.transform || '',
|
||||
apply_mode: m.apply_mode || 'both',
|
||||
field: field || "",
|
||||
transform: m.transform || "",
|
||||
apply_mode: m.apply_mode || "both",
|
||||
skip: false,
|
||||
position: idx,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Load import mappings error', e.response?.status || '', e.response?.data || e);
|
||||
console.error(
|
||||
"Load import mappings error",
|
||||
e.response?.status || "",
|
||||
e.response?.data || e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -274,18 +317,22 @@ async function processImport() {
|
|||
processing.value = true;
|
||||
processResult.value = null;
|
||||
try {
|
||||
const { data } = await axios.post(route('imports.process', { import: importId.value }), {}, {
|
||||
headers: { Accept: 'application/json' },
|
||||
withCredentials: true,
|
||||
});
|
||||
const { data } = await axios.post(
|
||||
route("imports.process", { import: importId.value }),
|
||||
{},
|
||||
{
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
processResult.value = data;
|
||||
} catch (e) {
|
||||
if (e.response) {
|
||||
console.error('Process import error', e.response.status, e.response.data);
|
||||
processResult.value = { error: e.response.data || 'Processing failed' };
|
||||
console.error("Process import error", e.response.status, e.response.data);
|
||||
processResult.value = { error: e.response.data || "Processing failed" };
|
||||
} else {
|
||||
console.error('Process import error', e);
|
||||
processResult.value = { error: 'Processing failed' };
|
||||
console.error("Process import error", e);
|
||||
processResult.value = { error: "Processing failed" };
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
|
|
@ -296,41 +343,49 @@ async function processImport() {
|
|||
|
||||
async function saveMappings() {
|
||||
if (!importId.value) return;
|
||||
mappingError.value = '';
|
||||
mappingError.value = "";
|
||||
const mappings = mappingRows.value
|
||||
.filter(r => !r.skip && r.entity && r.field)
|
||||
.map(r => ({
|
||||
.filter((r) => !r.skip && r.entity && r.field)
|
||||
.map((r) => ({
|
||||
source_column: r.source_column,
|
||||
target_field: `${(canonicalRootByKey.value[r.entity] || r.entity)}.${r.field}`,
|
||||
target_field: `${canonicalRootByKey.value[r.entity] || r.entity}.${r.field}`,
|
||||
transform: r.transform || null,
|
||||
apply_mode: r.apply_mode || 'both',
|
||||
apply_mode: r.apply_mode || "both",
|
||||
options: null,
|
||||
}));
|
||||
if (!mappings.length) {
|
||||
mappingSaved.value = false;
|
||||
mappingError.value = 'Select entity and field for at least one column (or uncheck Skip) before saving.';
|
||||
mappingError.value =
|
||||
"Select entity and field for at least one column (or uncheck Skip) before saving.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
savingMappings.value = true;
|
||||
const url = (typeof route === 'function')
|
||||
? route('imports.mappings.save', { import: importId.value })
|
||||
: `/imports/${importId.value}/mappings`;
|
||||
const { data } = await axios.post(url, { mappings }, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
withCredentials: true,
|
||||
});
|
||||
const url =
|
||||
typeof route === "function"
|
||||
? route("imports.mappings.save", { import: importId.value })
|
||||
: `/imports/${importId.value}/mappings`;
|
||||
const { data } = await axios.post(
|
||||
url,
|
||||
{ mappings },
|
||||
{
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
mappingSaved.value = true;
|
||||
mappingSavedCount.value = Number(data?.saved || mappings.length);
|
||||
mappingError.value = '';
|
||||
mappingError.value = "";
|
||||
} catch (e) {
|
||||
mappingSaved.value = false;
|
||||
if (e.response) {
|
||||
console.error('Save mappings error', e.response.status, e.response.data);
|
||||
alert('Failed to save mappings: ' + (e.response.data?.message || e.response.status));
|
||||
console.error("Save mappings error", e.response.status, e.response.data);
|
||||
alert(
|
||||
"Failed to save mappings: " + (e.response.data?.message || e.response.status)
|
||||
);
|
||||
} else {
|
||||
console.error('Save mappings error', e);
|
||||
alert('Failed to save mappings. See console for details.');
|
||||
console.error("Save mappings error", e);
|
||||
alert("Failed to save mappings. See console for details.");
|
||||
}
|
||||
} finally {
|
||||
savingMappings.value = false;
|
||||
|
|
@ -338,16 +393,19 @@ async function saveMappings() {
|
|||
}
|
||||
|
||||
// Reset saved flag whenever user edits mappings
|
||||
watch(mappingRows, () => {
|
||||
mappingSaved.value = false;
|
||||
mappingSavedCount.value = 0;
|
||||
mappingError.value = '';
|
||||
}, { deep: true });
|
||||
watch(
|
||||
mappingRows,
|
||||
() => {
|
||||
mappingSaved.value = false;
|
||||
mappingSavedCount.value = 0;
|
||||
mappingError.value = "";
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEntityDefs();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -388,20 +446,32 @@ onMounted(async () => {
|
|||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ option.name }}</span>
|
||||
<span class="ml-2 text-xs text-gray-500">({{ option.source_type }})</span>
|
||||
<span class="ml-2 text-xs text-gray-500"
|
||||
>({{ option.source_type }})</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{ option.client_id ? 'Client' : 'Global' }}</span>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
||||
>{{ option.client_id ? "Client" : "Global" }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template #singleLabel="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ option.name }}</span>
|
||||
<span class="ml-1 text-xs text-gray-500">({{ option.source_type }})</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{ option.client_id ? 'Client' : 'Global' }}</span>
|
||||
<span class="ml-1 text-xs text-gray-500"
|
||||
>({{ option.source_type }})</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
||||
>{{ option.client_id ? "Client" : "Global" }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</Multiselect>
|
||||
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">Only global templates are shown until a client is selected.</p>
|
||||
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">
|
||||
Only global templates are shown until a client is selected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -411,19 +481,26 @@ onMounted(async () => {
|
|||
<input type="file" @change="onFileChange" class="mt-1 block w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Has header row</label>
|
||||
<label class="block text-sm font-medium text-gray-700"
|
||||
>Has header row</label
|
||||
>
|
||||
<input type="checkbox" v-model="hasHeader" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click.prevent="uploadAndPreview" class="px-4 py-2 bg-blue-600 text-white rounded">Upload & Preview Columns</button>
|
||||
<button
|
||||
@click.prevent="uploadAndPreview"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded"
|
||||
>
|
||||
Upload & Preview Columns
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="applyTemplateToImport"
|
||||
:disabled="!importId || !form.import_template_id || templateApplied"
|
||||
class="px-4 py-2 bg-emerald-600 disabled:bg-gray-300 text-white rounded"
|
||||
>
|
||||
{{ templateApplied ? 'Template Applied' : 'Apply Template' }}
|
||||
{{ templateApplied ? "Template Applied" : "Apply Template" }}
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="saveMappings"
|
||||
|
|
@ -431,32 +508,52 @@ onMounted(async () => {
|
|||
class="px-4 py-2 bg-orange-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||
title="Save ad-hoc mappings for this import"
|
||||
>
|
||||
<span v-if="savingMappings" class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"></span>
|
||||
<span
|
||||
v-if="savingMappings"
|
||||
class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
||||
></span>
|
||||
<span>Save Mappings</span>
|
||||
<span v-if="selectedMappingsCount" class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded">{{ selectedMappingsCount }}</span>
|
||||
<span
|
||||
v-if="selectedMappingsCount"
|
||||
class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded"
|
||||
>{{ selectedMappingsCount }}</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="processImport"
|
||||
:disabled="!importId || processing || (!templateApplied && !mappingSaved)"
|
||||
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded"
|
||||
>
|
||||
{{ processing ? 'Processing…' : 'Process Import' }}
|
||||
{{ processing ? "Processing…" : "Process Import" }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600" v-if="!importId">
|
||||
Upload a file first to enable saving mappings.
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600" v-else-if="importId && !selectedMappingsCount">
|
||||
Select an Entity and Field for at least one detected column (or uncheck Skip) and then click Save Mappings.
|
||||
<div
|
||||
class="mt-2 text-xs text-gray-600"
|
||||
v-else-if="importId && !selectedMappingsCount"
|
||||
>
|
||||
Select an Entity and Field for at least one detected column (or uncheck Skip)
|
||||
and then click Save Mappings.
|
||||
</div>
|
||||
|
||||
<div v-if="detected.columns.length" class="pt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})</h3>
|
||||
<h3 class="font-semibold">
|
||||
Detected Columns ({{ detected.has_header ? "header" : "positional" }})
|
||||
</h3>
|
||||
<button
|
||||
class="px-3 py-1.5 border rounded text-sm"
|
||||
@click.prevent="(async () => { await refreshSuggestions(detected.columns); mappingRows.forEach(r => applySuggestionToRow(r)); })()"
|
||||
>Auto map suggestions</button>
|
||||
@click.prevent="
|
||||
(async () => {
|
||||
await refreshSuggestions(detected.columns);
|
||||
mappingRows.forEach((r) => applySuggestionToRow(r));
|
||||
})()
|
||||
"
|
||||
>
|
||||
Auto map suggestions
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full border bg-white">
|
||||
|
|
@ -476,21 +573,38 @@ onMounted(async () => {
|
|||
<div>{{ row.source_column }}</div>
|
||||
<div class="text-xs mt-1" v-if="suggestions[row.source_column]">
|
||||
<span class="text-gray-500">Suggest:</span>
|
||||
<button class="ml-1 underline text-indigo-700 hover:text-indigo-900" @click.prevent="applySuggestionToRow(row)">
|
||||
{{ suggestions[row.source_column].entity }}.{{ suggestions[row.source_column].field }}
|
||||
<button
|
||||
class="ml-1 underline text-indigo-700 hover:text-indigo-900"
|
||||
@click.prevent="applySuggestionToRow(row)"
|
||||
>
|
||||
{{ suggestions[row.source_column].entity }}.{{
|
||||
suggestions[row.source_column].field
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<select v-model="row.entity" class="border rounded p-1 w-full">
|
||||
<option value="">—</option>
|
||||
<option v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
<option
|
||||
v-for="opt in entityOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<select v-model="row.field" class="border rounded p-1 w-full">
|
||||
<option value="">—</option>
|
||||
<option v-for="f in fieldOptionsByEntity[row.entity] || []" :key="f.value" :value="f.value">{{ f.label }}</option>
|
||||
<option
|
||||
v-for="f in fieldOptionsByEntity[row.entity] || []"
|
||||
:key="f.value"
|
||||
:value="f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
|
|
@ -515,16 +629,22 @@ onMounted(async () => {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2">Mappings saved ({{ mappingSavedCount }}).</div>
|
||||
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">{{ mappingError }}</div>
|
||||
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2">
|
||||
Mappings saved ({{ mappingSavedCount }}).
|
||||
</div>
|
||||
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">
|
||||
{{ mappingError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="processResult" class="pt-4">
|
||||
<h3 class="font-semibold mb-2">Import Result</h3>
|
||||
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{ processResult }}</pre>
|
||||
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{
|
||||
processResult
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
import { computed, watch } from 'vue';
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { ref } from "vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
import Multiselect from "vue-multiselect";
|
||||
import { computed, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
clients: Array,
|
||||
|
|
@ -13,10 +13,10 @@ const props = defineProps({
|
|||
});
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
description: '',
|
||||
source_type: 'csv',
|
||||
default_record_type: '',
|
||||
name: "",
|
||||
description: "",
|
||||
source_type: "csv",
|
||||
default_record_type: "",
|
||||
is_active: true,
|
||||
client_uuid: null,
|
||||
entities: [],
|
||||
|
|
@ -24,33 +24,63 @@ const form = useForm({
|
|||
segment_id: null,
|
||||
decision_id: null,
|
||||
action_id: null,
|
||||
delimiter: '',
|
||||
delimiter: "",
|
||||
// Payments import mode
|
||||
payments_import: false,
|
||||
// For payments mode: how to locate Contract - use single key 'reference'
|
||||
contract_key_mode: null,
|
||||
},
|
||||
});
|
||||
|
||||
const decisionsForSelectedAction = computed(() => {
|
||||
const act = (props.actions || []).find(a => a.id === form.meta.action_id);
|
||||
const act = (props.actions || []).find((a) => a.id === form.meta.action_id);
|
||||
return act?.decisions || [];
|
||||
});
|
||||
|
||||
watch(() => form.meta.action_id, () => {
|
||||
// Clear decision when action changes to enforce valid pair
|
||||
form.meta.decision_id = null;
|
||||
});
|
||||
watch(
|
||||
() => form.meta.action_id,
|
||||
() => {
|
||||
// Clear decision when action changes to enforce valid pair
|
||||
form.meta.decision_id = null;
|
||||
}
|
||||
);
|
||||
|
||||
function submit() {
|
||||
form.post(route('importTemplates.store'), {
|
||||
form.post(route("importTemplates.store"), {
|
||||
onSuccess: () => {
|
||||
// You can redirect or show a success message here
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Payments mode: lock entities to Contract -> Account -> Payment and provide key mode
|
||||
const prevEntities = ref([]);
|
||||
watch(
|
||||
() => form.meta.payments_import,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
// Save current selection and lock to the required chain
|
||||
prevEntities.value = Array.isArray(form.entities) ? [...form.entities] : [];
|
||||
form.entities = ["contracts", "accounts", "payments"];
|
||||
// default contract key mode to 'reference'
|
||||
if (!form.meta.contract_key_mode) {
|
||||
form.meta.contract_key_mode = "reference";
|
||||
}
|
||||
} else {
|
||||
// Restore previous selection when turning off
|
||||
form.entities = prevEntities.value?.length ? [...prevEntities.value] : [];
|
||||
form.meta.contract_key_mode = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Create Import Template">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Create Import Template</h2>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Create Import Template
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-6">
|
||||
|
|
@ -58,11 +88,13 @@ function submit() {
|
|||
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Client (optional)</label>
|
||||
<label class="block text-sm font-medium text-gray-700"
|
||||
>Client (optional)</label
|
||||
>
|
||||
<Multiselect
|
||||
v-model="form.client_uuid"
|
||||
:options="props.clients || []"
|
||||
:reduce="c => c.uuid"
|
||||
:reduce="(c) => c.uuid"
|
||||
track-by="uuid"
|
||||
label="name"
|
||||
placeholder="Global (no client)"
|
||||
|
|
@ -70,71 +102,159 @@ function submit() {
|
|||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Leave empty to make this template global (visible to all clients).</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Leave empty to make this template global (visible to all clients).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Entities (tables)</label>
|
||||
<Multiselect
|
||||
v-model="form.entities"
|
||||
:options="[
|
||||
{ value: 'person', label: 'Person' },
|
||||
{ value: 'person_addresses', label: 'Person Addresses' },
|
||||
{ value: 'person_phones', label: 'Person Phones' },
|
||||
{ value: 'emails', label: 'Emails' },
|
||||
{ value: 'accounts', label: 'Accounts' },
|
||||
{ value: 'contracts', label: 'Contracts' },
|
||||
]"
|
||||
:multiple="true"
|
||||
track-by="value"
|
||||
label="label"
|
||||
:reduce="o => o.value"
|
||||
placeholder="Select one or more entities"
|
||||
:searchable="false"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Choose which tables this template targets. You can still define per-column mappings later.</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium text-gray-700"
|
||||
>Entities (tables)</label
|
||||
>
|
||||
<label class="inline-flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.meta.payments_import"
|
||||
class="rounded"
|
||||
/>
|
||||
<span>Payments import</span>
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="!form.meta.payments_import">
|
||||
<Multiselect
|
||||
v-model="form.entities"
|
||||
:options="[
|
||||
{ value: 'person', label: 'Person' },
|
||||
{ value: 'person_addresses', label: 'Person Addresses' },
|
||||
{ value: 'person_phones', label: 'Person Phones' },
|
||||
{ value: 'emails', label: 'Emails' },
|
||||
{ value: 'accounts', label: 'Accounts' },
|
||||
{ value: 'contracts', label: 'Contracts' },
|
||||
{ value: 'payments', label: 'Payments' },
|
||||
]"
|
||||
:multiple="true"
|
||||
track-by="value"
|
||||
label="label"
|
||||
:reduce="(o) => o.value"
|
||||
placeholder="Select one or more entities"
|
||||
:searchable="false"
|
||||
class="mt-1"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="mt-1">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 px-3 py-1 text-xs font-medium">Contracts</span>
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 px-3 py-1 text-xs font-medium">Accounts</span>
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 px-3 py-1 text-xs font-medium">Payments</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Choose which tables this template targets. You can still define per-column
|
||||
mappings later.
|
||||
</p>
|
||||
<div v-if="form.meta.payments_import" class="mt-2 text-xs text-gray-600">
|
||||
Payments mode locks entities to:
|
||||
<span class="font-medium">Contracts → Accounts → Payments</span> and
|
||||
optimizes matching for payments import.
|
||||
</div>
|
||||
<div v-if="form.meta.payments_import" class="mt-3">
|
||||
<label class="block text-sm font-medium text-gray-700"
|
||||
>Contract match key</label
|
||||
>
|
||||
<select
|
||||
v-model="form.meta.contract_key_mode"
|
||||
class="mt-1 block w-full border rounded p-2"
|
||||
>
|
||||
<option value="reference">
|
||||
Reference (use only contract.reference to locate records)
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
When importing payments, Contract records are located using the selected
|
||||
key. Use your CSV mapping to map the appropriate column to the contract
|
||||
reference.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Defaults: Segment / Decision / Action -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Default Segment</label>
|
||||
<select v-model="form.meta.segment_id" class="mt-1 block w-full border rounded p-2">
|
||||
<label class="block text-sm font-medium text-gray-700"
|
||||
>Default Segment</label
|
||||
>
|
||||
<select
|
||||
v-model="form.meta.segment_id"
|
||||
class="mt-1 block w-full border rounded p-2"
|
||||
>
|
||||
<option :value="null">(none)</option>
|
||||
<option v-for="s in (props.segments || [])" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
<option v-for="s in props.segments || []" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700"
|
||||
>Default Action (for Activity)</label
|
||||
>
|
||||
<select
|
||||
v-model="form.meta.action_id"
|
||||
class="mt-1 block w-full border rounded p-2"
|
||||
>
|
||||
<option :value="null">(none)</option>
|
||||
<option v-for="a in props.actions || []" :key="a.id" :value="a.id">
|
||||
{{ a.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Default Decision</label>
|
||||
<select v-model="form.meta.decision_id" class="mt-1 block w-full border rounded p-2" :disabled="!form.meta.action_id">
|
||||
<label class="block text-sm font-medium text-gray-700"
|
||||
>Default Decision</label
|
||||
>
|
||||
<select
|
||||
v-model="form.meta.decision_id"
|
||||
class="mt-1 block w-full border rounded p-2"
|
||||
:disabled="!form.meta.action_id"
|
||||
>
|
||||
<option :value="null">(none)</option>
|
||||
<option v-for="d in decisionsForSelectedAction" :key="d.id" :value="d.id">{{ d.name }}</option>
|
||||
</select>
|
||||
<p v-if="!form.meta.action_id" class="text-xs text-gray-500 mt-1">Select an Action to see its Decisions.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Default Action (for Activity)</label>
|
||||
<select v-model="form.meta.action_id" class="mt-1 block w-full border rounded p-2">
|
||||
<option :value="null">(none)</option>
|
||||
<option v-for="a in (props.actions || [])" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||
<option v-for="d in decisionsForSelectedAction" :key="d.id" :value="d.id">
|
||||
{{ d.name }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="!form.meta.action_id" class="text-xs text-gray-500 mt-1">
|
||||
Select an Action to see its Decisions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input v-model="form.name" type="text" class="mt-1 block w-full border rounded p-2" />
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full border rounded p-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="form.description" class="mt-1 block w-full border rounded p-2" rows="3" />
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="mt-1 block w-full border rounded p-2"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Source Type</label>
|
||||
<select v-model="form.source_type" class="mt-1 block w-full border rounded p-2">
|
||||
<select
|
||||
v-model="form.source_type"
|
||||
class="mt-1 block w-full border rounded p-2"
|
||||
>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="xml">XML</option>
|
||||
<option value="xls">XLS</option>
|
||||
|
|
@ -143,23 +263,44 @@ function submit() {
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Default Record Type (optional)</label>
|
||||
<input v-model="form.default_record_type" type="text" class="mt-1 block w-full border rounded p-2" placeholder="e.g., account, person" />
|
||||
<label class="block text-sm font-medium text-gray-700"
|
||||
>Default Record Type (optional)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.default_record_type"
|
||||
type="text"
|
||||
class="mt-1 block w-full border rounded p-2"
|
||||
placeholder="e.g., account, person"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="is_active" v-model="form.is_active" type="checkbox" class="rounded" />
|
||||
<label for="is_active" class="text-sm font-medium text-gray-700">Active</label>
|
||||
<input
|
||||
id="is_active"
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="rounded"
|
||||
/>
|
||||
<label for="is_active" class="text-sm font-medium text-gray-700"
|
||||
>Active</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<button @click.prevent="submit" class="px-4 py-2 bg-emerald-600 text-white rounded" :disabled="form.processing">
|
||||
{{ form.processing ? 'Saving…' : 'Create Template' }}
|
||||
<button
|
||||
@click.prevent="submit"
|
||||
class="px-4 py-2 bg-emerald-600 text-white rounded"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
{{ form.processing ? "Saving…" : "Create Template" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="form.errors && Object.keys(form.errors).length" class="text-sm text-red-600">
|
||||
<div
|
||||
v-if="form.errors && Object.keys(form.errors).length"
|
||||
class="text-sm text-red-600"
|
||||
>
|
||||
<div v-for="(msg, key) in form.errors" :key="key">{{ msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user