updates to UI and add archiving option

This commit is contained in:
Simon Pocrnjič 2025-10-05 19:45:49 +02:00
parent fe91c7e4bc
commit bab9d6561f
50 changed files with 3337 additions and 416 deletions

View File

@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use App\Models\ArchiveSetting;
use App\Services\Archiving\ArchiveExecutor;
use Illuminate\Console\Command;
class RunArchive extends Command
{
protected $signature = 'archive:run {--setting=* : Specific archive_setting IDs to run}';
protected $description = 'Execute archive operations based on configured archive settings';
public function handle(): int
{
$ids = collect($this->option('setting'))
->filter()
->map(fn ($v) => (int) $v)
->filter();
$query = ArchiveSetting::query()
->where('enabled', true)
// Manual strategies are never auto-run via the job
->where('strategy', '!=', 'manual');
if ($ids->isNotEmpty()) {
$query->whereIn('id', $ids);
}
$settings = $query->get();
if ($settings->isEmpty()) {
$this->info('No enabled archive settings found.');
return self::SUCCESS;
}
$executor = app(ArchiveExecutor::class);
$overall = [];
foreach ($settings as $setting) {
$this->line("Processing setting #{$setting->id} (".($setting->name ?? 'unnamed').')');
$res = $executor->executeSetting($setting);
foreach ($res as $table => $count) {
$overall[$table] = ($overall[$table] ?? 0) + $count;
$this->line(" - {$table}: {$count} affected");
}
}
$this->info('Archive run complete.');
if (empty($overall)) {
$this->info('No rows matched any archive setting.');
} else {
$this->table(['Table', 'Affected'], collect($overall)->map(fn ($c, $t) => [$t, $c])->values()->all());
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ArchiveSettingRequest;
use App\Models\Action;
use App\Models\ArchiveEntity;
use App\Models\ArchiveSetting;
use App\Models\Segment;
use App\Services\Archiving\ArchiveExecutor;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ArchiveSettingController extends Controller
{
public function index(Request $request): Response
{
$settings = ArchiveSetting::query()
->latest()
->paginate(25)
->withQueryString();
$archiveEntities = ArchiveEntity::query()
->where('enabled', true)
->orderBy('focus')
->get(['id', 'focus', 'name', 'related']);
return Inertia::render('Settings/Archive/Index', [
'settings' => $settings,
'archiveEntities' => $archiveEntities,
'actions' => Action::query()->with('decisions:id,name')->orderBy('name')->get(['id', 'name', 'segment_id']),
'segments' => Segment::query()->orderBy('name')->get(['id', 'name']),
'chainPatterns' => config('archiving.chains'),
]);
}
public function store(ArchiveSettingRequest $request): RedirectResponse
{
$data = $request->validated();
$data['created_by'] = $request->user()?->id;
ArchiveSetting::create($data);
return redirect()->back()->with('flash.banner', 'Archive rule created');
}
public function update(ArchiveSettingRequest $request, ArchiveSetting $archiveSetting): RedirectResponse
{
$data = $request->validated();
$data['updated_by'] = $request->user()?->id;
$archiveSetting->update($data);
return redirect()->back()->with('flash.banner', 'Archive rule updated');
}
public function destroy(ArchiveSetting $archiveSetting): RedirectResponse
{
$archiveSetting->delete();
return redirect()->back()->with('flash.banner', 'Archive rule deleted');
}
public function run(ArchiveSetting $archiveSetting, Request $request): RedirectResponse
{
// Allow manual triggering even if strategy is manual or disabled? We'll require enabled.
if (! $archiveSetting->enabled) {
return back()->with('flash.banner', 'Rule is disabled');
}
$executor = app(ArchiveExecutor::class);
$results = $executor->executeSetting($archiveSetting, context: null, userId: $request->user()?->id);
$summary = empty($results) ? 'No rows matched.' : collect($results)->map(fn ($c, $t) => "$t:$c")->implode(', ');
return back()->with('flash.banner', 'Archive run complete: '.$summary);
}
}

View File

@ -159,6 +159,9 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ
public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request) public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request)
{ {
$contract = Contract::where('uuid', $uuid)->firstOrFail(); $contract = Contract::where('uuid', $uuid)->firstOrFail();
if (! $contract->active) {
return back()->with('warning', __('contracts.edit_not_allowed_archived'));
}
\DB::transaction(function () use ($request, $contract) { \DB::transaction(function () use ($request, $contract) {
$contract->update([ $contract->update([
@ -243,6 +246,10 @@ public function storeActivity(ClientCase $clientCase, Request $request)
if (! empty($attributes['contract_uuid'])) { if (! empty($attributes['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail('id'); $contract = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail('id');
if ($contract) { if ($contract) {
// Prevent attaching a new activity specifically to an archived contract
if (! $contract->active) {
return back()->with('warning', __('contracts.activity_not_allowed_archived'));
}
$contractId = $contract->id; $contractId = $contract->id;
} }
} }
@ -315,6 +322,11 @@ public function updateContractSegment(ClientCase $clientCase, string $uuid, Requ
$contract = $clientCase->contracts()->where('uuid', $uuid)->firstOrFail(); $contract = $clientCase->contracts()->where('uuid', $uuid)->firstOrFail();
// Safety: Disallow segment change if contract archived (inactive)
if (! $contract->active) {
return back()->with('warning', __('contracts.segment_change_not_allowed_archived'));
}
\DB::transaction(function () use ($contract, $validated) { \DB::transaction(function () use ($contract, $validated) {
// Deactivate current active relation(s) // Deactivate current active relation(s)
\DB::table('contract_segment') \DB::table('contract_segment')
@ -365,6 +377,10 @@ public function attachSegment(ClientCase $clientCase, Request $request)
// Optionally make it active for a specific contract // Optionally make it active for a specific contract
if (! empty($validated['contract_uuid']) && ($validated['make_active_for_contract'] ?? false)) { if (! empty($validated['contract_uuid']) && ($validated['make_active_for_contract'] ?? false)) {
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->firstOrFail(); $contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->firstOrFail();
if (! $contract->active) {
// Prevent segment activation for archived contract
return; // Silent; we still attach to case but do not alter archived contract
}
\DB::table('contract_segment') \DB::table('contract_segment')
->where('contract_id', $contract->id) ->where('contract_id', $contract->id)
->where('active', true) ->where('active', true)
@ -402,6 +418,9 @@ public function storeDocument(ClientCase $clientCase, Request $request)
$contract = null; $contract = null;
if (! empty($validated['contract_uuid'])) { if (! empty($validated['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first(); $contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
if ($contract && ! $contract->active) {
return back()->with('warning', __('contracts.document_not_allowed_archived'));
}
} }
$directory = $contract $directory = $contract
? ('contracts/'.$contract->uuid.'/documents') ? ('contracts/'.$contract->uuid.'/documents')
@ -1000,16 +1019,49 @@ public function show(ClientCase $clientCase)
'phone_types' => \App\Models\Person\PhoneType::all(), 'phone_types' => \App\Models\Person\PhoneType::all(),
]; ];
// $active = false;
// Optional segment filter from query string // Optional segment filter from query string
$segmentId = request()->integer('segment'); $segmentId = request()->integer('segment');
// Prepare contracts and a reference map // Determine latest archive (non-reactivate) setting for this context to infer archive segment and related tables
$latestArchiveSetting = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->where(function ($q) {
$q->whereNull('reactivate')->orWhere('reactivate', false);
})
->orderByDesc('id')
->first();
$archiveSegmentId = optional($latestArchiveSetting)->segment_id; // may be null
$relatedArchiveTables = [];
if ($latestArchiveSetting) {
$entities = (array) $latestArchiveSetting->entities;
foreach ($entities as $edef) {
if (isset($edef['related']) && is_array($edef['related'])) {
foreach ($edef['related'] as $rel) {
$relatedArchiveTables[] = $rel;
}
}
}
$relatedArchiveTables = array_values(array_unique($relatedArchiveTables));
}
// Prepare contracts and a reference map.
// Only apply active/inactive filtering IF a segment filter is provided.
$contractsQuery = $case->contracts() $contractsQuery = $case->contracts()
->with(['type', 'account', 'objects', 'segments:id,name']) ->with(['type', 'account', 'objects', 'segments:id,name']);
->orderByDesc('created_at');
$contractsQuery->orderByDesc('created_at');
if (! empty($segmentId)) { if (! empty($segmentId)) {
// Filter to contracts that are in the provided segment and active on pivot // Filter to contracts that are in the provided segment and active on pivot
if ($archiveSegmentId && $segmentId === $archiveSegmentId) {
// Viewing the archive segment: only archived (inactive) contracts
$contractsQuery->where('active', 0);
} else {
// Any other specific segment: only active contracts
$contractsQuery->where('active', 1);
}
$contractsQuery->whereExists(function ($q) use ($segmentId) { $contractsQuery->whereExists(function ($q) use ($segmentId) {
$q->from('contract_segment') $q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id') ->whereColumn('contract_segment.contract_id', 'contracts.id')
@ -1019,6 +1071,7 @@ public function show(ClientCase $clientCase)
} }
$contracts = $contractsQuery->get(); $contracts = $contractsQuery->get();
$contractRefMap = []; $contractRefMap = [];
foreach ($contracts as $c) { foreach ($contracts as $c) {
$contractRefMap[$c->id] = $c->reference; $contractRefMap[$c->id] = $c->reference;
@ -1062,6 +1115,10 @@ public function show(ClientCase $clientCase)
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'bankAccounts']))->firstOrFail(), 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'bankAccounts']))->firstOrFail(),
'client_case' => $case, 'client_case' => $case,
'contracts' => $contracts, 'contracts' => $contracts,
'archive_meta' => [
'archive_segment_id' => $archiveSegmentId,
'related_tables' => $relatedArchiveTables,
],
'activities' => tap( 'activities' => tap(
(function () use ($case, $segmentId, $contractIds) { (function () use ($case, $segmentId, $contractIds) {
$q = $case->activities() $q = $case->activities()
@ -1090,7 +1147,11 @@ function ($p) {
'documents' => $mergedDocs, 'documents' => $mergedDocs,
'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(), 'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(),
'account_types' => \App\Models\AccountType::all(), 'account_types' => \App\Models\AccountType::all(),
'actions' => \App\Models\Action::with('decisions')->get(), 'actions' => \App\Models\Action::with('decisions')
/*->when($segmentId, function($q) use($segmentId) {
$q->where('segment_id', $segmentId)->orWhereNull('segment_id');
})*/
->get(),
'types' => $types, 'types' => $types,
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']), 'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']), 'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
@ -1170,4 +1231,184 @@ public function deleteContractDocument(Contract $contract, Document $document, R
? response()->json(['status' => 'ok']) ? response()->json(['status' => 'ok'])
: back()->with('success', 'Document deleted.'); : back()->with('success', 'Document deleted.');
} }
/**
* Manually archive a contract (flag active=0) and optionally its immediate financial relations.
*/
public function archiveContract(ClientCase $clientCase, string $uuid, Request $request)
{
$contract = Contract::query()->where('uuid', $uuid)->firstOrFail();
if ($contract->client_case_id !== $clientCase->id) {
abort(404);
}
$reactivateRequested = (bool) $request->boolean('reactivate');
// Determine applicable settings based on intent (archive vs reactivate)
if ($reactivateRequested) {
$latestReactivate = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->where('reactivate', true)
->whereIn('strategy', ['immediate', 'manual'])
->orderByDesc('id')
->first();
if (! $latestReactivate) {
return back()->with('warning', __('contracts.reactivate_not_allowed'));
}
$settings = collect([$latestReactivate]);
$hasReactivateRule = true;
} else {
$settings = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->whereIn('strategy', ['immediate', 'manual'])
->where(function ($q) { // exclude reactivate-only rules from archive run
$q->whereNull('reactivate')->orWhere('reactivate', false);
})
->get();
if ($settings->isEmpty()) {
return back()->with('warning', __('contracts.no_archive_settings'));
}
$hasReactivateRule = false;
}
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
$context = [
'contract_id' => $contract->id,
'client_case_id' => $clientCase->id,
];
if ($contract->account) {
$context['account_id'] = $contract->account->id;
}
$overall = [];
$hadAnyEffect = false;
foreach ($settings as $setting) {
$res = $executor->executeSetting($setting, $context, optional($request->user())->id);
foreach ($res as $table => $count) {
$overall[$table] = ($overall[$table] ?? 0) + $count;
if ($count > 0) {
$hadAnyEffect = true;
}
}
}
if ($reactivateRequested && $hasReactivateRule) {
// Reactivation path: ensure contract becomes active and soft-delete cleared.
if ($contract->active == 0 || $contract->deleted_at) {
$contract->forceFill(['active' => 1, 'deleted_at' => null])->save();
$overall['contracts_reactivated'] = ($overall['contracts_reactivated'] ?? 0) + 1;
$hadAnyEffect = true;
}
} else {
// Ensure the contract itself is archived even if rule conditions would have excluded it
if (! empty($contract->getAttributes()) && $contract->active) {
if (! array_key_exists('contracts', $overall)) {
$contract->update(['active' => 0]);
$overall['contracts'] = ($overall['contracts'] ?? 0) + 1;
} else {
$contract->refresh();
}
$hadAnyEffect = true;
}
}
// Create an Activity record logging this archive if an action or decision is tied to any setting
if ($hadAnyEffect) {
$activitySetting = $settings->first(fn ($s) => ! is_null($s->action_id) || ! is_null($s->decision_id));
if ($activitySetting) {
try {
if ($reactivateRequested) {
$note = 'Ponovna aktivacija pogodba '.$contract->reference;
} else {
$noteKey = 'contracts.archived_activity_note';
$note = __($noteKey, ['reference' => $contract->reference]);
if ($note === $noteKey) {
$note = \Illuminate\Support\Facades\Lang::get($noteKey, ['reference' => $contract->reference], 'sl');
}
}
$activityData = [
'client_case_id' => $clientCase->id,
'action_id' => $activitySetting->action_id,
'decision_id' => $activitySetting->decision_id,
'note' => $note,
'active' => 1,
'user_id' => optional($request->user())->id,
];
if ($reactivateRequested) {
// Attach the contract_id when reactivated as per requirement
$activityData['contract_id'] = $contract->id;
}
\App\Models\Activity::create($activityData);
} catch (\Throwable $e) {
logger()->warning('Failed to create archive/reactivate activity', [
'error' => $e->getMessage(),
'contract_id' => $contract->id,
'setting_id' => optional($activitySetting)->id,
'reactivate' => $reactivateRequested,
]);
}
}
}
// If any archive setting specifies a segment_id, move the contract to that segment (archive bucket)
$segmentSetting = $settings->first(fn ($s) => ! is_null($s->segment_id)); // for reactivation this is the single reactivation setting if segment specified
if ($segmentSetting && $segmentSetting->segment_id) {
try {
$segmentId = $segmentSetting->segment_id;
\DB::transaction(function () use ($contract, $segmentId, $clientCase) {
// Ensure the segment is attached to the client case (activate if previously inactive)
$casePivot = \DB::table('client_case_segment')
->where('client_case_id', $clientCase->id)
->where('segment_id', $segmentId)
->first();
if (! $casePivot) {
\DB::table('client_case_segment')->insert([
'client_case_id' => $clientCase->id,
'segment_id' => $segmentId,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
} elseif (! $casePivot->active) {
\DB::table('client_case_segment')
->where('id', $casePivot->id)
->update(['active' => true, 'updated_at' => now()]);
}
// Deactivate all current active contract segments
\DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('active', true)
->update(['active' => false, 'updated_at' => now()]);
// Attach or activate the archive segment for this contract
$existing = \DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('segment_id', $segmentId)
->first();
if ($existing) {
\DB::table('contract_segment')
->where('id', $existing->id)
->update(['active' => true, 'updated_at' => now()]);
} else {
\DB::table('contract_segment')->insert([
'contract_id' => $contract->id,
'segment_id' => $segmentId,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
});
} catch (\Throwable $e) {
logger()->warning('Failed to move contract to archive segment', [
'error' => $e->getMessage(),
'contract_id' => $contract->id,
'segment_id' => $segmentSetting->segment_id,
'setting_id' => $segmentSetting->id,
]);
}
}
$message = $reactivateRequested ? __('contracts.reactivated') : __('contracts.archived');
return back()->with('success', $message);
}
} }

View File

@ -109,6 +109,7 @@ public function store(Request $request)
'sample_headers' => 'nullable|array', 'sample_headers' => 'nullable|array',
'client_id' => 'nullable|integer|exists:clients,id', 'client_id' => 'nullable|integer|exists:clients,id',
'is_active' => 'boolean', 'is_active' => 'boolean',
'reactivate' => 'boolean',
'entities' => 'nullable|array', 'entities' => 'nullable|array',
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
'mappings' => 'array', 'mappings' => 'array',
@ -155,6 +156,7 @@ public function store(Request $request)
'user_id' => $request->user()?->id, 'user_id' => $request->user()?->id,
'client_id' => $data['client_id'] ?? null, 'client_id' => $data['client_id'] ?? null,
'is_active' => $data['is_active'] ?? true, 'is_active' => $data['is_active'] ?? true,
'reactivate' => $data['reactivate'] ?? false,
'meta' => array_filter([ 'meta' => array_filter([
'entities' => $entities, 'entities' => $entities,
'segment_id' => data_get($data, 'meta.segment_id'), 'segment_id' => data_get($data, 'meta.segment_id'),
@ -219,6 +221,7 @@ public function edit(ImportTemplate $template)
'source_type' => $template->source_type, 'source_type' => $template->source_type,
'default_record_type' => $template->default_record_type, 'default_record_type' => $template->default_record_type,
'is_active' => $template->is_active, 'is_active' => $template->is_active,
'reactivate' => $template->reactivate,
'client_uuid' => $template->client?->uuid, 'client_uuid' => $template->client?->uuid,
'sample_headers' => $template->sample_headers, 'sample_headers' => $template->sample_headers,
'meta' => $template->meta, 'meta' => $template->meta,
@ -298,6 +301,7 @@ public function update(Request $request, ImportTemplate $template)
'default_record_type' => 'nullable|string|max:50', 'default_record_type' => 'nullable|string|max:50',
'client_id' => 'nullable|integer|exists:clients,id', 'client_id' => 'nullable|integer|exists:clients,id',
'is_active' => 'boolean', 'is_active' => 'boolean',
'reactivate' => 'boolean',
'sample_headers' => 'nullable|array', 'sample_headers' => 'nullable|array',
'meta' => 'nullable|array', 'meta' => 'nullable|array',
'meta.delimiter' => 'nullable|string|max:4', 'meta.delimiter' => 'nullable|string|max:4',
@ -341,6 +345,7 @@ public function update(Request $request, ImportTemplate $template)
'default_record_type' => $data['default_record_type'] ?? null, 'default_record_type' => $data['default_record_type'] ?? null,
'client_id' => $data['client_id'] ?? null, 'client_id' => $data['client_id'] ?? null,
'is_active' => $data['is_active'] ?? $template->is_active, 'is_active' => $data['is_active'] ?? $template->is_active,
'reactivate' => $data['reactivate'] ?? $template->reactivate,
'sample_headers' => $data['sample_headers'] ?? $template->sample_headers, 'sample_headers' => $data['sample_headers'] ?? $template->sample_headers,
'meta' => (function () use ($newMeta) { 'meta' => (function () use ($newMeta) {
// If payments import mode is enabled, force entities sequence in meta // If payments import mode is enabled, force entities sequence in meta

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ArchiveSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true; // TODO: add policy / gate later
}
public function rules(): array
{
$chains = (array) config('archiving.chains', []);
// Focus entity names (seeded focuses) allowed as primary tables
$focuses = ['contracts', 'client_cases'];
$allowed = array_unique(array_merge($focuses, $chains));
return [
'action_id' => ['nullable', 'exists:actions,id'],
'decision_id' => ['nullable', 'exists:decisions,id'],
'segment_id' => ['nullable', 'exists:segments,id'],
'entities' => ['required', 'array', 'min:1'],
'entities.*.table' => ['required', 'string', 'in:'.implode(',', $allowed)],
'entities.*.related' => ['nullable', 'array'],
'entities.*.conditions' => ['nullable', 'array'],
'entities.*.columns' => ['nullable', 'array'],
'name' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'enabled' => ['boolean'],
'strategy' => ['required', 'in:immediate,scheduled,queued,manual'],
'soft' => ['boolean'],
'reactivate' => ['boolean'],
'options' => ['nullable', 'array'],
];
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ArchiveEntity extends Model
{
use HasFactory;
protected $fillable = [
'focus',
'related',
'name',
'description',
'enabled',
];
protected function casts(): array
{
return [
'related' => 'array',
'enabled' => 'boolean',
];
}
}

43
app/Models/ArchiveRun.php Normal file
View File

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ArchiveRun extends Model
{
use HasFactory;
protected $fillable = [
'archive_setting_id',
'user_id',
'status',
'counts',
'context',
'started_at',
'finished_at',
'duration_ms',
'message',
];
protected function casts(): array
{
return [
'counts' => 'array',
'context' => 'array',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
}
public function setting()
{
return $this->belongsTo(ArchiveSetting::class, 'archive_setting_id');
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ArchiveSetting extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = [
'action_id',
'decision_id',
'segment_id',
'entities',
'name',
'description',
'enabled',
'strategy',
'soft',
'reactivate',
'options',
'created_by',
'updated_by',
];
protected function casts(): array
{
return [
'entities' => 'array',
'options' => 'array',
'enabled' => 'boolean',
'soft' => 'boolean',
'reactivate' => 'boolean',
];
}
// Relationships (nullable FKs)
public function action()
{
return $this->belongsTo(Action::class);
}
public function decision()
{
return $this->belongsTo(Decision::class);
}
public function segment()
{
return $this->belongsTo(Segment::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function updater()
{
return $this->belongsTo(User::class, 'updated_by');
}
}

View File

@ -56,6 +56,7 @@ public function segments(): BelongsToMany
public function account(): HasOne public function account(): HasOne
{ {
return $this->hasOne(\App\Models\Account::class) return $this->hasOne(\App\Models\Account::class)
->latestOfMany()
->with('type'); ->with('type');
} }

View File

@ -12,7 +12,7 @@ class Import extends Model
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'uuid','user_id','import_template_id','client_id','source_type','file_name','original_name','disk','path','size','sheet_name','status','total_rows','valid_rows','invalid_rows','imported_rows','started_at','finished_at','failed_at','error_summary','meta' 'uuid', 'user_id', 'import_template_id', 'client_id', 'source_type', 'file_name', 'original_name', 'disk', 'path', 'size', 'sheet_name', 'status', 'reactivate', 'total_rows', 'valid_rows', 'invalid_rows', 'imported_rows', 'started_at', 'finished_at', 'failed_at', 'error_summary', 'meta',
]; ];
protected $casts = [ protected $casts = [
@ -21,6 +21,7 @@ class Import extends Model
'started_at' => 'datetime', 'started_at' => 'datetime',
'finished_at' => 'datetime', 'finished_at' => 'datetime',
'failed_at' => 'datetime', 'failed_at' => 'datetime',
'reactivate' => 'boolean',
]; ];
public function user(): BelongsTo public function user(): BelongsTo

View File

@ -12,13 +12,14 @@ class ImportTemplate extends Model
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'uuid', 'name', 'description', 'source_type', 'default_record_type', 'sample_headers', 'user_id', 'client_id', 'is_active', 'meta' 'uuid', 'name', 'description', 'source_type', 'default_record_type', 'sample_headers', 'user_id', 'client_id', 'is_active', 'reactivate', 'meta',
]; ];
protected $casts = [ protected $casts = [
'sample_headers' => 'array', 'sample_headers' => 'array',
'meta' => 'array', 'meta' => 'array',
'is_active' => 'boolean', 'is_active' => 'boolean',
'reactivate' => 'boolean',
]; ];
public function user(): BelongsTo public function user(): BelongsTo

View File

@ -0,0 +1,40 @@
<?php
namespace App\Policies;
use App\Models\ArchiveSetting;
use App\Models\User;
class ArchiveSettingPolicy
{
protected function isAdmin(User $user): bool
{
// Placeholder: adjust to real permission system / role flag
return (bool) ($user->is_admin ?? false);
}
public function viewAny(User $user): bool
{
return $this->isAdmin($user);
}
public function view(User $user, ArchiveSetting $setting): bool
{
return $this->isAdmin($user);
}
public function create(User $user): bool
{
return $this->isAdmin($user);
}
public function update(User $user, ArchiveSetting $setting): bool
{
return $this->isAdmin($user);
}
public function delete(User $user, ArchiveSetting $setting): bool
{
return $this->isAdmin($user);
}
}

View File

@ -0,0 +1,286 @@
<?php
namespace App\Services\Archiving;
use App\Models\ArchiveRun;
use App\Models\ArchiveSetting;
use App\Models\Contract;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class ArchiveExecutor
{
/**
* Execute a single archive setting. Returns summary counts keyed by table.
*/
public function executeSetting(ArchiveSetting $setting, ?array $context = null, ?int $userId = null, ?ArchiveRun $existingRun = null): array
{
if (! $setting->enabled) {
return [];
}
$results = [];
$run = $existingRun;
$started = now();
$startedHr = microtime(true); // high-resolution start for accurate duration
if (! $run) {
$run = ArchiveRun::create([
'archive_setting_id' => $setting->id,
'user_id' => $userId,
'status' => 'running',
'context' => $context,
'started_at' => $started,
]);
}
$entities = $setting->entities ?? [];
if (! is_array($entities)) {
return [];
}
// Flatten entities: UI stores a single focus entity with a 'related' array.
$flat = [];
foreach ($entities as $entityDef) {
if (! is_array($entityDef)) {
continue;
}
if (! empty($entityDef['table'])) {
// Mark first / focus explicitly if not set
if (! array_key_exists('focus', $entityDef)) {
// Consider focus if its table matches a known focus (contracts, client_cases)
$entityDef['focus'] = in_array($entityDef['table'], ['contracts', 'client_cases']);
}
$flat[] = $entityDef;
}
if (! empty($entityDef['related']) && is_array($entityDef['related'])) {
foreach ($entityDef['related'] as $rel) {
if (! is_string($rel) || $rel === $entityDef['table']) {
continue;
}
$flat[] = [
'table' => $rel,
'focus' => false,
];
}
}
}
if (! empty($flat)) {
$entities = $flat;
}
foreach ($entities as $entityDef) {
$rawTable = $entityDef['table'] ?? null;
if (! $rawTable) {
continue;
}
$chain = explode('.', $rawTable);
$table = end($chain); // physical table name assumed last segment
$singularToPlural = (array) config('archiving.singular_plural', []);
if (isset($singularToPlural[$table]) && Schema::hasTable($singularToPlural[$table])) {
$table = $singularToPlural[$table];
}
if (! $table || ! Schema::hasTable($table)) {
continue;
}
// Conditions ignored (simplified mode)
$soft = (bool) $setting->soft; // soft flag remains relevant for archive
$reactivate = (bool) ($setting->reactivate ?? false);
$batchSize = (int) ($setting->options['batch_size'] ?? 500);
if ($batchSize < 1) {
$batchSize = 500;
}
$affectedTotal = 0;
// Process in batches to avoid locking large tables
while (true) {
$query = DB::table($table)->whereNull('deleted_at');
if (Schema::hasColumn($table, 'active')) {
$query->where('active', 1);
}
// Apply context filters or chain derived filters
$filterApplied = $this->applyContextFilters($query, $context, $table, (bool) ($entityDef['focus'] ?? false), $chain, $rawTable);
// If context provided but no filter could be applied and this is not the focus entity, skip to avoid whole-table archiving.
if ($context && ! $filterApplied && empty($entityDef['focus'])) {
break;
}
$ids = $query->limit($batchSize)->pluck('id');
if ($ids->isEmpty()) {
break;
}
DB::transaction(function () use ($table, $ids, $soft, $reactivate, &$affectedTotal) {
if ($reactivate) {
// Reactivation path
if (Schema::hasColumn($table, 'active')) {
DB::table($table)
->whereIn('id', $ids)
->update(['active' => 1, 'updated_at' => now(), 'deleted_at' => null]);
$affectedTotal += $ids->count();
} elseif (Schema::hasColumn($table, 'deleted_at')) {
DB::table($table)
->whereIn('id', $ids)
->update(['deleted_at' => null, 'updated_at' => now()]);
$affectedTotal += $ids->count();
}
} else {
// Archiving path
if ($soft && Schema::hasColumn($table, 'active')) {
DB::table($table)
->whereIn('id', $ids)
->update([
'active' => 0,
'updated_at' => now(),
]);
$affectedTotal += $ids->count();
} elseif ($soft && Schema::hasColumn($table, 'deleted_at')) {
DB::table($table)
->whereIn('id', $ids)
->update([
'deleted_at' => now(),
'updated_at' => now(),
]);
$affectedTotal += $ids->count();
} else {
// Hard delete
$affectedTotal += DB::table($table)->whereIn('id', $ids)->delete();
}
}
});
if ($ids->count() < $batchSize) {
break; // last batch
}
}
if ($affectedTotal > 0) {
$results[$table] = $affectedTotal;
}
}
try {
if (! empty($results)) {
Log::info('ArchiveExecutor results', [
'setting_id' => $setting->id,
'results' => $results,
]);
}
$finished = now();
$durationMs = (int) max(0, round((microtime(true) - $startedHr) * 1000));
$run->update([
'status' => 'success',
'counts' => $results,
'finished_at' => $finished,
'duration_ms' => $durationMs,
]);
} catch (\Throwable $e) {
$durationMs = (int) max(0, round((microtime(true) - $startedHr) * 1000));
try {
$run->update([
'status' => 'error',
'message' => $e->getMessage(),
'finished_at' => now(),
'duration_ms' => $durationMs,
]);
} catch (\Throwable $ignored) {
// swallow secondary failure to avoid masking original exception
}
throw $e;
}
return $results;
}
/**
* Context filters allow scoping execution (e.g., only a given contract id) during manual per-record archive.
* Expected keys in $context: contract_id, client_case_id, account_id etc.
*/
protected function applyContextFilters(Builder $query, ?array $context, string $table, bool $isFocus, array $chain = [], ?string $raw = null): bool
{
$applied = false;
if (! $context) {
return $applied;
}
foreach ($context as $key => $value) {
if ($value === null) {
continue;
}
if (Schema::hasColumn($query->from, $key)) {
$query->where($key, $value);
$applied = true;
}
}
// Support polymorphic documents (documentable_id/type) for contract context
if (! $applied && isset($context['contract_id']) && Schema::hasColumn($table, 'documentable_type') && Schema::hasColumn($table, 'documentable_id')) {
$query->where('documentable_type', \App\Models\Contract::class)->where('documentable_id', $context['contract_id']);
$applied = true;
}
// Fallback: for the focus entity contracts table using contract_id context
if (! $applied && $isFocus && isset($context['contract_id']) && $table === 'contracts') {
$query->where('id', $context['contract_id']);
$applied = true;
}
// Chain-based inference (dot notation) limited strictly to declared chain segments.
// Examples:
// - account.payments => resolve payments by account_id from context (if available via contract->account)
// - account.bookings => same pattern
// - contracts.documents => already handled by polymorphic logic above
if (! $applied && ! empty($chain) && count($chain) > 1) {
// We only support a limited mapping derived from context keys, no dynamic relationship traversal.
// Supported patterns:
// account.payments => requires account_id (contracts focus)
// account.bookings => requires account_id (contracts focus)
// contracts.account => requires contract_id, maps to accounts.contract_id
// contracts.account.payments => requires contract_id then account_id (pre-provided in context)
// contracts.account.bookings => same as above
// Additional patterns can be appended cautiously.
$pattern = implode('.', $chain);
switch ($pattern) {
case 'account.payments':
case 'account.bookings':
if (isset($context['account_id']) && Schema::hasColumn($table, 'account_id')) {
$query->where('account_id', $context['account_id']);
$applied = true;
}
break;
case 'contracts.account':
if (isset($context['contract_id']) && $table === 'accounts' && Schema::hasColumn('accounts', 'contract_id')) {
$query->where('contract_id', $context['contract_id']);
$applied = true;
}
break;
case 'contracts.account.payments':
case 'contracts.account.bookings':
// Prefer direct account_id context if present; if not, we cannot safely infer without querying
if (isset($context['account_id']) && Schema::hasColumn($table, 'account_id')) {
$query->where('account_id', $context['account_id']);
$applied = true;
} elseif (isset($context['contract_id']) && Schema::hasColumn($table, 'account_id') && Schema::hasTable('accounts')) {
// Derive account ids for this contract in a subquery (limited, safe scope)
$accountIds = DB::table('accounts')->where('contract_id', $context['contract_id'])->pluck('id');
if ($accountIds->isNotEmpty()) {
$query->whereIn('account_id', $accountIds);
$applied = true;
}
}
break;
case 'contracts.documents':
// already covered by polymorphic; if not yet applied, mimic
if (isset($context['contract_id']) && Schema::hasColumn($table, 'documentable_type') && Schema::hasColumn($table, 'documentable_id')) {
$query->where('documentable_type', \App\Models\Contract::class)
->where('documentable_id', $context['contract_id']);
$applied = true;
}
break;
}
}
return $applied;
}
}

View File

@ -215,6 +215,16 @@ public function process(Import $import, ?Authenticatable $user = null): array
$rawAssoc = $this->buildRowAssoc($row, $header); $rawAssoc = $this->buildRowAssoc($row, $header);
[$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings); [$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings);
// Determine row-level reactivation intent: precedence row > import > template
$rowReactivate = false;
$rawReactivateVal = $rawAssoc['reactivate'] ?? null; // direct column named 'reactivate'
if (! is_null($rawReactivateVal)) {
$rowReactivate = filter_var($rawReactivateVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
}
$importReactivate = (bool) ($import->reactivate ?? false);
$templateReactivate = (bool) (optional($import->template)->reactivate ?? false);
$reactivateMode = $rowReactivate || $importReactivate || $templateReactivate;
// Do not auto-derive or fallback values; only use explicitly mapped fields // Do not auto-derive or fallback values; only use explicitly mapped fields
$rawSha1 = sha1(json_encode($rawAssoc)); $rawSha1 = sha1(json_encode($rawAssoc));
@ -230,6 +240,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
// Contracts // Contracts
$contractResult = null; $contractResult = null;
$reactivatedThisRow = false;
if (isset($mapped['contract'])) { if (isset($mapped['contract'])) {
// In payments-import with contract_key_mode=reference, treat contract.reference as a keyref only // In payments-import with contract_key_mode=reference, treat contract.reference as a keyref only
if ($paymentsImport && $contractKeyMode === 'reference') { if ($paymentsImport && $contractKeyMode === 'reference') {
@ -248,6 +259,29 @@ public function process(Import $import, ?Authenticatable $user = null): array
$found = $q->first(); $found = $q->first();
if ($found) { if ($found) {
$contractResult = ['action' => 'resolved', 'contract' => $found]; $contractResult = ['action' => 'resolved', 'contract' => $found];
// Reactivation branch for resolved existing contract
if ($reactivateMode && ($found->active == 0 || $found->deleted_at)) {
$reactivationApplied = $this->attemptContractReactivation($found, $user);
if ($reactivationApplied['reactivated']) {
$reactivatedThisRow = true;
$imported++;
$importRow->update([
'status' => 'imported',
'entity_type' => Contract::class,
'entity_id' => $found->id,
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'contract_reactivated',
'level' => 'info',
'message' => 'Contract reactivated via import.',
'context' => ['contract_id' => $found->id],
]);
// Do NOT continue; allow postContractActions + account processing below.
}
}
} else { } else {
$contractResult = null; // let requireContract logic flag invalid later $contractResult = null; // let requireContract logic flag invalid later
} }
@ -256,6 +290,31 @@ public function process(Import $import, ?Authenticatable $user = null): array
} }
} else { } else {
$contractResult = $this->upsertContractChain($import, $mapped, $mappings); $contractResult = $this->upsertContractChain($import, $mapped, $mappings);
// If contract was resolved/updated/inserted and reactivation requested but not needed (already active), we just continue normal flow.
if ($reactivateMode && $contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$found = $contractResult['contract'];
if ($found->active == 0 || $found->deleted_at) {
$reactivationApplied = $this->attemptContractReactivation($found, $user);
if ($reactivationApplied['reactivated']) {
$reactivatedThisRow = true;
$importRow->update([
'status' => 'imported',
'entity_type' => Contract::class,
'entity_id' => $found->id,
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'contract_reactivated',
'level' => 'info',
'message' => 'Contract reactivated via import (post-upsert).',
'context' => ['contract_id' => $found->id],
]);
// Do not continue; allow post actions + account handling.
}
}
}
} }
if ($contractResult['action'] === 'skipped') { if ($contractResult['action'] === 'skipped') {
// Even if no contract fields were updated, we may still need to apply template meta // Even if no contract fields were updated, we may still need to apply template meta
@ -315,6 +374,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
]); ]);
// Post-contract actions from template/import meta // Post-contract actions from template/import meta
if (! $reactivateMode || $reactivatedThisRow) { // run post actions also for reactivated contracts
try { try {
$this->postContractActions($import, $contractResult['contract']); $this->postContractActions($import, $contractResult['contract']);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -327,6 +387,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
'message' => $e->getMessage(), 'message' => $e->getMessage(),
]); ]);
} }
}
} else { } else {
$invalid++; $invalid++;
$importRow->update(['status' => 'invalid', 'errors' => [$contractResult['message'] ?? 'Contract processing failed']]); $importRow->update(['status' => 'invalid', 'errors' => [$contractResult['message'] ?? 'Contract processing failed']]);
@ -1073,6 +1134,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
$existing = Account::query() $existing = Account::query()
->where('contract_id', $contractId) ->where('contract_id', $contractId)
->where('reference', $reference) ->where('reference', $reference)
->where('active', 1)
->first(); ->first();
// Build applyable data based on apply_mode // Build applyable data based on apply_mode
@ -2032,4 +2094,59 @@ private function postContractActions(Import $import, Contract $contract): void
]); ]);
} }
} }
/**
* Attempt to reactivate a single archived contract via the latest enabled reactivate ArchiveSetting.
* Returns array{reactivated: bool}.
*/
protected function attemptContractReactivation(Contract $contract, ?Authenticatable $user = null): array
{
try {
// Skip if already active
if ($contract->active && ! $contract->deleted_at) {
return ['reactivated' => false];
}
$setting = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->where('reactivate', true)
->orderByDesc('id')
->first();
if (! $setting) {
return ['reactivated' => false];
}
$context = [
'contract_id' => $contract->id,
'client_case_id' => $contract->client_case_id,
];
if ($contract->account) {
$context['account_id'] = $contract->account->id;
}
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
$executor->executeSetting($setting, $context, $user?->getAuthIdentifier());
// Ensure contract flagged active (safety)
$contract->forceFill(['active' => 1, 'deleted_at' => null])->save();
// Activity from archive setting (if action/decision present) handled inside executor path or we can optionally create here
if ($setting->action_id || $setting->decision_id) {
try {
Activity::create([
'due_date' => null,
'amount' => null,
'note' => 'Ponovna aktivacija pogodba '.$contract->reference,
'action_id' => $setting->action_id,
'decision_id' => $setting->decision_id,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
'user_id' => $user?->getAuthIdentifier(),
]);
} catch (\Throwable $e) {
// Non-fatal
}
}
return ['reactivated' => true];
} catch (\Throwable $e) {
return ['reactivated' => false];
}
}
} }

View File

@ -71,6 +71,18 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
$assoc = $this->associateRow($columns, $rawValues); $assoc = $this->associateRow($columns, $rawValues);
$rowEntities = []; $rowEntities = [];
// Reactivation intent detection (row > import > template)
$rowReactivate = false;
if (array_key_exists('reactivate', $assoc)) {
$rawReactivateVal = $assoc['reactivate'];
if (! is_null($rawReactivateVal) && $rawReactivateVal !== '') {
$rowReactivate = filter_var($rawReactivateVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
}
}
$importReactivate = (bool) ($import->reactivate ?? false);
$templateReactivate = (bool) (optional($import->template)->reactivate ?? false);
$reactivateMode = $rowReactivate || $importReactivate || $templateReactivate;
// Helper closure to resolve mapping value (with normalization fallbacks) // Helper closure to resolve mapping value (with normalization fallbacks)
$val = function (string $tf) use ($assoc, $targetToSource) { $val = function (string $tf) use ($assoc, $targetToSource) {
// Direct hit // Direct hit
@ -95,6 +107,15 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
// Contract // Contract
if (isset($entityRoots['contract'])) { if (isset($entityRoots['contract'])) {
[$contractEntity, $summaries, $contractCache] = $this->simulateContract($val, $summaries, $contractCache, $val('contract.reference')); [$contractEntity, $summaries, $contractCache] = $this->simulateContract($val, $summaries, $contractCache, $val('contract.reference'));
// If reactivation requested and contract exists but is inactive / soft-deleted, mark action as reactivate for UI clarity
if ($reactivateMode && ($contractEntity['action'] === 'update') && (
(isset($contractEntity['active']) && $contractEntity['active'] === 0) ||
(! empty($contractEntity['deleted_at']))
)) {
$contractEntity['original_action'] = $contractEntity['action'];
$contractEntity['action'] = 'reactivate';
$contractEntity['reactivation'] = true;
}
$rowEntities['contract'] = $contractEntity + [ $rowEntities['contract'] = $contractEntity + [
'action_label' => $translatedActions[$contractEntity['action']] ?? $contractEntity['action'], 'action_label' => $translatedActions[$contractEntity['action']] ?? $contractEntity['action'],
]; ];
@ -628,7 +649,7 @@ private function simulateContract(callable $val, array $summaries, array $cache,
if (array_key_exists($reference, $cache)) { if (array_key_exists($reference, $cache)) {
$contract = $cache[$reference]; $contract = $cache[$reference];
} else { } else {
$contract = Contract::query()->where('reference', $reference)->first(['id', 'reference', 'client_case_id']); $contract = Contract::query()->where('reference', $reference)->first(['id', 'reference', 'client_case_id', 'active', 'deleted_at']);
$cache[$reference] = $contract; // may be null $cache[$reference] = $contract; // may be null
} }
} }
@ -637,6 +658,8 @@ private function simulateContract(callable $val, array $summaries, array $cache,
'id' => $contract?->id, 'id' => $contract?->id,
'exists' => (bool) $contract, 'exists' => (bool) $contract,
'client_case_id' => $contract?->client_case_id, 'client_case_id' => $contract?->client_case_id,
'active' => $contract?->active,
'deleted_at' => $contract?->deleted_at,
'action' => $contract ? 'update' : ($reference ? 'create' : 'skip'), 'action' => $contract ? 'update' : ($reference ? 'create' : 'skip'),
]; ];
$summaries['contract']['total_rows']++; $summaries['contract']['total_rows']++;
@ -658,7 +681,10 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
if (array_key_exists($reference, $cache)) { if (array_key_exists($reference, $cache)) {
$account = $cache[$reference]; $account = $cache[$reference];
} else { } else {
$account = Account::query()->where('reference', $reference)->first(['id', 'reference', 'balance_amount']); $account = Account::query()
->where('reference', $reference)
->where('active', 1)
->first(['id', 'reference', 'balance_amount']);
$cache[$reference] = $account; $cache[$reference] = $account;
} }
} }
@ -1156,6 +1182,7 @@ private function actionTranslations(): array
'update' => 'posodobi', 'update' => 'posodobi',
'skip' => 'preskoči', 'skip' => 'preskoči',
'implicit' => 'posredno', 'implicit' => 'posredno',
'reactivate' => 'reaktiviraj',
]; ];
} }

20
config/archiving.php Normal file
View File

@ -0,0 +1,20 @@
<?php
return [
// Supported chain patterns exposed to the UI (checkbox selectable)
'chains' => [
'contracts.account',
'contracts.account.payments',
'contracts.account.bookings',
'contracts.documents',
'account.payments',
'account.bookings',
],
// Singular-to-plural resolution for chain tail segments
'singular_plural' => [
'account' => 'accounts',
'payment' => 'payments',
'booking' => 'bookings',
'document' => 'documents',
],
];

View File

@ -0,0 +1,43 @@
<?php
namespace Database\Factories;
use App\Models\ArchiveSetting;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ArchiveSetting>
*/
class ArchiveSettingFactory extends Factory
{
protected $model = ArchiveSetting::class;
public function definition(): array
{
return [
'action_id' => null,
'decision_id' => null,
'segment_id' => null,
'entities' => [
[
'table' => 'documents',
'conditions' => [
'older_than_days' => $this->faker->numberBetween(30, 365),
],
'columns' => ['id', 'deleted_at'],
],
],
'name' => $this->faker->sentence(3),
'description' => $this->faker->optional()->paragraph(),
'enabled' => true,
'strategy' => 'immediate',
'soft' => true,
'options' => [
'batch_size' => $this->faker->numberBetween(50, 500),
],
'created_by' => User::query()->inRandomOrder()->value('id'),
'updated_by' => null,
];
}
}

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('archive_settings', function (Blueprint $table): void {
$table->id();
// Contextual foreign keys (nullable allows broader global rules)
$table->foreignId('action_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('decision_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('segment_id')->nullable()->constrained()->nullOnDelete();
// JSON describing entities (tables/models) impacted
// Example shape: [{"table":"documents","conditions":{"older_than_days":180},"columns":["id","deleted_at"]}]
$table->json('entities');
// Optional descriptive metadata
$table->string('name')->nullable();
$table->text('description')->nullable();
$table->boolean('enabled')->default(true);
// Execution strategy: immediate | scheduled | queued
$table->string('strategy')->default('immediate');
// Whether to perform a soft archive (flag / soft delete) instead of permanent removal
$table->boolean('soft')->default(true);
// Additional arbitrary options (thresholds, flags, custom logic parameters)
$table->json('options')->nullable();
// Auditing foreign keys for who created / last updated the rule
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->softDeletes();
// Useful indexes
$table->index(['action_id', 'decision_id', 'segment_id']);
$table->index('enabled');
$table->index('strategy');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('archive_settings');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('archive_entities', function (Blueprint $table): void {
$table->id();
$table->string('focus')->unique(); // e.g. contracts, client_cases
$table->json('related'); // JSON array of related table names
$table->string('name')->nullable();
$table->text('description')->nullable();
$table->boolean('enabled')->default(true);
$table->timestamps();
$table->index('enabled');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('archive_entities');
}
};

View File

@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Add active column to bookings if missing
if (Schema::hasTable('bookings') && ! Schema::hasColumn('bookings', 'active')) {
Schema::table('bookings', function (Blueprint $table): void {
$table->unsignedTinyInteger('active')->default(1)->after('description');
$table->index('active');
});
}
// Add active column to activities if missing
if (Schema::hasTable('activities') && ! Schema::hasColumn('activities', 'active')) {
Schema::table('activities', function (Blueprint $table): void {
$table->unsignedTinyInteger('active')->default(1)->after('note');
$table->index('active');
});
}
// Add active column to documents if missing
if (Schema::hasTable('documents') && ! Schema::hasColumn('documents', 'active')) {
Schema::table('documents', function (Blueprint $table): void {
$table->unsignedTinyInteger('active')->default(1)->after('is_public');
$table->index('active');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('bookings') && Schema::hasColumn('bookings', 'active')) {
Schema::table('bookings', function (Blueprint $table): void {
$table->dropIndex(['active']);
$table->dropColumn('active');
});
}
if (Schema::hasTable('activities') && Schema::hasColumn('activities', 'active')) {
Schema::table('activities', function (Blueprint $table): void {
$table->dropIndex(['active']);
$table->dropColumn('active');
});
}
if (Schema::hasTable('documents') && Schema::hasColumn('documents', 'active')) {
Schema::table('documents', function (Blueprint $table): void {
$table->dropIndex(['active']);
$table->dropColumn('active');
});
}
}
};

View File

@ -0,0 +1,28 @@
<?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
{
if (Schema::hasTable('payments') && ! Schema::hasColumn('payments', 'active')) {
Schema::table('payments', function (Blueprint $table): void {
$table->unsignedTinyInteger('active')->default(1)->after('type_id');
$table->index('active');
});
}
}
public function down(): void
{
if (Schema::hasTable('payments') && Schema::hasColumn('payments', 'active')) {
Schema::table('payments', function (Blueprint $table): void {
$table->dropIndex(['active']);
$table->dropColumn('active');
});
}
}
};

View File

@ -0,0 +1,31 @@
<?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::create('archive_runs', function (Blueprint $table): void {
$table->id();
$table->foreignId('archive_setting_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('status', 30)->default('running'); // running|success|error
$table->json('counts')->nullable(); // per-table affected counts
$table->json('context')->nullable(); // manual context scope
$table->timestamp('started_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->unsignedInteger('duration_ms')->nullable();
$table->text('message')->nullable();
$table->timestamps();
$table->index(['archive_setting_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('archive_runs');
}
};

View File

@ -0,0 +1,28 @@
<?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
{
if (Schema::hasTable('archive_settings') && ! Schema::hasColumn('archive_settings', 'reactivate')) {
Schema::table('archive_settings', function (Blueprint $table): void {
$table->boolean('reactivate')->default(false)->after('soft');
$table->index('reactivate');
});
}
}
public function down(): void
{
if (Schema::hasTable('archive_settings') && Schema::hasColumn('archive_settings', 'reactivate')) {
Schema::table('archive_settings', function (Blueprint $table): void {
$table->dropIndex(['reactivate']);
$table->dropColumn('reactivate');
});
}
}
};

View File

@ -0,0 +1,36 @@
<?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('import_templates', function (Blueprint $table) {
if (! Schema::hasColumn('import_templates', 'reactivate')) {
$table->boolean('reactivate')->default(false)->after('is_active');
}
});
Schema::table('imports', function (Blueprint $table) {
if (! Schema::hasColumn('imports', 'reactivate')) {
$table->boolean('reactivate')->default(false)->after('status');
}
});
}
public function down(): void
{
Schema::table('import_templates', function (Blueprint $table) {
if (Schema::hasColumn('import_templates', 'reactivate')) {
$table->dropColumn('reactivate');
}
});
Schema::table('imports', function (Blueprint $table) {
if (Schema::hasColumn('imports', 'reactivate')) {
$table->dropColumn('reactivate');
}
});
}
};

View File

@ -0,0 +1,35 @@
<?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('accounts', function (Blueprint $table) {
// Drop existing unique index if present (contract_id, reference, deleted_at)
try {
$table->dropUnique('accounts_reference_unique');
} catch (\Throwable $e) {
// ignore if it does not exist
}
// Recreate including active flag so archived/inactive accounts can reuse reference
$table->unique(['contract_id', 'reference', 'active', 'deleted_at'], 'accounts_reference_unique');
});
}
public function down(): void
{
Schema::table('accounts', function (Blueprint $table) {
try {
$table->dropUnique('accounts_reference_unique');
} catch (\Throwable $e) {
// ignore
}
// Restore previous definition without active
$table->unique(['contract_id', 'reference', 'deleted_at'], 'accounts_reference_unique');
});
}
};

View File

@ -0,0 +1,59 @@
<?php
namespace Database\Seeders;
use App\Models\ArchiveEntity;
use Illuminate\Database\Seeder;
class ArchiveEntitySeeder extends Seeder
{
/**
* Seed archive focus entities and their selectable related tables.
*/
public function run(): void
{
$entities = [
[
'focus' => 'contracts',
'name' => 'Contracts',
'description' => 'Contracts and their financial / activity related records.',
'related' => [
// Direct related tables
'accounts',
'activities',
'documents', // polymorphic (contract documents only when used as focus)
// Chained relations (dot notation) resolve via contract -> account -> payments/bookings
'account.payments',
'account.bookings',
],
],
[
'focus' => 'client_cases',
'name' => 'Client Cases',
'description' => 'Client cases and subordinate contractual / financial records.',
'related' => [
'contracts', // direct contracts under case
'contracts.account', // via contracts (hasOne account)
'activities', // case level activities (and possibly contract-linked)
'documents', // case level documents
// Chained relations:
'contracts.account.payments', // contracts -> account -> payments
'contracts.account.bookings', // contracts -> account -> bookings
'contracts.documents', // contracts -> documents (polymorphic)
],
],
];
foreach ($entities as $data) {
ArchiveEntity::query()->updateOrCreate(
['focus' => $data['focus']],
[
'name' => $data['name'],
'description' => $data['description'],
'related' => $data['related'],
'enabled' => true,
]
);
}
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use App\Models\ArchiveSetting;
use Illuminate\Database\Seeder;
class ArchiveSettingSeeder extends Seeder
{
public function run(): void
{
if (ArchiveSetting::query()->count() === 0) {
ArchiveSetting::factory()->count(2)->create();
}
}
}

View File

@ -31,6 +31,7 @@ public function run(): void
$this->call([ $this->call([
AccountTypeSeeder::class, AccountTypeSeeder::class,
PaymentSettingSeeder::class, PaymentSettingSeeder::class,
ArchiveEntitySeeder::class,
PersonSeeder::class, PersonSeeder::class,
SegmentSeeder::class, SegmentSeeder::class,
ActionSeeder::class, ActionSeeder::class,

14
lang/sl/contracts.php Normal file
View File

@ -0,0 +1,14 @@
<?php
return [
'archived' => 'Pogodba je bila arhivirana.',
'reactivated' => 'Pogodba je bila ponovno aktivirana.',
'reactivate_not_allowed' => 'Ponovna aktivacija ni na voljo.',
'no_archive_settings' => 'Ni nastavitev za arhiviranje.',
'archived_activity_note' => 'Arhivirana pogodba :reference',
'reactivated_activity_note' => 'Ponovno aktivirana pogodba :reference',
'segment_change_not_allowed_archived' => 'Segmenta ni mogoče spremeniti za arhivirano pogodbo.',
'edit_not_allowed_archived' => 'Urejanje ni dovoljeno za arhivirano pogodbo.',
'activity_not_allowed_archived' => 'Aktivnosti ni mogoče dodati k arhivirani pogodbi.',
'document_not_allowed_archived' => 'Dokumenta ni mogoče dodati k arhivirani pogodbi.',
];

View File

@ -1,7 +1,7 @@
<script setup> <script setup>
import InputLabel from './InputLabel.vue' import InputLabel from "./InputLabel.vue";
import InputError from './InputError.vue' import InputError from "./InputError.vue";
import { computed } from 'vue' import { computed } from "vue";
/* /*
DatePickerField (v-calendar) DatePickerField (v-calendar)
@ -25,45 +25,44 @@ const props = defineProps({
modelValue: { type: [Date, String, Number, null], default: null }, modelValue: { type: [Date, String, Number, null], default: null },
id: { type: String, default: undefined }, id: { type: String, default: undefined },
label: { type: String, default: undefined }, label: { type: String, default: undefined },
format: { type: String, default: 'dd.MM.yyyy' }, format: { type: String, default: "dd.MM.yyyy" },
enableTimePicker: { type: Boolean, default: false }, enableTimePicker: { type: Boolean, default: false },
inline: { type: Boolean, default: false }, inline: { type: Boolean, default: false },
// legacy/unused in v-calendar (kept to prevent breaking callers) // legacy/unused in v-calendar (kept to prevent breaking callers)
autoApply: { type: Boolean, default: false }, autoApply: { type: Boolean, default: false },
teleportTarget: { type: [Boolean, String], default: 'body' }, teleportTarget: { type: [Boolean, String], default: "body" },
autoPosition: { type: Boolean, default: true }, autoPosition: { type: Boolean, default: true },
menuClassName: { type: String, default: 'dp-over-modal' }, menuClassName: { type: String, default: "dp-over-modal" },
fixed: { type: Boolean, default: true }, fixed: { type: Boolean, default: true },
closeOnAutoApply: { type: Boolean, default: true }, closeOnAutoApply: { type: Boolean, default: true },
closeOnScroll: { type: Boolean, default: true }, closeOnScroll: { type: Boolean, default: true },
placeholder: { type: String, default: '' }, placeholder: { type: String, default: "" },
error: { type: [String, Array], default: undefined }, error: { type: [String, Array], default: undefined },
}) });
const emit = defineEmits(['update:modelValue', 'change']) const emit = defineEmits(["update:modelValue", "change"]);
const valueProxy = computed({ const valueProxy = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (val) => { set: (val) => {
emit('update:modelValue', val) emit("update:modelValue", val);
emit('change', val) emit("change", val);
}, },
}) });
// Convert common date mask from lowercase tokens to v-calendar tokens // Convert common date mask from lowercase tokens to v-calendar tokens
const inputMask = computed(() => { const inputMask = computed(() => {
let m = props.format || 'dd.MM.yyyy' let m = props.format || "dd.MM.yyyy";
return m return (
.replace(/yyyy/g, 'YYYY') m.replace(/yyyy/g, "YYYY").replace(/dd/g, "DD").replace(/MM/g, "MM") +
.replace(/dd/g, 'DD') (props.enableTimePicker ? " HH:mm" : "")
.replace(/MM/g, 'MM') );
+ (props.enableTimePicker ? ' HH:mm' : '') });
})
const popoverCfg = computed(() => ({ const popoverCfg = computed(() => ({
visibility: props.inline ? 'visible' : 'click', visibility: props.inline ? "visible" : "click",
placement: 'bottom-start', placement: "bottom-start",
})) }));
</script> </script>
<template> <template>
@ -84,20 +83,24 @@ const popoverCfg = computed(() => ({
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
:placeholder="placeholder" :placeholder="placeholder"
:value="inputValue" :value="inputValue"
autocomplete="off"
v-on="inputEvents" v-on="inputEvents"
/> />
</template> </template>
</VDatePicker> </VDatePicker>
<template v-if="error"> <template v-if="error">
<InputError v-if="Array.isArray(error)" v-for="(e, idx) in error" :key="idx" :message="e" /> <InputError
v-if="Array.isArray(error)"
v-for="(e, idx) in error"
:key="idx"
:message="e"
/>
<InputError v-else :message="error" /> <InputError v-else :message="error" />
</template> </template>
</div> </div>
</template> </template>
<style> <style>
/* Ensure the date picker menu overlays modals/dialogs */ /* Ensure the date picker menu overlays modals/dialogs */
</style> </style>

View File

@ -255,7 +255,13 @@ function closeActions() {
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3" class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
>Vir</FwbTableHeadCell >Vir</FwbTableHeadCell
> >
<FwbTableHeadCell class="w-px" /> <FwbTableHeadCell
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-"
>Opis</FwbTableHeadCell
>
<FwbTableHeadCell
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-"
></FwbTableHeadCell>
</FwbTableHead> </FwbTableHead>
<FwbTableBody> <FwbTableBody>
<template v-for="(doc, i) in documents" :key="doc.uuid || i"> <template v-for="(doc, i) in documents" :key="doc.uuid || i">
@ -318,7 +324,7 @@ function closeActions() {
@click="handleDownload(doc)" @click="handleDownload(doc)"
> >
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" /> <FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" />
<span>Download file</span> <span>Prenos</span>
</button> </button>
<button <button
type="button" type="button"
@ -326,7 +332,7 @@ function closeActions() {
@click="askDelete(doc)" @click="askDelete(doc)"
> >
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4" /> <FontAwesomeIcon :icon="faTrash" class="h-4 w-4" />
<span>Delete</span> <span>Izbriši</span>
</button> </button>
<!-- future actions can be slotted here --> <!-- future actions can be slotted here -->
</template> </template>

View File

@ -1,143 +1,323 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faLocationDot,
faPhone,
faEnvelope,
faLandmark,
faChevronDown,
} from "@fortawesome/free-solid-svg-icons";
const props = defineProps({ const props = defineProps({
person: { type: Object, required: true }, person: { type: Object, required: true },
types: { type: Object, default: () => ({}) }, types: { type: Object, default: () => ({}) },
// Allow overriding the default active tab: 'addresses' | 'phones' | 'emails' | 'bank' // Allow overriding the default active tab: 'addresses' | 'phones' | 'emails' | 'bank'
defaultTab: { type: String, default: 'addresses' }, defaultTab: { type: String, default: "addresses" },
}) });
const phoneTypes = computed(() => { const phoneTypes = computed(() => {
const arr = props.types?.phone_types || [] const arr = props.types?.phone_types || [];
const map = {} const map = {};
for (const t of arr) { map[t.id] = t.name } for (const t of arr) {
return map map[t.id] = t.name;
}) }
return map;
});
const displayName = computed(() => { const displayName = computed(() => {
const p = props.person || {} const p = props.person || {};
const full = p.full_name?.trim() const full = p.full_name?.trim();
if (full) { return full } if (full) {
const first = p.first_name?.trim() || '' return full;
const last = p.last_name?.trim() || '' }
return `${first} ${last}`.trim() const first = p.first_name?.trim() || "";
}) const last = p.last_name?.trim() || "";
return `${first} ${last}`.trim();
});
const primaryAddress = computed(() => props.person?.addresses?.[0] || null) const primaryAddress = computed(() => props.person?.addresses?.[0] || null);
const primaryEmail = computed(() => props.person?.emails?.[0]?.value || null) const primaryEmail = computed(() => props.person?.emails?.[0]?.value || null);
// Backend phone model uses `nu` as the number // Backend phone model uses `nu` as the number
const allPhones = computed(() => props.person?.phones || []) const allPhones = computed(() => props.person?.phones || []);
const allAddresses = computed(() => props.person?.addresses || []) const allAddresses = computed(() => props.person?.addresses || []);
const allEmails = computed(() => props.person?.emails || []) const allEmails = computed(() => props.person?.emails || []);
// Laravel serializes relation names to snake_case, so prefer bank_accounts, fallback to bankAccounts // Laravel serializes relation names to snake_case, so prefer bank_accounts, fallback to bankAccounts
const allBankAccounts = computed(() => props.person?.bank_accounts || props.person?.bankAccounts || []) const allBankAccounts = computed(
const bankIban = computed(() => allBankAccounts.value?.[0]?.iban || null) () => props.person?.bank_accounts || props.person?.bankAccounts || []
const taxNumber = computed(() => props.person?.tax_number || null) );
const ssn = computed(() => props.person?.social_security_number || null) // Use the LAST added bank account (assumes incoming order oldest -> newest)
const bankIban = computed(() => {
const list = allBankAccounts.value || [];
if (!list.length) {
return null;
}
return list[list.length - 1]?.iban || null;
});
const taxNumber = computed(() => props.person?.tax_number || null);
const ssn = computed(() => props.person?.social_security_number || null);
// Summary sizing // Summary sizing
const showMore = ref(false) const showMore = ref(false);
const summaryPhones = computed(() => allPhones.value.slice(0, showMore.value ? 2 : 1)) const summaryPhones = computed(() => allPhones.value.slice(0, showMore.value ? 3 : 1));
// Tabs // Tabs
const activeTab = ref(props.defaultTab || 'addresses') // Limit tabs to addresses | phones | emails (TRR tab removed)
watch(() => props.defaultTab, (val) => { if (val) activeTab.value = val }) const allowedTabs = ["addresses", "phones", "emails"];
const initialTab = allowedTabs.includes(props.defaultTab)
? props.defaultTab
: "addresses";
const activeTab = ref(initialTab);
watch(
() => props.defaultTab,
(val) => {
if (val && allowedTabs.includes(val)) {
activeTab.value = val;
}
}
);
function maskIban(iban) { function maskIban(iban) {
if (!iban || typeof iban !== 'string') return null if (!iban || typeof iban !== "string") return null;
const clean = iban.replace(/\s+/g, '') const clean = iban.replace(/\s+/g, "");
if (clean.length <= 8) return clean if (clean.length <= 8) return clean;
return `${clean.slice(0, 4)} **** **** ${clean.slice(-4)}` return `${clean.slice(0, 4)} **** **** ${clean.slice(-4)}`;
} }
</script> </script>
<template> <template>
<!-- Summary -->
<div class="text-sm"> <div class="text-sm">
<div v-if="displayName" class="font-medium text-gray-900">{{ displayName }}</div> <div class="mt-2 flex flex-wrap gap-1.5">
<span v-if="primaryAddress" class="pill pill-slate" title="Naslov">
<div v-if="primaryAddress" class="mt-1 text-gray-700"> <FontAwesomeIcon :icon="faLocationDot" class="w-4 h-4 mr-1" />
<span>{{ primaryAddress.address }}</span> <span class="truncate max-w-[9rem]">{{ primaryAddress.address }}</span>
<span v-if="primaryAddress.country" class="text-gray-500 text-xs ml-1">({{ primaryAddress.country }})</span> </span>
<span v-if="summaryPhones.length" class="pill pill-indigo" title="Telefon">
<FontAwesomeIcon :icon="faPhone" class="w-4 h-4 mr-1" />
{{ summaryPhones[0].nu
}}<span
v-if="
(summaryPhones[0].type_id && phoneTypes[summaryPhones[0].type_id]) ||
summaryPhones[0].type?.name
"
class="ml-1 text-[10px] opacity-80"
>({{
summaryPhones[0].type?.name || phoneTypes[summaryPhones[0].type_id]
}})</span
>
</span>
<span v-if="primaryEmail && showMore" class="pill pill-default" title="E-pošta">
<FontAwesomeIcon :icon="faEnvelope" class="w-4 h-4 mr-1" />
<span class="truncate max-w-[9rem]">{{ primaryEmail }}</span>
</span>
<span v-if="bankIban" class="pill pill-emerald" title="TRR (zadnji dodan)">
<FontAwesomeIcon :icon="faLandmark" class="w-4 h-4 mr-1" />
{{ maskIban(bankIban) }}
</span>
</div> </div>
<div v-if="summaryPhones?.length" class="mt-1 space-y-0.5"> <transition name="fade">
<div v-for="p in summaryPhones" :key="p.id" class="text-gray-700"> <div v-if="showMore" class="mt-3 grid grid-cols-2 gap-x-2 gap-y-2 text-[14px]">
<span>{{ p.nu }}</span> <div v-if="taxNumber">
<span v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name" class="text-gray-500 text-xs ml-1">({{ p.type?.name || phoneTypes[p.type_id] }})</span> <div class="label">Davčna</div>
<div class="value font-mono">{{ taxNumber }}</div>
</div>
<div v-if="ssn">
<div class="label">EMŠO</div>
<div class="value font-mono">{{ ssn }}</div>
</div>
<div v-if="bankIban">
<div class="label">TRR (zadnji)</div>
<div class="value font-mono">{{ maskIban(bankIban) }}</div>
</div>
<div v-if="primaryEmail">
<div class="label">Epošta</div>
<div class="value truncate">{{ primaryEmail }}</div>
</div> </div>
</div> </div>
</transition>
<div v-if="showMore && primaryEmail" class="mt-1 text-gray-700">{{ primaryEmail }}</div> <button
type="button"
<div v-if="showMore && bankIban" class="mt-1 text-gray-700">TRR: <span class="font-mono">{{ maskIban(bankIban) }}</span></div> class="mt-3 inline-flex items-center text-[11px] font-medium text-indigo-600 hover:text-indigo-700 focus:outline-none"
@click="showMore = !showMore"
<div v-if="showMore && taxNumber" class="mt-1 text-gray-700">Davčna: <span class="font-mono">{{ taxNumber }}</span></div> >
<div v-if="showMore && ssn" class="mt-1 text-gray-700">EMŠO: <span class="font-mono">{{ ssn }}</span></div> <FontAwesomeIcon
:icon="faChevronDown"
<button type="button" class="mt-2 text-xs text-blue-600 hover:underline" @click="showMore = !showMore"> :class="[
{{ showMore ? 'Skrij' : 'Prikaži več' }} 'w-3 h-3 mr-1 transition-transform',
showMore ? 'rotate-180' : 'rotate-0',
]"
/>
{{ showMore ? "Manj podrobnosti" : "Več podrobnosti" }}
</button> </button>
</div> </div>
<!-- Tabs --> <!-- Segmented Tabs -->
<div class="mt-3"> <div class="mt-5">
<div class="flex gap-2 overflow-x-auto"> <div class="relative">
<button type="button" @click="activeTab = 'addresses'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='addresses' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']"> <div
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a1 1 0 0 1 .832.445l6 8.5a1 1 0 0 1 .168.555V17a1 1 0 0 1-1 1h-4v-4H8v4H4a1 1 0 0 1-1-1v-5.5a1 1 0 0 1 .168-.555l6-8.5A1 1 0 0 1 10 2Z"/></svg> class="flex w-full text-[11px] font-medium rounded-lg border bg-gray-50 overflow-hidden"
Naslovi ({{ allAddresses.length }}) >
<button
type="button"
@click="activeTab = 'addresses'"
:class="['seg-btn', activeTab === 'addresses' && 'seg-active']"
>
<FontAwesomeIcon :icon="faLocationDot" class="w-3.5 h-3.5 mr-1 shrink-0" />
<span class="truncate">Naslovi ({{ allAddresses.length }})</span>
</button> </button>
<button type="button" @click="activeTab = 'phones'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='phones' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']"> <button
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M2.3 3.3c.6-1 1.9-1.3 2.9-.7l1.7 1a2 2 0 0 1 .9 2.5l-.5 1.2a2 2 0 0 0 .4 2.2l2.8 2.8a2 2 0 0 0 2.2.4l1.2-.5a2 2 0 0 1 2.5.9l1 1.7c.6 1 .3 2.3-.7 2.9-2 1.1-4.5 1.1-6.5 0-2.5-1.3-4.8-3.6-6.1-6.1-1.1-2-1.1-4.5 0-6.5Z"/></svg> type="button"
Telefoni ({{ allPhones.length }}) @click="activeTab = 'phones'"
:class="['seg-btn', activeTab === 'phones' && 'seg-active']"
>
<FontAwesomeIcon :icon="faPhone" class="w-3.5 h-3.5 mr-1 shrink-0" />
<span class="truncate">Telefoni ({{ allPhones.length }})</span>
</button> </button>
<button type="button" @click="activeTab = 'emails'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='emails' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']"> <button
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M2.5 5A1.5 1.5 0 0 1 4 3.5h12A1.5 1.5 0 0 1 17.5 5v10A1.5 1.5 0 0 1 16 16.5H4A1.5 1.5 0 0 1 2.5 15V5Zm2.1.5 5.4 3.6a1 1 0 0 0 1.1 0l5.4-3.6V5H4.6Z"/></svg> type="button"
E-pošta ({{ allEmails.length }}) @click="activeTab = 'emails'"
</button> :class="['seg-btn', activeTab === 'emails' && 'seg-active']"
<button type="button" @click="activeTab = 'bank'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='bank' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']"> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M10 2 2 6v2h16V6l-8-4Zm-6 7h12v7H4V9Zm-1 8h14v1H3v-1Z"/></svg> <FontAwesomeIcon :icon="faEnvelope" class="w-3.5 h-3.5 mr-1 shrink-0" />
TRR ({{ allBankAccounts.length }}) <span class="truncate">Epošta ({{ allEmails.length }})</span>
</button> </button>
</div> </div>
</div>
<div class="mt-2"> <div class="mt-3 rounded-md border bg-white/60 p-2">
<!-- Addresses --> <!-- Addresses -->
<div v-if="activeTab==='addresses'"> <div v-if="activeTab === 'addresses'">
<div v-if="!allAddresses.length" class="text-gray-500 text-xs">Ni naslovov.</div> <div v-if="!allAddresses.length" class="empty">Ni naslovov.</div>
<div v-for="(a,idx) in allAddresses" :key="a.id || idx" class="py-1"> <div v-for="(a, idx) in allAddresses" :key="a.id || idx" class="item-row">
<div class="text-gray-800">{{ a.address }}</div> <div class="font-medium text-gray-800">{{ a.address }}</div>
<div v-if="a.country" class="text-gray-600 text-xs">{{ a.country }}</div> <div v-if="a.country" class="sub">{{ a.country }}</div>
</div> </div>
</div> </div>
<!-- Phones --> <!-- Phones -->
<div v-else-if="activeTab==='phones'"> <div v-else-if="activeTab === 'phones'">
<div v-if="!allPhones.length" class="text-gray-500 text-xs">Ni telefonov.</div> <div v-if="!allPhones.length" class="empty">Ni telefonov.</div>
<div v-for="(p,idx) in allPhones" :key="p.id || idx" class="py-1"> <div v-for="(p, idx) in allPhones" :key="p.id || idx" class="item-row">
<div class="text-gray-800">{{ p.nu }} <span v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name" class="text-gray-500 text-xs">({{ p.type?.name || phoneTypes[p.type_id] }})</span></div> <div class="font-medium text-gray-800">
{{ p.nu }}
<span
v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name"
class="sub ml-1"
>({{ p.type?.name || phoneTypes[p.type_id] }})</span
>
</div>
</div> </div>
</div> </div>
<!-- Emails --> <!-- Emails -->
<div v-else-if="activeTab==='emails'"> <div v-else-if="activeTab === 'emails'">
<div v-if="!allEmails.length" class="text-gray-500 text-xs">Ni e-poštnih naslovov.</div> <div v-if="!allEmails.length" class="empty">Ni e-poštnih naslovov.</div>
<div v-for="(e,idx) in allEmails" :key="e.id || idx" class="py-1"> <div v-for="(e, idx) in allEmails" :key="e.id || idx" class="item-row">
<div class="text-gray-800">{{ e.value }}<span v-if="e.label" class="text-gray-500 text-xs ml-1">({{ e.label }})</span></div> <div class="font-medium text-gray-800">
{{ e.value }}<span v-if="e.label" class="sub ml-1">({{ e.label }})</span>
</div> </div>
</div> </div>
<!-- Bank accounts -->
<div v-else>
<div v-if="!allBankAccounts.length" class="text-gray-500 text-xs">Ni TRR računov.</div>
<div v-for="(b,idx) in allBankAccounts" :key="b.id || idx" class="py-1">
<div class="text-gray-800">{{ maskIban(b.iban) }}</div>
<div v-if="b.bank_name" class="text-gray-600 text-xs">{{ b.bank_name }}</div>
</div>
</div> </div>
<!-- (TRR tab removed; last bank account surfaced in summary) -->
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* Basic utility replacements (no Tailwind processor here) */
.pill {
display: inline-flex;
align-items: center;
max-width: 100%;
border-radius: 9999px;
padding: 0.35rem 0.75rem; /* slightly larger */
font-size: 12px;
font-weight: 600;
line-height: 1.15;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.06);
}
.pill-slate {
background: #f1f5f9;
color: #334155;
}
.pill-indigo {
background: #e0e7ff;
color: #3730a3;
}
.pill-default {
background: #f3f4f6;
color: #374151;
}
.pill-emerald {
background: #d1fae5;
color: #047857;
}
.seg-btn {
flex: 1 1 0;
min-width: 0; /* allow flex item to shrink below intrinsic size */
white-space: nowrap;
padding: 0.5rem 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
border-right: 1px solid #e5e7eb;
font-size: 11px;
background: transparent;
color: #4b5563;
transition: background 0.15s, color 0.15s;
overflow: hidden;
}
.seg-btn:last-child {
border-right: none;
}
.seg-btn:hover {
background: #ffffffb3;
color: #1f2937;
}
.seg-active {
background: #fff;
color: #111827;
font-weight: 600;
box-shadow: inset 0 0 0 1px #e5e7eb;
}
.item-row {
padding: 0.375rem 0;
border-bottom: 1px dashed #e5e7eb;
}
.item-row:last-child {
border-bottom: none;
}
.sub {
font-size: 12px;
color: #6b7280;
font-weight: 400;
}
.empty {
font-size: 12px;
color: #6b7280;
font-style: italic;
}
.label {
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 10px;
color: #9ca3af;
}
.value {
margin-top: 0.125rem;
color: #1f2937;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style> </style>

View File

@ -9,7 +9,18 @@ import Breadcrumbs from "@/Components/Breadcrumbs.vue";
import GlobalSearch from "./Partials/GlobalSearch.vue"; import GlobalSearch from "./Partials/GlobalSearch.vue";
import NotificationsBell from "./Partials/NotificationsBell.vue"; import NotificationsBell from "./Partials/NotificationsBell.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faMobileScreenButton } from "@fortawesome/free-solid-svg-icons"; import {
faMobileScreenButton,
faGaugeHigh,
faLayerGroup,
faUserGroup,
faFolderOpen,
faFileImport,
faTableList,
faFileCirclePlus,
faMap,
faGear,
} from "@fortawesome/free-solid-svg-icons";
const props = defineProps({ const props = defineProps({
title: String, title: String,
@ -173,7 +184,7 @@ const rawMenuGroups = [
], ],
}, },
{ {
label: "Terensko", label: "Terensko delo",
items: [ items: [
{ {
key: "fieldjobs", key: "fieldjobs",
@ -205,6 +216,19 @@ const menuGroups = computed(() => {
})); }));
}); });
// Icon map for menu keys -> FontAwesome icon definitions
const menuIconMap = {
dashboard: faGaugeHigh,
segments: faLayerGroup,
clients: faUserGroup,
cases: faFolderOpen,
imports: faFileImport,
"import-templates": faTableList,
"import-templates-new": faFileCirclePlus,
fieldjobs: faMap,
settings: faGear,
};
function isActive(patterns) { function isActive(patterns) {
try { try {
return patterns?.some((p) => route().current(p)); return patterns?.some((p) => route().current(p));
@ -267,161 +291,12 @@ function isActive(patterns) {
]" ]"
:title="item.title" :title="item.title"
> >
<!-- Icons --> <!-- Unified FontAwesome icon rendering -->
<template v-if="item.key === 'dashboard'"> <FontAwesomeIcon
<svg v-if="menuIconMap[item.key]"
xmlns="http://www.w3.org/2000/svg" :icon="menuIconMap[item.key]"
viewBox="0 0 24 24" class="w-5 h-5 text-gray-600"
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 --> <!-- Title -->
<span v-if="!sidebarCollapsed">{{ item.title }}</span> <span v-if="!sidebarCollapsed">{{ item.title }}</span>
</Link> </Link>

View File

@ -73,11 +73,22 @@ const store = async () => {
amount: form.amount, amount: form.amount,
note: form.note, note: form.note,
}); });
// Helper to safely format a selected date (Date instance or parsable value) to YYYY-MM-DD
const formatDateForSubmit = (value) => {
if (!value) return null; // leave empty as null
const d = value instanceof Date ? value : new Date(value);
if (isNaN(d.getTime())) return null; // invalid date -> null
// Avoid timezone shifting by constructing in local time
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`; // matches en-CA style YYYY-MM-DD
};
form form
.transform((data) => ({ .transform((data) => ({
...data, ...data,
due_date: new Date(data.due_date).toLocaleDateString("en-CA"), due_date: formatDateForSubmit(data.due_date),
})) }))
.post(route("clientCase.activity.store", props.client_case), { .post(route("clientCase.activity.store", props.client_case), {
onSuccess: () => { onSuccess: () => {

View File

@ -179,17 +179,17 @@ const confirmDeleteAction = () => {
> >
</div> </div>
</td> </td>
<td class="py-2 pl-2 pr-2 align-top text-right"> <td class="py-2 pl-2 pr-2 align-middle text-right">
<Dropdown align="right" width="30" :content-classes="['py-1', 'bg-white']"> <Dropdown align="right" width="30">
<template #trigger> <template #trigger>
<button <button
type="button" type="button"
class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-gray-100" class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
aria-haspopup="menu" :title="'Actions'"
> >
<FontAwesomeIcon <FontAwesomeIcon
:icon="['fas', 'ellipsis-vertical']" :icon="faEllipsisVertical"
class="text-gray-600 text-[20px]" class="h-4 w-4 text-gray-700"
/> />
</button> </button>
</template> </template>

View File

@ -21,6 +21,7 @@ import {
faTrash, faTrash,
faListCheck, faListCheck,
faPlus, faPlus,
faBoxArchive,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
const props = defineProps({ const props = defineProps({
@ -119,6 +120,10 @@ const confirmChange = ref({
fromAll: false, fromAll: false,
}); });
const askChangeSegment = (c, segmentId, fromAll = false) => { const askChangeSegment = (c, segmentId, fromAll = false) => {
// Prevent segment change for archived contracts
if (!c?.active) {
return;
}
confirmChange.value = { show: true, contract: c, segmentId, fromAll }; confirmChange.value = { show: true, contract: c, segmentId, fromAll };
}; };
const closeConfirm = () => { const closeConfirm = () => {
@ -262,13 +267,16 @@ const closePaymentsDialog = () => {
class="inline-flex items-center justify-center h-7 w-7 rounded-full hover:bg-gray-100" class="inline-flex items-center justify-center h-7 w-7 rounded-full hover:bg-gray-100"
:class="{ :class="{
'opacity-50 cursor-not-allowed': 'opacity-50 cursor-not-allowed':
!segments || segments.length === 0, !segments || segments.length === 0 || !c.active,
}" }"
:title=" :title="
segments && segments.length !c.active
? 'Segmenta ni mogoče spremeniti za arhivirano pogodbo'
: segments && segments.length
? 'Spremeni segment' ? 'Spremeni segment'
: 'Ni segmentov na voljo za ta primer' : 'Ni segmentov na voljo za ta primer'
" "
:disabled="!c.active || !segments || !segments.length"
> >
<FontAwesomeIcon <FontAwesomeIcon
:icon="faPenToSquare" :icon="faPenToSquare"
@ -313,6 +321,11 @@ const closePaymentsDialog = () => {
</div> </div>
</template> </template>
</Dropdown> </Dropdown>
<span
v-if="!c.active"
class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-gray-200 text-gray-700 uppercase tracking-wide"
>Arhivirano</span
>
</div> </div>
</FwbTableCell> </FwbTableCell>
<FwbTableCell class="text-right">{{ <FwbTableCell class="text-right">{{
@ -433,6 +446,7 @@ const closePaymentsDialog = () => {
<button <button
type="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" class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
v-if="c.active"
@click="onEdit(c)" @click="onEdit(c)"
> >
<FontAwesomeIcon <FontAwesomeIcon
@ -444,6 +458,7 @@ const closePaymentsDialog = () => {
<button <button
type="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" class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
v-if="c.active"
@click="onAddActivity(c)" @click="onAddActivity(c)"
> >
<FontAwesomeIcon :icon="faListCheck" class="h-4 w-4 text-gray-600" /> <FontAwesomeIcon :icon="faListCheck" class="h-4 w-4 text-gray-600" />
@ -468,6 +483,7 @@ const closePaymentsDialog = () => {
<button <button
type="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" class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
v-if="c.active"
@click="openObjectDialog(c)" @click="openObjectDialog(c)"
> >
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" /> <FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" />
@ -492,12 +508,62 @@ const closePaymentsDialog = () => {
<button <button
type="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" class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
v-if="c.active && c?.account"
@click="openPaymentDialog(c)" @click="openPaymentDialog(c)"
> >
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" /> <FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" />
<span>Dodaj plačilo</span> <span>Dodaj plačilo</span>
</button> </button>
<div class="my-1 border-t border-gray-100" />
<!-- Arhiviranje / Ponovna aktivacija -->
<div
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
>
{{ c.active ? "Arhiviranje" : "Ponovna aktivacija" }}
</div>
<button
v-if="c.active"
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="
router.post(
route('clientCase.contract.archive', {
client_case: client_case.uuid,
uuid: c.uuid,
}),
{},
{
preserveScroll: true,
only: ['contracts', 'activities', 'documents'],
}
)
"
>
<FontAwesomeIcon :icon="faBoxArchive" class="h-4 w-4 text-gray-600" />
<span>Arhiviraj</span>
</button>
<button
v-else
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="
router.post(
route('clientCase.contract.archive', {
client_case: client_case.uuid,
uuid: c.uuid,
}),
{ reactivate: true },
{
preserveScroll: true,
only: ['contracts', 'activities', 'documents'],
}
)
"
>
<FontAwesomeIcon :icon="faBoxArchive" class="h-4 w-4 text-gray-600" />
<span>Ponovno aktiviraj</span>
</button>
<div class="my-1 border-t border-gray-100" /> <div class="my-1 border-t border-gray-100" />
<!-- Destruktivno --> <!-- Destruktivno -->
<button <button

View File

@ -373,7 +373,14 @@ function referenceOf(entityName, ent) {
<span>{{ activeEntity }}</span> <span>{{ activeEntity }}</span>
<span <span
v-if="r.entities[activeEntity].action_label" v-if="r.entities[activeEntity].action_label"
class="text-[10px] px-1 py-0.5 rounded bg-gray-100" :class="[
'text-[10px] px-1 py-0.5 rounded',
r.entities[activeEntity].action === 'create' && 'bg-emerald-100 text-emerald-700',
r.entities[activeEntity].action === 'update' && 'bg-blue-100 text-blue-700',
r.entities[activeEntity].action === 'reactivate' && 'bg-purple-100 text-purple-700 font-semibold',
r.entities[activeEntity].action === 'skip' && 'bg-gray-100 text-gray-600',
r.entities[activeEntity].action === 'implicit' && 'bg-teal-100 text-teal-700'
].filter(Boolean)"
>{{ r.entities[activeEntity].action_label }}</span >{{ r.entities[activeEntity].action_label }}</span
> >
<span <span
@ -502,10 +509,25 @@ function referenceOf(entityName, ent) {
</div> </div>
<div> <div>
Akcija: Akcija:
<span class="font-medium">{{ <span
:class="[
'font-medium inline-flex items-center gap-1',
r.entities[activeEntity].action === 'reactivate' && 'text-purple-700'
].filter(Boolean)"
>{{
r.entities[activeEntity].action_label || r.entities[activeEntity].action_label ||
r.entities[activeEntity].action r.entities[activeEntity].action
}}</span> }}
<span
v-if="r.entities[activeEntity].reactivation"
class="text-[9px] px-1 py-0.5 rounded bg-purple-100 text-purple-700"
title="Pogodba bo reaktivirana"
>react</span
></span
>
</div>
<div v-if="r.entities[activeEntity].original_action === 'update' && r.entities[activeEntity].action === 'reactivate'" class="text-[10px] text-purple-600 mt-0.5">
(iz neaktivnega aktivno)
</div> </div>
</template> </template>
<template v-else> <template v-else>

View File

@ -18,6 +18,7 @@ const form = useForm({
source_type: "csv", source_type: "csv",
default_record_type: "", default_record_type: "",
is_active: true, is_active: true,
reactivate: false,
client_uuid: null, client_uuid: null,
entities: [], entities: [],
meta: { meta: {
@ -285,6 +286,10 @@ watch(
<label for="is_active" class="text-sm font-medium text-gray-700" <label for="is_active" class="text-sm font-medium text-gray-700"
>Active</label >Active</label
> >
<div class="flex items-center gap-2 ml-6">
<input id="reactivate" v-model="form.reactivate" type="checkbox" class="rounded" />
<label for="reactivate" class="text-sm font-medium text-gray-700">Reactivation import</label>
</div>
</div> </div>
<div class="pt-4"> <div class="pt-4">

View File

@ -20,6 +20,7 @@ const form = useForm({
source_type: props.template.source_type, source_type: props.template.source_type,
default_record_type: props.template.default_record_type || "", default_record_type: props.template.default_record_type || "",
is_active: props.template.is_active, is_active: props.template.is_active,
reactivate: props.template.reactivate ?? false,
client_uuid: props.template.client_uuid || null, client_uuid: props.template.client_uuid || null,
sample_headers: props.template.sample_headers || [], sample_headers: props.template.sample_headers || [],
// Add meta with default delimiter support // Add meta with default delimiter support
@ -434,9 +435,11 @@ watch(
type="checkbox" type="checkbox"
class="rounded" class="rounded"
/> />
<label for="is_active" class="text-sm font-medium text-gray-700" <label for="is_active" class="text-sm font-medium text-gray-700">Aktivna</label>
>Aktivna</label <div class="flex items-center gap-2 ml-6">
> <input id="reactivate" v-model="form.reactivate" type="checkbox" class="rounded" />
<label for="reactivate" class="text-sm font-medium text-gray-700">Reaktivacija</label>
</div>
<button <button
@click.prevent="save" @click.prevent="save"
class="ml-auto px-3 py-2 bg-indigo-600 text-white rounded" class="ml-auto px-3 py-2 bg-indigo-600 text-white rounded"

View File

@ -73,6 +73,27 @@ function formatAmount(val) {
}); });
} }
function formatDateShort(val) {
if (!val) return "";
try {
const d = new Date(val);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("sl-SI", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return "";
}
}
function activityActionLine(a) {
const base = a?.action?.name || "";
const decision = a?.decision?.name ? `${a.decision.name}` : "";
return base + decision;
}
// Activity drawer state // Activity drawer state
const drawerAddActivity = ref(false); const drawerAddActivity = ref(false);
const activityContractUuid = ref(null); const activityContractUuid = ref(null);
@ -139,6 +160,35 @@ const submitComplete = () => {
}, },
}); });
}; };
// Contracts objects (Predmeti) modal state
const objectsModal = reactive({ open: false, items: [], contract: null });
function getContractObjects(c) {
if (!c) return [];
// Try a few common property names; fallback empty
return c.objects || c.contract_objects || c.items || [];
}
function openObjectsModal(c) {
objectsModal.contract = c;
objectsModal.items = getContractObjects(c) || [];
objectsModal.open = true;
}
function closeObjectsModal() {
objectsModal.open = false;
objectsModal.items = [];
objectsModal.contract = null;
}
// Client details (Stranka) summary
const clientSummary = computed(() => {
const p = props.client?.person || {};
return {
name: p.full_name || p.name || "—",
tax: p.tax_number || p.davcna || p.tax || null,
emso: p.emso || p.ems || null,
trr: p.trr || p.bank_account || null,
};
});
</script> </script>
<template> <template>
@ -172,10 +222,13 @@ const submitComplete = () => {
<!-- Client details (account holder) --> <!-- Client details (account holder) -->
<div class="bg-white rounded-lg shadow border overflow-hidden"> <div class="bg-white rounded-lg shadow border overflow-hidden">
<div class="p-3 sm:p-4"> <div class="p-3 sm:p-4">
<SectionTitle> <h3
<template #title>Stranka</template> class="text-base font-semibold text-gray-900 leading-tight flex items-center gap-2"
</SectionTitle> >
<div class="mt-2"> <span class="truncate">{{ clientSummary.name }}</span>
<span class="chip-base chip-indigo">Naročnik</span>
</h3>
<div class="mt-4 pt-4 border-t border-dashed">
<PersonDetailPhone <PersonDetailPhone
:types="types" :types="types"
:person="client.person" :person="client.person"
@ -188,14 +241,17 @@ const submitComplete = () => {
<!-- Person (case person) --> <!-- Person (case person) -->
<div class="bg-white rounded-lg shadow border overflow-hidden"> <div class="bg-white rounded-lg shadow border overflow-hidden">
<div class="p-3 sm:p-4"> <div class="p-3 sm:p-4">
<SectionTitle> <h3
<template #title>Primer - oseba</template> class="text-base font-semibold text-gray-900 leading-tight flex items-center gap-2"
</SectionTitle> >
<div class="mt-2"> <span class="truncate">{{ client_case.person.full_name }}</span>
<span class="chip-base chip-indigo">Primer</span>
</h3>
<div class="mt-4 pt-4 border-t border-dashed">
<PersonDetailPhone <PersonDetailPhone
:types="types" :types="types"
:person="client_case.person" :person="client_case.person"
default-tab="phones" default-tab="addresses"
/> />
</div> </div>
</div> </div>
@ -211,48 +267,82 @@ const submitComplete = () => {
<div <div
v-for="c in contracts" v-for="c in contracts"
:key="c.uuid || c.id" :key="c.uuid || c.id"
class="rounded border p-3 sm:p-4" class="rounded border p-3 sm:p-4 bg-white shadow-sm"
> >
<div class="flex items-center justify-between"> <!-- Header Row -->
<div> <div class="flex items-start justify-between gap-3">
<p class="font-medium text-gray-900">{{ c.reference || c.uuid }}</p> <div class="min-w-0">
<p class="text-sm text-gray-600">Tip: {{ c.type?.name || "—" }}</p> <div class="flex items-center gap-2 flex-wrap">
</div> <p
<div class="text-right"> class="font-semibold text-gray-900 text-sm leading-tight truncate"
<div class="space-y-2"> >
<p v-if="c.account" class="text-sm text-gray-700"> {{ c.reference || c.uuid }}
Odprto: {{ formatAmount(c.account.balance_amount) }}
</p> </p>
<span
v-if="c.type?.name"
class="inline-flex items-center px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 text-[11px] font-medium"
>
{{ c.type.name }}
</span>
</div>
<div v-if="c.account" class="mt-2 flex items-baseline gap-2">
<span class="uppercase tracking-wide text-[11px] text-gray-400"
>Odprto</span
>
<span
class="text-lg font-semibold text-gray-900 leading-none tracking-tight"
>{{ formatAmount(c.account.balance_amount) }} </span
>
</div>
</div>
<div class="flex flex-col gap-1.5 w-32 text-right shrink-0">
<button <button
type="button" type="button"
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 w-full sm:w-auto" class="text-sm px-3 py-2 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 active:scale-[.97] transition shadow"
@click="openDrawerAddActivity(c)" @click="openDrawerAddActivity(c)"
> >
+ Aktivnost + Aktivnost
</button> </button>
<button <button
type="button" type="button"
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 w-full sm:w-auto" class="text-sm px-3 py-2 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 active:scale-[.97] transition shadow"
@click="openDocDialog(c)" @click="openDocDialog(c)"
> >
+ Dokument + Dokument
</button> </button>
<!--button
type="button"
:disabled="!getContractObjects(c).length"
@click="openObjectsModal(c)"
class="relative text-sm px-3 py-2 rounded-md flex items-center justify-center transition disabled:cursor-not-allowed disabled:opacity-50 bg-slate-600 text-white hover:bg-slate-700 active:scale-[.97] shadow"
>
Predmeti
<span
class="ml-1 inline-flex items-center justify-center min-w-[1.1rem] h-5 text-[11px] px-1.5 rounded-full bg-white/90 text-slate-700 font-medium"
>{{ getContractObjects(c).length }}</span
>
</button-->
</div> </div>
</div> </div>
</div> <!-- Subject / Last Object -->
<div v-if="c.last_object" class="mt-2 text-sm text-gray-700"> <div v-if="c.last_object" class="mt-3 border-t pt-3">
<p class="font-medium">Predmet:</p> <p class="text-[11px] uppercase tracking-wide text-gray-400 mb-1">
<p> Zadnji predmet
<span class="text-gray-900">{{ </p>
c.last_object.name || c.last_object.reference <div class="text-sm font-medium text-gray-800">
}}</span> {{ c.last_object.name || c.last_object.reference }}
<span v-if="c.last_object.type" class="ml-2 text-gray-500" <span
v-if="c.last_object.type"
class="ml-2 text-xs font-normal text-gray-500"
>({{ c.last_object.type }})</span >({{ c.last_object.type }})</span
> >
</p> </div>
<p v-if="c.last_object.description" class="text-gray-600 mt-1"> <div
v-if="c.last_object.description"
class="mt-1 text-sm text-gray-600 leading-snug"
>
{{ c.last_object.description }} {{ c.last_object.description }}
</p> </div>
</div> </div>
</div> </div>
<p v-if="!contracts?.length" class="text-sm text-gray-600"> <p v-if="!contracts?.length" class="text-sm text-gray-600">
@ -270,40 +360,66 @@ const submitComplete = () => {
<template #title>Aktivnosti</template> <template #title>Aktivnosti</template>
</SectionTitle> </SectionTitle>
<button <button
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" class="text-xs font-medium px-3 py-2 rounded-md bg-indigo-600 text-white shadow-sm active:scale-[.98] hover:bg-indigo-700"
@click="openDrawerAddActivity()" @click="openDrawerAddActivity()"
> >
Nova Nova
</button> </button>
</div> </div>
<div class="mt-2 divide-y"> <div class="mt-3 space-y-3">
<div v-for="a in activities" :key="a.id" class="py-2 text-sm"> <div
<div class="flex items-center justify-between"> v-for="a in activities"
<div class="text-gray-800"> :key="a.id"
{{ a.action?.name class="rounded-md border border-gray-200 bg-gray-50/70 px-3 py-3 shadow-sm text-[13px]"
}}<span v-if="a.decision"> {{ a.decision?.name }}</span>
</div>
<div class="text-right text-gray-500">
<div v-if="a.contract">Pogodba: {{ a.contract.reference }}</div>
<div class="text-xs" v-if="a.created_at || a.user || a.user_name">
<span v-if="a.created_at">{{
new Date(a.created_at).toLocaleDateString("sl-SI")
}}</span>
<span v-if="(a.user && a.user.name) || a.user_name" class="ml-1"
>· {{ a.user?.name || a.user_name }}</span
> >
<!-- Top line: action + date/user -->
<div class="flex items-start justify-between gap-3">
<div class="font-medium text-gray-800 leading-snug truncate">
{{ activityActionLine(a) || "Aktivnost" }}
</div>
<div
class="shrink-0 text-right text-[11px] text-gray-500 leading-tight"
>
<div v-if="a.created_at">{{ formatDateShort(a.created_at) }}</div>
<div v-if="(a.user && a.user.name) || a.user_name" class="truncate">
{{ a.user?.name || a.user_name }}
</div> </div>
</div> </div>
</div> </div>
<div v-if="a.note" class="text-gray-600">{{ a.note }}</div>
<div class="text-gray-500"> <!-- Badges row -->
<span v-if="a.due_date">Zapadlost: {{ a.due_date }}</span> <div class="mt-2 flex flex-wrap gap-1.5">
<span v-if="a.amount != null" class="ml-2" <span
v-if="a.contract"
class="inline-flex items-center rounded-full bg-indigo-100 text-indigo-700 px-2 py-0.5 text-[10px] font-medium"
>Pogodba: {{ a.contract.reference }}</span
>
<span
v-if="a.due_date"
class="inline-flex items-center rounded-full bg-amber-100 text-amber-700 px-2 py-0.5 text-[10px] font-medium"
>Zapadlost: {{ formatDateShort(a.due_date) || a.due_date }}</span
>
<span
v-if="a.amount != null"
class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-700 px-2 py-0.5 text-[10px] font-medium"
>Znesek: {{ formatAmount(a.amount) }} </span >Znesek: {{ formatAmount(a.amount) }} </span
> >
<span
v-if="a.status"
class="inline-flex items-center rounded-full bg-gray-200 text-gray-700 px-2 py-0.5 text-[10px] font-medium"
>{{ a.status }}</span
>
</div>
<!-- Note -->
<div v-if="a.note" class="mt-2 text-gray-700 leading-snug">
{{ a.note }}
</div> </div>
</div> </div>
<div v-if="!activities?.length" class="text-gray-600 py-2"> <div
v-if="!activities?.length"
class="text-gray-600 text-sm py-2 text-center"
>
Ni aktivnosti. Ni aktivnosti.
</div> </div>
</div> </div>
@ -423,6 +539,65 @@ const submitComplete = () => {
</template> </template>
</ConfirmationModal> </ConfirmationModal>
<!-- Contract Objects (Predmeti) Modal -->
<DialogModal :show="objectsModal.open" @close="closeObjectsModal">
<template #title>
Predmeti
<span
v-if="objectsModal.contract"
class="block text-xs font-normal text-gray-500 mt-0.5"
>
{{ objectsModal.contract.reference || objectsModal.contract.uuid }}
</span>
</template>
<template #content>
<div
v-if="objectsModal.items.length"
class="space-y-3 max-h-[60vh] overflow-y-auto pr-1"
>
<div
v-for="(o, idx) in objectsModal.items"
:key="o.id || o.uuid || idx"
class="rounded border border-gray-200 bg-gray-50 px-3 py-2 text-sm"
>
<div class="font-medium text-gray-800 truncate">
{{ o.name || o.reference || "#" + (o.id || o.uuid || idx + 1) }}
</div>
<div class="mt-0.5 text-xs text-gray-500 flex flex-wrap gap-x-2 gap-y-0.5">
<span
v-if="o.type"
class="inline-flex items-center bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded-full"
>{{ o.type }}</span
>
<span
v-if="o.status"
class="inline-flex items-center bg-gray-200 text-gray-700 px-1.5 py-0.5 rounded-full"
>{{ o.status }}</span
>
<span
v-if="o.amount != null"
class="inline-flex items-center bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded-full"
>{{ formatAmount(o.amount) }} </span
>
</div>
<div v-if="o.description" class="mt-1 text-gray-600 leading-snug">
{{ o.description }}
</div>
</div>
</div>
<div v-else class="text-gray-600 text-sm">Ni predmetov.</div>
</template>
<template #footer>
<button
type="button"
class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200"
@click="closeObjectsModal"
>
Zapri
</button>
</template>
</DialogModal>
<!-- Upload Document Modal --> <!-- Upload Document Modal -->
<DialogModal :show="docDialogOpen" @close="closeDocDialog"> <DialogModal :show="docDialogOpen" @close="closeDocDialog">
<template #title>Dodaj dokument</template> <template #title>Dodaj dokument</template>
@ -493,4 +668,27 @@ const submitComplete = () => {
</AppPhoneLayout> </AppPhoneLayout>
</template> </template>
<style scoped></style> <style scoped>
/* Using basic CSS since @apply is not processed in this scoped block by default */
.chip-base {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem; /* py-0.5 px-2 */
border-radius: 9999px;
font-size: 11px;
font-weight: 500;
line-height: 1.1;
}
.chip-indigo {
background: #eef2ff;
color: #3730a3;
} /* approx indigo-50 / indigo-700 */
.chip-default {
background: #f1f5f9;
color: #334155;
} /* slate-100 / slate-700 */
.chip-emerald {
background: #ecfdf5;
color: #047857;
} /* emerald-50 / emerald-700 */
</style>

View File

@ -0,0 +1,591 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { useForm, router } from "@inertiajs/vue3";
import { ref } from "vue";
const props = defineProps({
settings: Object,
archiveEntities: Array,
actions: Array,
segments: Array,
chainPatterns: Array,
});
const newForm = useForm({
name: "",
description: "",
enabled: true,
strategy: "immediate",
soft: true,
reactivate: false,
focus: "",
related: [],
entities: [],
action_id: null,
decision_id: null,
segment_id: null,
options: { batch_size: 200 },
});
// Editing state & form
const editingSetting = ref(null);
// Conditions temporarily inactive in backend; keep placeholder for future restore
const originalEntityMeta = ref({ columns: ["id"] });
const editForm = useForm({
name: "",
description: "",
enabled: true,
strategy: "immediate",
soft: true,
reactivate: false,
focus: "",
related: [],
entities: [],
action_id: null,
decision_id: null,
segment_id: null,
options: { batch_size: 200 },
});
const selectedEntity = ref(null);
function onFocusChange() {
const found = props.archiveEntities.find((e) => e.focus === newForm.focus);
selectedEntity.value = found || null;
newForm.related = [];
}
function submitCreate() {
if (!newForm.focus) {
alert("Select a focus entity.");
return;
}
if (newForm.decision_id && !newForm.action_id) {
alert("Select an action before choosing a decision.");
return;
}
newForm.entities = [
{
table: newForm.focus,
related: newForm.related,
// conditions omitted while inactive
columns: ["id"],
},
];
newForm.post(route("settings.archive.store"), {
onSuccess: () => {
newForm.focus = "";
newForm.related = [];
newForm.entities = [];
newForm.action_id = null;
newForm.decision_id = null;
newForm.segment_id = null;
selectedEntity.value = null;
},
});
}
function toggleEnabled(setting) {
router.put(route("settings.archive.update", setting.id), {
...setting,
enabled: !setting.enabled,
});
}
function startEdit(setting) {
editingSetting.value = setting;
// Populate editForm
editForm.name = setting.name || "";
editForm.description = setting.description || "";
editForm.enabled = setting.enabled;
editForm.strategy = setting.strategy || "immediate";
editForm.soft = setting.soft;
editForm.reactivate = setting.reactivate ?? false;
editForm.action_id = setting.action_id ?? null;
editForm.decision_id = setting.decision_id ?? null;
editForm.segment_id = setting.segment_id ?? null;
// Entities (first only)
const first = Array.isArray(setting.entities) ? setting.entities[0] : null;
if (first) {
editForm.focus = first.table || "";
editForm.related = first.related || [];
originalEntityMeta.value = {
columns: first.columns || ["id"],
};
const found = props.archiveEntities.find((e) => e.focus === editForm.focus);
selectedEntity.value = found || null;
} else {
editForm.focus = "";
editForm.related = [];
originalEntityMeta.value = { columns: ["id"] };
// If reactivate is checked it implies soft semantics; keep soft true (UI might show both)
}
}
function cancelEdit() {
editingSetting.value = null;
editForm.reset();
selectedEntity.value = null;
}
function submitUpdate() {
if (!editingSetting.value) return;
if (!editForm.focus) {
alert("Select a focus entity.");
return;
}
if (editForm.decision_id && !editForm.action_id) {
alert("Select an action before choosing a decision.");
return;
}
editForm.entities = [
{
table: editForm.focus,
related: editForm.related,
// conditions omitted while inactive
columns: originalEntityMeta.value.columns || ["id"],
},
];
editForm.put(route("settings.archive.update", editingSetting.value.id), {
onSuccess: () => {
cancelEdit();
},
});
}
function remove(setting) {
if (!confirm("Delete archive rule?")) return;
router.delete(route("settings.archive.destroy", setting.id));
}
// Run Now removed (feature temporarily disabled)
</script>
<template>
<AppLayout title="Archive Settings">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Archive Settings</h2>
</template>
<div class="py-6 max-w-6xl mx-auto px-4">
<div class="mb-6 border-l-4 border-amber-500 bg-amber-50 text-amber-800 px-4 py-3 rounded">
<p class="text-sm font-medium">Archive rule conditions are temporarily inactive.</p>
<p class="text-xs mt-1">All enabled rules apply to the focus entity and its selected related tables without date/other filters. Stored condition JSON is preserved for future reactivation.</p>
<p class="text-xs mt-1 font-medium">The "Run Now" action is currently disabled.</p>
<div class="mt-3 text-xs bg-white/60 rounded p-3 border border-amber-200">
<p class="font-semibold mb-1 text-amber-900">Chain Path Help</p>
<p class="mb-1">Supported chained related tables (dot notation):</p>
<ul class="list-disc ml-4 space-y-0.5">
<li v-for="cp in chainPatterns" :key="cp">
<code class="px-1 bg-amber-100 rounded">{{ cp }}</code>
</li>
</ul>
<p class="mt-1 italic">Only these chains are processed; others are ignored.</p>
</div>
</div>
<div class="grid gap-6 md:grid-cols-3">
<div class="md:col-span-2 space-y-4">
<div
v-for="s in settings.data"
:key="s.id"
class="border rounded-lg p-4 bg-white shadow-sm"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h3 class="font-medium text-gray-900 flex items-center gap-2">
<span class="truncate">{{ s.name || "Untitled Rule #" + s.id }}</span>
<span
v-if="!s.enabled"
class="inline-flex text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-800"
>Disabled</span
>
</h3>
<p v-if="s.description" class="text-sm text-gray-600 mt-1">
{{ s.description }}
</p>
<p class="mt-2 text-xs text-gray-500">
Strategy: {{ s.strategy }} Soft: {{ s.soft ? "Yes" : "No" }}
</p>
</div>
<div class="flex flex-col items-end gap-2 shrink-0">
<button
@click="startEdit(s)"
class="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-800 hover:bg-gray-300"
>
Edit
</button>
<!-- Run Now removed -->
<button
@click="toggleEnabled(s)"
class="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700"
>
{{ s.enabled ? "Disable" : "Enable" }}
</button>
<button
@click="remove(s)"
class="text-xs px-3 py-1.5 rounded bg-red-600 text-white hover:bg-red-700"
>
Delete
</button>
</div>
</div>
<div class="mt-3 text-xs bg-gray-50 border rounded p-2 overflow-x-auto">
<pre class="whitespace-pre-wrap">{{
JSON.stringify(s.entities, null, 2)
}}</pre>
</div>
</div>
<div v-if="!settings.data.length" class="text-sm text-gray-600">
No archive rules.
</div>
</div>
<div class="space-y-4">
<div v-if="!editingSetting" class="border rounded-lg p-4 bg-white shadow-sm">
<h3 class="font-semibold text-gray-900 mb-2 text-sm">New Rule</h3>
<div class="space-y-3 text-sm">
<div>
<label class="block text-xs font-medium text-gray-600"
>Segment (optional)</label
>
<select
v-model="newForm.segment_id"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option :value="null">-- none --</option>
<option v-for="seg in segments" :key="seg.id" :value="seg.id">
{{ seg.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Action (optional)</label
>
<select
v-model="newForm.action_id"
@change="
() => {
newForm.decision_id = null;
}
"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option :value="null">-- none --</option>
<option v-for="a in actions" :key="a.id" :value="a.id">
{{ a.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Decision (optional)</label
>
<select
v-model="newForm.decision_id"
:disabled="!newForm.action_id"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option :value="null">-- none --</option>
<option
v-for="d in actions.find((a) => a.id === newForm.action_id)
?.decisions || []"
:key="d.id"
:value="d.id"
>
{{ d.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Name</label>
<input
v-model="newForm.name"
type="text"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
/>
<div v-if="newForm.errors.name" class="text-red-600 text-xs mt-1">
{{ newForm.errors.name }}
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Focus Entity</label
>
<select
v-model="newForm.focus"
@change="onFocusChange"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option value="" disabled>-- choose --</option>
<option v-for="ae in archiveEntities" :key="ae.id" :value="ae.focus">
{{ ae.name || ae.focus }}
</option>
</select>
</div>
<div v-if="selectedEntity" class="space-y-1">
<div class="text-xs font-medium text-gray-600">Related Tables</div>
<div class="flex flex-wrap gap-2">
<label
v-for="r in selectedEntity.related"
:key="r"
class="inline-flex items-center gap-1 text-xs bg-gray-100 px-2 py-1 rounded border"
>
<input
type="checkbox"
:value="r"
v-model="newForm.related"
class="rounded"
/>
<span>{{ r }}</span>
</label>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Description</label>
<textarea
v-model="newForm.description"
rows="2"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
></textarea>
<div v-if="newForm.errors.description" class="text-red-600 text-xs mt-1">
{{ newForm.errors.description }}
</div>
</div>
<div class="flex items-center gap-2">
<input id="enabled" type="checkbox" v-model="newForm.enabled" />
<label for="enabled" class="text-xs font-medium text-gray-700"
>Enabled</label
>
</div>
<div class="flex items-center gap-2">
<input id="soft" type="checkbox" v-model="newForm.soft" />
<label for="soft" class="text-xs font-medium text-gray-700"
>Soft Archive</label
>
</div>
<div class="flex items-center gap-2">
<input id="reactivate" type="checkbox" v-model="newForm.reactivate" />
<label for="reactivate" class="text-xs font-medium text-gray-700"
>Reactivate (undo archive)</label
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Strategy</label>
<select
v-model="newForm.strategy"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option value="immediate">Immediate</option>
<option value="scheduled">Scheduled</option>
<option value="queued">Queued</option>
<option value="manual">Manual (never auto-run)</option>
</select>
<div v-if="newForm.errors.strategy" class="text-red-600 text-xs mt-1">
{{ newForm.errors.strategy }}
</div>
</div>
<button
@click="submitCreate"
type="button"
:disabled="newForm.processing"
class="w-full text-sm px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50"
>
Create
</button>
<div v-if="Object.keys(newForm.errors).length" class="text-xs text-red-600">
Please fix validation errors.
</div>
</div>
</div>
<div v-else class="border rounded-lg p-4 bg-white shadow-sm">
<h3 class="font-semibold text-gray-900 mb-2 text-sm">
Edit Rule #{{ editingSetting.id }}
</h3>
<div class="space-y-3 text-sm">
<div
class="text-xs text-gray-500"
v-if="editingSetting.strategy === 'manual'"
>
Manual strategy: this rule will only run when triggered manually.
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Segment (optional)</label
>
<select
v-model="editForm.segment_id"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option :value="null">-- none --</option>
<option v-for="seg in segments" :key="seg.id" :value="seg.id">
{{ seg.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Action (optional)</label
>
<select
v-model="editForm.action_id"
@change="
() => {
editForm.decision_id = null;
}
"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option :value="null">-- none --</option>
<option v-for="a in actions" :key="a.id" :value="a.id">
{{ a.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Decision (optional)</label
>
<select
v-model="editForm.decision_id"
:disabled="!editForm.action_id"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option :value="null">-- none --</option>
<option
v-for="d in actions.find((a) => a.id === editForm.action_id)
?.decisions || []"
:key="d.id"
:value="d.id"
>
{{ d.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Name</label>
<input
v-model="editForm.name"
type="text"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
/>
<div v-if="editForm.errors.name" class="text-red-600 text-xs mt-1">
{{ editForm.errors.name }}
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600"
>Focus Entity</label
>
<select
v-model="editForm.focus"
@change="onFocusChange() /* reuse selectedEntity for preview */"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option value="" disabled>-- choose --</option>
<option v-for="ae in archiveEntities" :key="ae.id" :value="ae.focus">
{{ ae.name || ae.focus }}
</option>
</select>
</div>
<div
v-if="selectedEntity && editForm.focus === selectedEntity.focus"
class="space-y-1"
>
<div class="text-xs font-medium text-gray-600">Related Tables</div>
<div class="flex flex-wrap gap-2">
<label
v-for="r in selectedEntity.related"
:key="r"
class="inline-flex items-center gap-1 text-xs bg-gray-100 px-2 py-1 rounded border"
>
<input
type="checkbox"
:value="r"
v-model="editForm.related"
class="rounded"
/>
<span>{{ r }}</span>
</label>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Description</label>
<textarea
v-model="editForm.description"
rows="2"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
></textarea>
<div v-if="editForm.errors.description" class="text-red-600 text-xs mt-1">
{{ editForm.errors.description }}
</div>
</div>
<div class="flex items-center gap-2">
<input id="edit_enabled" type="checkbox" v-model="editForm.enabled" />
<label for="edit_enabled" class="text-xs font-medium text-gray-700"
>Enabled</label
>
</div>
<div class="flex items-center gap-2">
<input id="edit_soft" type="checkbox" v-model="editForm.soft" />
<label for="edit_soft" class="text-xs font-medium text-gray-700"
>Soft Archive</label
>
</div>
<div class="flex items-center gap-2">
<input id="edit_reactivate" type="checkbox" v-model="editForm.reactivate" />
<label for="edit_reactivate" class="text-xs font-medium text-gray-700"
>Reactivate (undo archive)</label
>
</div>
<div>
<label class="block text-xs font-medium text-gray-600">Strategy</label>
<select
v-model="editForm.strategy"
class="mt-1 w-full border rounded px-2 py-1.5 text-sm"
>
<option value="immediate">Immediate</option>
<option value="scheduled">Scheduled</option>
<option value="queued">Queued</option>
<option value="manual">Manual (never auto-run)</option>
</select>
<div v-if="editForm.errors.strategy" class="text-red-600 text-xs mt-1">
{{ editForm.errors.strategy }}
</div>
</div>
<div class="flex gap-2">
<button
@click="submitUpdate"
type="button"
:disabled="editForm.processing"
class="flex-1 text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
>
Update
</button>
<button
@click="cancelEdit"
type="button"
class="px-3 py-2 rounded text-sm bg-gray-200 hover:bg-gray-300"
>
Cancel
</button>
</div>
<div
v-if="Object.keys(editForm.errors).length"
class="text-xs text-red-600"
>
Please fix validation errors.
</div>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<style scoped>
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
monospace;
}
</style>

View File

@ -1,6 +1,6 @@
<script setup> <script setup>
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from "@/Layouts/AppLayout.vue";
import { Link } from '@inertiajs/vue3'; import { Link } from "@inertiajs/vue3";
</script> </script>
<template> <template>
@ -12,27 +12,72 @@ import { Link } from '@inertiajs/vue3';
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6"> <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Segments</h3> <h3 class="text-lg font-semibold mb-2">Segments</h3>
<p class="text-sm text-gray-600 mb-4">Manage segments used across the app.</p> <p class="text-sm text-gray-600 mb-4">Manage segments used across the app.</p>
<Link :href="route('settings.segments')" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Open Segments</Link> <Link
:href="route('settings.segments')"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Open Segments</Link
>
</div> </div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6"> <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Payments</h3> <h3 class="text-lg font-semibold mb-2">Payments</h3>
<p class="text-sm text-gray-600 mb-4">Defaults for payments and auto-activity.</p> <p class="text-sm text-gray-600 mb-4">
<Link :href="route('settings.payment.edit')" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Open Payment Settings</Link> Defaults for payments and auto-activity.
</p>
<Link
:href="route('settings.payment.edit')"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Open Payment Settings</Link
>
</div> </div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6"> <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Workflow</h3> <h3 class="text-lg font-semibold mb-2">Workflow</h3>
<p class="text-sm text-gray-600 mb-4">Configure actions and decisions relationships.</p> <p class="text-sm text-gray-600 mb-4">
<Link :href="route('settings.workflow')" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Open Workflow</Link> Configure actions and decisions relationships.
</p>
<Link
:href="route('settings.workflow')"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Open Workflow</Link
>
</div> </div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6"> <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Field Job Settings</h3> <h3 class="text-lg font-semibold mb-2">Field Job Settings</h3>
<p class="text-sm text-gray-600 mb-4">Configure segment-based field job rules.</p> <p class="text-sm text-gray-600 mb-4">
<Link :href="route('settings.fieldjob.index')" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Open Field Job</Link> Configure segment-based field job rules.
</p>
<Link
:href="route('settings.fieldjob.index')"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Open Field Job</Link
>
</div> </div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6"> <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Contract Configs</h3> <h3 class="text-lg font-semibold mb-2">Contract Configs</h3>
<p class="text-sm text-gray-600 mb-4">Auto-assign initial segments for contracts by type.</p> <p class="text-sm text-gray-600 mb-4">
<Link :href="route('settings.contractConfigs.index')" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Open Contract Configs</Link> Auto-assign initial segments for contracts by type.
</p>
<Link
:href="route('settings.contractConfigs.index')"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Open Contract Configs</Link
>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<h3 class="text-lg font-semibold mb-2">Archive Settings</h3>
<p class="text-sm text-gray-600 mb-4">
Define rules for archiving or soft-deleting aged data.
</p>
<Link
:href="route('settings.archive.index')"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Open Archive Settings</Link
>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,6 +3,7 @@
use App\Charts\ExampleChart; use App\Charts\ExampleChart;
use App\Http\Controllers\AccountBookingController; use App\Http\Controllers\AccountBookingController;
use App\Http\Controllers\AccountPaymentController; use App\Http\Controllers\AccountPaymentController;
use App\Http\Controllers\ArchiveSettingController;
use App\Http\Controllers\CaseObjectController; use App\Http\Controllers\CaseObjectController;
use App\Http\Controllers\ClientCaseContoller; use App\Http\Controllers\ClientCaseContoller;
use App\Http\Controllers\ClientController; use App\Http\Controllers\ClientController;
@ -149,6 +150,7 @@
Route::get('client-cases', [ClientCaseContoller::class, 'index'])->name('clientCase'); Route::get('client-cases', [ClientCaseContoller::class, 'index'])->name('clientCase');
Route::get('client-cases/{client_case:uuid}', [ClientCaseContoller::class, 'show'])->name('clientCase.show'); Route::get('client-cases/{client_case:uuid}', [ClientCaseContoller::class, 'show'])->name('clientCase.show');
Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/segment', [ClientCaseContoller::class, 'updateContractSegment'])->name('clientCase.contract.updateSegment'); Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/segment', [ClientCaseContoller::class, 'updateContractSegment'])->name('clientCase.contract.updateSegment');
Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/archive', [ClientCaseContoller::class, 'archiveContract'])->name('clientCase.contract.archive');
Route::post('client-cases', [ClientCaseContoller::class, 'store'])->name('clientCase.store'); Route::post('client-cases', [ClientCaseContoller::class, 'store'])->name('clientCase.store');
// client-case / contract // client-case / contract
Route::post('client-cases/{client_case:uuid}/contract', [ClientCaseContoller::class, 'storeContract'])->name('clientCase.contract.store'); Route::post('client-cases/{client_case:uuid}/contract', [ClientCaseContoller::class, 'storeContract'])->name('clientCase.contract.store');
@ -177,6 +179,12 @@
Route::get('settings/segments', [SegmentController::class, 'settings'])->name('settings.segments'); Route::get('settings/segments', [SegmentController::class, 'settings'])->name('settings.segments');
Route::post('settings/segments', [SegmentController::class, 'store'])->name('settings.segments.store'); Route::post('settings/segments', [SegmentController::class, 'store'])->name('settings.segments.store');
Route::put('settings/segments/{segment}', [SegmentController::class, 'update'])->name('settings.segments.update'); Route::put('settings/segments/{segment}', [SegmentController::class, 'update'])->name('settings.segments.update');
// settings / archive settings
Route::get('settings/archive', [ArchiveSettingController::class, 'index'])->name('settings.archive.index');
Route::post('settings/archive', [ArchiveSettingController::class, 'store'])->name('settings.archive.store');
Route::put('settings/archive/{archiveSetting}', [ArchiveSettingController::class, 'update'])->name('settings.archive.update');
Route::post('settings/archive/{archiveSetting}/run', [ArchiveSettingController::class, 'run'])->name('settings.archive.run');
Route::delete('settings/archive/{archiveSetting}', [ArchiveSettingController::class, 'destroy'])->name('settings.archive.destroy');
Route::get('settings/workflow', [WorkflowController::class, 'index'])->name('settings.workflow'); Route::get('settings/workflow', [WorkflowController::class, 'index'])->name('settings.workflow');
Route::get('settings/field-job', [FieldJobSettingController::class, 'index'])->name('settings.fieldjob.index'); Route::get('settings/field-job', [FieldJobSettingController::class, 'index'])->name('settings.fieldjob.index');

View File

@ -0,0 +1,56 @@
<?php
namespace Tests\Feature;
use App\Models\Account;
use App\Models\ArchiveSetting;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ArchiveContractAccountChainTest extends \Tests\TestCase
{
use RefreshDatabase;
public function test_archives_account_via_contracts_account_chain(): void
{
$case = ClientCase::factory()->create();
$contract = Contract::factory()->create([
'client_case_id' => $case->id,
'active' => 1,
]);
$accountTypeId = \DB::table('account_types')->insertGetId([
'name' => 'Type',
'description' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
]);
$account = Account::create([
'contract_id' => $contract->id,
'type_id' => $accountTypeId,
'active' => 1,
'initial_amount' => 0,
'balance_amount' => 0,
]);
ArchiveSetting::factory()->create([
'enabled' => true,
'strategy' => 'manual',
'soft' => true,
'entities' => [
['table' => 'contracts', 'focus' => true],
['table' => 'contracts.account'],
],
]);
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('clientCase.contract.archive', ['client_case' => $case->uuid, 'uuid' => $contract->uuid]))
->assertRedirect();
$this->assertDatabaseHas('accounts', ['id' => $account->id, 'active' => 0]);
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace Tests\Feature;
use App\Models\Account;
use App\Models\ArchiveSetting;
use App\Models\Booking;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Payment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ArchiveContractChainedEntitiesTest extends \Tests\TestCase
{
use RefreshDatabase;
public function test_archives_payments_and_bookings_via_account_chain(): void
{
$case = ClientCase::factory()->create();
$contract = Contract::factory()->create([
'client_case_id' => $case->id,
'active' => 1,
]);
// Create account tied to contract
// Minimal account type requirement
$accountTypeId = \DB::table('account_types')->insertGetId([
'name' => 'Test Type',
'description' => 'Temp',
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
]);
$account = Account::create([
'contract_id' => $contract->id,
'type_id' => $accountTypeId,
'active' => 1,
'initial_amount' => 0,
'balance_amount' => 0,
]);
// Seed payments & bookings for that account
$payment = Payment::create([
'account_id' => $account->id,
'amount_cents' => 10000,
'currency' => 'EUR',
'reference' => 'P-TEST',
'paid_at' => now(),
'meta' => json_encode([]),
]);
$booking = Booking::create([
'account_id' => $account->id,
'payment_id' => $payment->id,
'amount_cents' => 10000,
'type' => 'debit',
'description' => 'Test Booking',
'booked_at' => now(),
]);
ArchiveSetting::factory()->create([
'enabled' => true,
'strategy' => 'manual',
'soft' => true,
'entities' => [
['table' => 'contracts', 'focus' => true],
['table' => 'account.payments'],
['table' => 'account.bookings'],
],
]);
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('clientCase.contract.archive', ['client_case' => $case->uuid, 'uuid' => $contract->uuid]))
->assertRedirect();
// Refresh models
$payment->refresh();
$booking->refresh();
$this->assertDatabaseHas('payments', ['id' => $payment->id, 'active' => 0]);
$this->assertDatabaseHas('bookings', ['id' => $booking->id, 'active' => 0]);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace Tests\Feature;
use App\Models\ArchiveSetting;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Segment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
class ArchiveContractSegmentTest extends \Tests\TestCase
{
use RefreshDatabase;
public function test_moves_contract_to_archive_segment_when_setting_has_segment_id(): void
{
$case = ClientCase::factory()->create();
$contract = Contract::factory()->create([
'client_case_id' => $case->id,
'active' => 1,
]);
$originalSegment = Segment::factory()->create(['active' => true]);
$archiveSegment = Segment::factory()->create(['active' => true]);
DB::table('client_case_segment')->insert([
'client_case_id' => $case->id,
'segment_id' => $originalSegment->id,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('contract_segment')->insert([
'contract_id' => $contract->id,
'segment_id' => $originalSegment->id,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
ArchiveSetting::factory()->create([
'enabled' => true,
'strategy' => 'manual',
'segment_id' => $archiveSegment->id,
'entities' => [
['table' => 'contracts', 'focus' => true],
],
]);
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('clientCase.contract.archive', ['client_case' => $case->uuid, 'uuid' => $contract->uuid]));
$response->assertRedirect();
$activePivots = DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('active', true)
->pluck('segment_id');
$this->assertTrue($activePivots->contains($archiveSegment->id));
$this->assertFalse($activePivots->contains($originalSegment->id));
}
}

View File

@ -0,0 +1,48 @@
<?php
use App\Models\ArchiveSetting;
use App\Models\ClientCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
/** @var \Tests\TestCase $this */
it('archives a single contract using archive settings', function () {
$user = User::factory()->create();
test()->actingAs($user);
$case = ClientCase::factory()->create(['active' => 1]);
$typeId = DB::table('contract_types')->insertGetId([
'name' => 'Standard',
'description' => 'Test',
'created_at' => now(),
'updated_at' => now(),
]);
$contract = $case->contracts()->create([
'uuid' => (string) Str::uuid(),
'reference' => 'T-TEST',
'start_date' => now()->toDateString(),
'end_date' => null,
'type_id' => $typeId,
'active' => 1,
]);
ArchiveSetting::factory()->create([
'entities' => [
['table' => 'contracts', 'conditions' => ['where' => ['client_case_id' => $case->id]]],
],
'strategy' => 'immediate',
'soft' => true,
'enabled' => true,
]);
test()->post(route('clientCase.contract.archive', [$case->uuid, $contract->uuid]))
->assertRedirect();
$contract->refresh();
expect($contract->active)->toBe(0);
});

View File

@ -0,0 +1,48 @@
<?php
use App\Models\ArchiveRun;
use App\Models\ArchiveSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('runs an archive setting via run-now endpoint and logs audit', function () {
$docId = null; // Define docId earlier
$user = User::factory()->create();
test()->actingAs($user);
// Insert sample document older than 200 days (minimal required columns)
$docId = DB::table('documents')->insertGetId([
'uuid' => (string) Str::uuid(),
'documentable_type' => 'App\\Models\\ClientCase', // generic
'documentable_id' => 1,
'name' => 'Old Doc',
'file_name' => 'old.txt',
'original_name' => 'old.txt',
'disk' => 'public',
'path' => 'documents/old.txt',
'mime_type' => 'text/plain',
'active' => 1,
'created_at' => now()->subDays(210),
'updated_at' => now()->subDays(210),
]);
$setting = ArchiveSetting::factory()->create([
'entities' => [[
'table' => 'documents',
'conditions' => ['older_than_days' => 180],
]],
'enabled' => true,
'strategy' => 'immediate',
]);
test()->post(route('settings.archive.run', $setting->id))->assertRedirect();
$setting->refresh();
$docRow = DB::table('documents')->where('id', $docId)->first();
expect(ArchiveRun::count())->toBe(1)
->and($docRow->active)->toBe(0);
});

View File

@ -0,0 +1,61 @@
<?php
/**
* @method \Illuminate\Testing\TestResponse post(string $uri, array $data = [])
* @method \Illuminate\Testing\TestResponse put(string $uri, array $data = [])
* @method \Illuminate\Testing\TestResponse delete(string $uri, array $data = [])
* @method $this actingAs(\App\Models\User $user, ?string $guard = null)
*/
use App\Models\ArchiveSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/** @var \Tests\TestCase $this */
it('creates an archive setting', function () {
$user = User::factory()->create();
test()->actingAs($user);
$response = test()->post(route('settings.archive.store'), [
'entities' => [
['table' => 'documents', 'conditions' => ['older_than_days' => 30]],
],
'strategy' => 'immediate',
'soft' => true,
'enabled' => true,
]);
$response->assertRedirect();
expect(ArchiveSetting::count())->toBe(1);
});
it('updates an archive setting', function () {
$user = User::factory()->create();
test()->actingAs($user);
$setting = ArchiveSetting::factory()->create();
$response = test()->put(route('settings.archive.update', $setting->id), [
'entities' => $setting->entities,
'strategy' => 'queued',
'soft' => false,
'enabled' => false,
]);
$response->assertRedirect();
$setting->refresh();
expect($setting->strategy)->toBe('queued')
->and($setting->soft)->toBeFalse()
->and($setting->enabled)->toBeFalse();
});
it('deletes an archive setting', function () {
$user = User::factory()->create();
test()->actingAs($user);
$setting = ArchiveSetting::factory()->create();
$response = test()->delete(route('settings.archive.destroy', $setting->id));
$response->assertRedirect();
expect(ArchiveSetting::withTrashed()->count())->toBe(1) // soft deleted
->and(ArchiveSetting::count())->toBe(0);
});

View File

@ -0,0 +1,50 @@
<?php
use App\Models\ArchiveSetting;
use App\Models\ClientCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('reactivates a single contract when a reactivate rule exists', function () {
$user = User::factory()->create();
test()->actingAs($user);
$case = ClientCase::factory()->create(['active' => 1]);
$typeId = DB::table('contract_types')->insertGetId([
'name' => 'Standard',
'description' => 'Test',
'created_at' => now(),
'updated_at' => now(),
]);
$contract = $case->contracts()->create([
'uuid' => (string) Str::uuid(),
'reference' => 'T-REACT',
'start_date' => now()->toDateString(),
'end_date' => null,
'type_id' => $typeId,
'active' => 0, // initially archived
'deleted_at' => now(), // also soft deleted to test clearing
]);
ArchiveSetting::factory()->create([
'entities' => [
['table' => 'contracts', 'conditions' => ['where' => ['client_case_id' => $case->id]]],
],
'strategy' => 'immediate',
'soft' => true,
'enabled' => true,
'reactivate' => true,
]);
test()->post(route('clientCase.contract.archive', ['client_case' => $case->uuid, 'uuid' => $contract->uuid]))
->assertRedirect();
$contract->refresh();
expect($contract->active)->toBe(1)
->and($contract->deleted_at)->toBeNull();
});