updates to UI and add archiving option
This commit is contained in:
parent
fe91c7e4bc
commit
bab9d6561f
57
app/Console/Commands/RunArchive.php
Normal file
57
app/Console/Commands/RunArchive.php
Normal 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;
|
||||
}
|
||||
}
|
||||
76
app/Http/Controllers/ArchiveSettingController.php
Normal file
76
app/Http/Controllers/ArchiveSettingController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -159,6 +159,9 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ
|
|||
public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request)
|
||||
{
|
||||
$contract = Contract::where('uuid', $uuid)->firstOrFail();
|
||||
if (! $contract->active) {
|
||||
return back()->with('warning', __('contracts.edit_not_allowed_archived'));
|
||||
}
|
||||
|
||||
\DB::transaction(function () use ($request, $contract) {
|
||||
$contract->update([
|
||||
|
|
@ -243,6 +246,10 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||
if (! empty($attributes['contract_uuid'])) {
|
||||
$contract = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail('id');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -315,6 +322,11 @@ public function updateContractSegment(ClientCase $clientCase, string $uuid, Requ
|
|||
|
||||
$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) {
|
||||
// Deactivate current active relation(s)
|
||||
\DB::table('contract_segment')
|
||||
|
|
@ -365,6 +377,10 @@ public function attachSegment(ClientCase $clientCase, Request $request)
|
|||
// Optionally make it active for a specific contract
|
||||
if (! empty($validated['contract_uuid']) && ($validated['make_active_for_contract'] ?? false)) {
|
||||
$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')
|
||||
->where('contract_id', $contract->id)
|
||||
->where('active', true)
|
||||
|
|
@ -402,6 +418,9 @@ public function storeDocument(ClientCase $clientCase, Request $request)
|
|||
$contract = null;
|
||||
if (! empty($validated['contract_uuid'])) {
|
||||
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
|
||||
if ($contract && ! $contract->active) {
|
||||
return back()->with('warning', __('contracts.document_not_allowed_archived'));
|
||||
}
|
||||
}
|
||||
$directory = $contract
|
||||
? ('contracts/'.$contract->uuid.'/documents')
|
||||
|
|
@ -1000,16 +1019,49 @@ public function show(ClientCase $clientCase)
|
|||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
];
|
||||
|
||||
// $active = false;
|
||||
|
||||
// Optional segment filter from query string
|
||||
$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()
|
||||
->with(['type', 'account', 'objects', 'segments:id,name'])
|
||||
->orderByDesc('created_at');
|
||||
->with(['type', 'account', 'objects', 'segments:id,name']);
|
||||
|
||||
$contractsQuery->orderByDesc('created_at');
|
||||
|
||||
if (! empty($segmentId)) {
|
||||
// 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) {
|
||||
$q->from('contract_segment')
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
|
|
@ -1019,6 +1071,7 @@ public function show(ClientCase $clientCase)
|
|||
}
|
||||
|
||||
$contracts = $contractsQuery->get();
|
||||
|
||||
$contractRefMap = [];
|
||||
foreach ($contracts as $c) {
|
||||
$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' => $case,
|
||||
'contracts' => $contracts,
|
||||
'archive_meta' => [
|
||||
'archive_segment_id' => $archiveSegmentId,
|
||||
'related_tables' => $relatedArchiveTables,
|
||||
],
|
||||
'activities' => tap(
|
||||
(function () use ($case, $segmentId, $contractIds) {
|
||||
$q = $case->activities()
|
||||
|
|
@ -1090,7 +1147,11 @@ function ($p) {
|
|||
'documents' => $mergedDocs,
|
||||
'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(),
|
||||
'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,
|
||||
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.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'])
|
||||
: 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ public function store(Request $request)
|
|||
'sample_headers' => 'nullable|array',
|
||||
'client_id' => 'nullable|integer|exists:clients,id',
|
||||
'is_active' => 'boolean',
|
||||
'reactivate' => 'boolean',
|
||||
'entities' => 'nullable|array',
|
||||
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
|
||||
'mappings' => 'array',
|
||||
|
|
@ -155,6 +156,7 @@ public function store(Request $request)
|
|||
'user_id' => $request->user()?->id,
|
||||
'client_id' => $data['client_id'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'reactivate' => $data['reactivate'] ?? false,
|
||||
'meta' => array_filter([
|
||||
'entities' => $entities,
|
||||
'segment_id' => data_get($data, 'meta.segment_id'),
|
||||
|
|
@ -219,6 +221,7 @@ public function edit(ImportTemplate $template)
|
|||
'source_type' => $template->source_type,
|
||||
'default_record_type' => $template->default_record_type,
|
||||
'is_active' => $template->is_active,
|
||||
'reactivate' => $template->reactivate,
|
||||
'client_uuid' => $template->client?->uuid,
|
||||
'sample_headers' => $template->sample_headers,
|
||||
'meta' => $template->meta,
|
||||
|
|
@ -298,6 +301,7 @@ public function update(Request $request, ImportTemplate $template)
|
|||
'default_record_type' => 'nullable|string|max:50',
|
||||
'client_id' => 'nullable|integer|exists:clients,id',
|
||||
'is_active' => 'boolean',
|
||||
'reactivate' => 'boolean',
|
||||
'sample_headers' => 'nullable|array',
|
||||
'meta' => 'nullable|array',
|
||||
'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,
|
||||
'client_id' => $data['client_id'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? $template->is_active,
|
||||
'reactivate' => $data['reactivate'] ?? $template->reactivate,
|
||||
'sample_headers' => $data['sample_headers'] ?? $template->sample_headers,
|
||||
'meta' => (function () use ($newMeta) {
|
||||
// If payments import mode is enabled, force entities sequence in meta
|
||||
|
|
|
|||
39
app/Http/Requests/ArchiveSettingRequest.php
Normal file
39
app/Http/Requests/ArchiveSettingRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Models/ArchiveEntity.php
Normal file
27
app/Models/ArchiveEntity.php
Normal 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
43
app/Models/ArchiveRun.php
Normal 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);
|
||||
}
|
||||
}
|
||||
66
app/Models/ArchiveSetting.php
Normal file
66
app/Models/ArchiveSetting.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ public function segments(): BelongsToMany
|
|||
public function account(): HasOne
|
||||
{
|
||||
return $this->hasOne(\App\Models\Account::class)
|
||||
->latestOfMany()
|
||||
->with('type');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class Import extends Model
|
|||
use HasFactory;
|
||||
|
||||
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 = [
|
||||
|
|
@ -21,6 +21,7 @@ class Import extends Model
|
|||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
'reactivate' => 'boolean',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@ class ImportTemplate extends Model
|
|||
use HasFactory;
|
||||
|
||||
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 = [
|
||||
'sample_headers' => 'array',
|
||||
'meta' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'reactivate' => 'boolean',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
|
|
|
|||
40
app/Policies/ArchiveSettingPolicy.php
Normal file
40
app/Policies/ArchiveSettingPolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
286
app/Services/Archiving/ArchiveExecutor.php
Normal file
286
app/Services/Archiving/ArchiveExecutor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -215,6 +215,16 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
$rawAssoc = $this->buildRowAssoc($row, $header);
|
||||
[$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
|
||||
|
||||
$rawSha1 = sha1(json_encode($rawAssoc));
|
||||
|
|
@ -230,6 +240,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
|
||||
// Contracts
|
||||
$contractResult = null;
|
||||
$reactivatedThisRow = false;
|
||||
if (isset($mapped['contract'])) {
|
||||
// In payments-import with contract_key_mode=reference, treat contract.reference as a keyref only
|
||||
if ($paymentsImport && $contractKeyMode === 'reference') {
|
||||
|
|
@ -248,6 +259,29 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
$found = $q->first();
|
||||
if ($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 {
|
||||
$contractResult = null; // let requireContract logic flag invalid later
|
||||
}
|
||||
|
|
@ -256,6 +290,31 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
}
|
||||
} else {
|
||||
$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') {
|
||||
// Even if no contract fields were updated, we may still need to apply template meta
|
||||
|
|
@ -315,17 +374,19 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
]);
|
||||
|
||||
// Post-contract actions from template/import meta
|
||||
try {
|
||||
$this->postContractActions($import, $contractResult['contract']);
|
||||
} catch (\Throwable $e) {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id,
|
||||
'event' => 'post_contract_action_failed',
|
||||
'level' => 'warning',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
if (! $reactivateMode || $reactivatedThisRow) { // run post actions also for reactivated contracts
|
||||
try {
|
||||
$this->postContractActions($import, $contractResult['contract']);
|
||||
} catch (\Throwable $e) {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id,
|
||||
'event' => 'post_contract_action_failed',
|
||||
'level' => 'warning',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$invalid++;
|
||||
|
|
@ -1073,6 +1134,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
|||
$existing = Account::query()
|
||||
->where('contract_id', $contractId)
|
||||
->where('reference', $reference)
|
||||
->where('active', 1)
|
||||
->first();
|
||||
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,18 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
|||
$assoc = $this->associateRow($columns, $rawValues);
|
||||
$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)
|
||||
$val = function (string $tf) use ($assoc, $targetToSource) {
|
||||
// Direct hit
|
||||
|
|
@ -95,6 +107,15 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
|||
// Contract
|
||||
if (isset($entityRoots['contract'])) {
|
||||
[$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 + [
|
||||
'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)) {
|
||||
$contract = $cache[$reference];
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
|
@ -637,6 +658,8 @@ private function simulateContract(callable $val, array $summaries, array $cache,
|
|||
'id' => $contract?->id,
|
||||
'exists' => (bool) $contract,
|
||||
'client_case_id' => $contract?->client_case_id,
|
||||
'active' => $contract?->active,
|
||||
'deleted_at' => $contract?->deleted_at,
|
||||
'action' => $contract ? 'update' : ($reference ? 'create' : 'skip'),
|
||||
];
|
||||
$summaries['contract']['total_rows']++;
|
||||
|
|
@ -658,7 +681,10 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
|
|||
if (array_key_exists($reference, $cache)) {
|
||||
$account = $cache[$reference];
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1156,6 +1182,7 @@ private function actionTranslations(): array
|
|||
'update' => 'posodobi',
|
||||
'skip' => 'preskoči',
|
||||
'implicit' => 'posredno',
|
||||
'reactivate' => 'reaktiviraj',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
20
config/archiving.php
Normal file
20
config/archiving.php
Normal 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',
|
||||
],
|
||||
];
|
||||
43
database/factories/ArchiveSettingFactory.php
Normal file
43
database/factories/ArchiveSettingFactory.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
59
database/seeders/ArchiveEntitySeeder.php
Normal file
59
database/seeders/ArchiveEntitySeeder.php
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
database/seeders/ArchiveSettingSeeder.php
Normal file
16
database/seeders/ArchiveSettingSeeder.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ public function run(): void
|
|||
$this->call([
|
||||
AccountTypeSeeder::class,
|
||||
PaymentSettingSeeder::class,
|
||||
ArchiveEntitySeeder::class,
|
||||
PersonSeeder::class,
|
||||
SegmentSeeder::class,
|
||||
ActionSeeder::class,
|
||||
|
|
|
|||
14
lang/sl/contracts.php
Normal file
14
lang/sl/contracts.php
Normal 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.',
|
||||
];
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup>
|
||||
import InputLabel from './InputLabel.vue'
|
||||
import InputError from './InputError.vue'
|
||||
import { computed } from 'vue'
|
||||
import InputLabel from "./InputLabel.vue";
|
||||
import InputError from "./InputError.vue";
|
||||
import { computed } from "vue";
|
||||
|
||||
/*
|
||||
DatePickerField (v-calendar)
|
||||
|
|
@ -25,45 +25,44 @@ const props = defineProps({
|
|||
modelValue: { type: [Date, String, Number, null], default: null },
|
||||
id: { type: String, default: undefined },
|
||||
label: { type: String, default: undefined },
|
||||
format: { type: String, default: 'dd.MM.yyyy' },
|
||||
format: { type: String, default: "dd.MM.yyyy" },
|
||||
enableTimePicker: { type: Boolean, default: false },
|
||||
inline: { type: Boolean, default: false },
|
||||
// legacy/unused in v-calendar (kept to prevent breaking callers)
|
||||
autoApply: { type: Boolean, default: false },
|
||||
teleportTarget: { type: [Boolean, String], default: 'body' },
|
||||
teleportTarget: { type: [Boolean, String], default: "body" },
|
||||
autoPosition: { type: Boolean, default: true },
|
||||
menuClassName: { type: String, default: 'dp-over-modal' },
|
||||
menuClassName: { type: String, default: "dp-over-modal" },
|
||||
fixed: { type: Boolean, default: true },
|
||||
closeOnAutoApply: { type: Boolean, default: true },
|
||||
closeOnScroll: { type: Boolean, default: true },
|
||||
placeholder: { type: String, default: '' },
|
||||
placeholder: { type: String, default: "" },
|
||||
error: { type: [String, Array], default: undefined },
|
||||
})
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
const emit = defineEmits(["update:modelValue", "change"]);
|
||||
|
||||
const valueProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val)
|
||||
emit('change', val)
|
||||
emit("update:modelValue", val);
|
||||
emit("change", val);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Convert common date mask from lowercase tokens to v-calendar tokens
|
||||
const inputMask = computed(() => {
|
||||
let m = props.format || 'dd.MM.yyyy'
|
||||
return m
|
||||
.replace(/yyyy/g, 'YYYY')
|
||||
.replace(/dd/g, 'DD')
|
||||
.replace(/MM/g, 'MM')
|
||||
+ (props.enableTimePicker ? ' HH:mm' : '')
|
||||
})
|
||||
let m = props.format || "dd.MM.yyyy";
|
||||
return (
|
||||
m.replace(/yyyy/g, "YYYY").replace(/dd/g, "DD").replace(/MM/g, "MM") +
|
||||
(props.enableTimePicker ? " HH:mm" : "")
|
||||
);
|
||||
});
|
||||
|
||||
const popoverCfg = computed(() => ({
|
||||
visibility: props.inline ? 'visible' : 'click',
|
||||
placement: 'bottom-start',
|
||||
}))
|
||||
visibility: props.inline ? "visible" : "click",
|
||||
placement: "bottom-start",
|
||||
}));
|
||||
</script>
|
||||
|
||||
<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"
|
||||
:placeholder="placeholder"
|
||||
:value="inputValue"
|
||||
autocomplete="off"
|
||||
v-on="inputEvents"
|
||||
/>
|
||||
</template>
|
||||
</VDatePicker>
|
||||
|
||||
<template v-if="error">
|
||||
<InputError v-if="Array.isArray(error)" v-for="(e, idx) in error" :key="idx" :message="e" />
|
||||
<InputError
|
||||
v-if="Array.isArray(error)"
|
||||
v-for="(e, idx) in error"
|
||||
:key="idx"
|
||||
:message="e"
|
||||
/>
|
||||
<InputError v-else :message="error" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Ensure the date picker menu overlays modals/dialogs */
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -255,7 +255,13 @@ function closeActions() {
|
|||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||
>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>
|
||||
<FwbTableBody>
|
||||
<template v-for="(doc, i) in documents" :key="doc.uuid || i">
|
||||
|
|
@ -318,7 +324,7 @@ function closeActions() {
|
|||
@click="handleDownload(doc)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" />
|
||||
<span>Download file</span>
|
||||
<span>Prenos</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -326,7 +332,7 @@ function closeActions() {
|
|||
@click="askDelete(doc)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
<span>Izbriši</span>
|
||||
</button>
|
||||
<!-- future actions can be slotted here -->
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,143 +1,323 @@
|
|||
<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({
|
||||
person: { type: Object, required: true },
|
||||
types: { type: Object, default: () => ({}) },
|
||||
// Allow overriding the default active tab: 'addresses' | 'phones' | 'emails' | 'bank'
|
||||
defaultTab: { type: String, default: 'addresses' },
|
||||
})
|
||||
defaultTab: { type: String, default: "addresses" },
|
||||
});
|
||||
|
||||
const phoneTypes = computed(() => {
|
||||
const arr = props.types?.phone_types || []
|
||||
const map = {}
|
||||
for (const t of arr) { map[t.id] = t.name }
|
||||
return map
|
||||
})
|
||||
const arr = props.types?.phone_types || [];
|
||||
const map = {};
|
||||
for (const t of arr) {
|
||||
map[t.id] = t.name;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const displayName = computed(() => {
|
||||
const p = props.person || {}
|
||||
const full = p.full_name?.trim()
|
||||
if (full) { return full }
|
||||
const first = p.first_name?.trim() || ''
|
||||
const last = p.last_name?.trim() || ''
|
||||
return `${first} ${last}`.trim()
|
||||
})
|
||||
const p = props.person || {};
|
||||
const full = p.full_name?.trim();
|
||||
if (full) {
|
||||
return full;
|
||||
}
|
||||
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 primaryEmail = computed(() => props.person?.emails?.[0]?.value || null)
|
||||
const primaryAddress = computed(() => props.person?.addresses?.[0] || null);
|
||||
const primaryEmail = computed(() => props.person?.emails?.[0]?.value || null);
|
||||
// Backend phone model uses `nu` as the number
|
||||
const allPhones = computed(() => props.person?.phones || [])
|
||||
const allAddresses = computed(() => props.person?.addresses || [])
|
||||
const allEmails = computed(() => props.person?.emails || [])
|
||||
const allPhones = computed(() => props.person?.phones || []);
|
||||
const allAddresses = computed(() => props.person?.addresses || []);
|
||||
const allEmails = computed(() => props.person?.emails || []);
|
||||
// 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 bankIban = computed(() => allBankAccounts.value?.[0]?.iban || null)
|
||||
const taxNumber = computed(() => props.person?.tax_number || null)
|
||||
const ssn = computed(() => props.person?.social_security_number || null)
|
||||
const allBankAccounts = computed(
|
||||
() => props.person?.bank_accounts || props.person?.bankAccounts || []
|
||||
);
|
||||
// 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
|
||||
const showMore = ref(false)
|
||||
const summaryPhones = computed(() => allPhones.value.slice(0, showMore.value ? 2 : 1))
|
||||
const showMore = ref(false);
|
||||
const summaryPhones = computed(() => allPhones.value.slice(0, showMore.value ? 3 : 1));
|
||||
|
||||
// Tabs
|
||||
const activeTab = ref(props.defaultTab || 'addresses')
|
||||
watch(() => props.defaultTab, (val) => { if (val) activeTab.value = val })
|
||||
// Limit tabs to addresses | phones | emails (TRR tab removed)
|
||||
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) {
|
||||
if (!iban || typeof iban !== 'string') return null
|
||||
const clean = iban.replace(/\s+/g, '')
|
||||
if (clean.length <= 8) return clean
|
||||
return `${clean.slice(0, 4)} **** **** ${clean.slice(-4)}`
|
||||
if (!iban || typeof iban !== "string") return null;
|
||||
const clean = iban.replace(/\s+/g, "");
|
||||
if (clean.length <= 8) return clean;
|
||||
return `${clean.slice(0, 4)} **** **** ${clean.slice(-4)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Summary -->
|
||||
<div class="text-sm">
|
||||
<div v-if="displayName" class="font-medium text-gray-900">{{ displayName }}</div>
|
||||
|
||||
<div v-if="primaryAddress" class="mt-1 text-gray-700">
|
||||
<span>{{ primaryAddress.address }}</span>
|
||||
<span v-if="primaryAddress.country" class="text-gray-500 text-xs ml-1">({{ primaryAddress.country }})</span>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span v-if="primaryAddress" class="pill pill-slate" title="Naslov">
|
||||
<FontAwesomeIcon :icon="faLocationDot" class="w-4 h-4 mr-1" />
|
||||
<span class="truncate max-w-[9rem]">{{ primaryAddress.address }}</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 v-if="summaryPhones?.length" class="mt-1 space-y-0.5">
|
||||
<div v-for="p in summaryPhones" :key="p.id" class="text-gray-700">
|
||||
<span>{{ p.nu }}</span>
|
||||
<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>
|
||||
<transition name="fade">
|
||||
<div v-if="showMore" class="mt-3 grid grid-cols-2 gap-x-2 gap-y-2 text-[14px]">
|
||||
<div v-if="taxNumber">
|
||||
<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">E‑pošta</div>
|
||||
<div class="value truncate">{{ primaryEmail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showMore && primaryEmail" class="mt-1 text-gray-700">{{ primaryEmail }}</div>
|
||||
|
||||
<div v-if="showMore && bankIban" class="mt-1 text-gray-700">TRR: <span class="font-mono">{{ maskIban(bankIban) }}</span></div>
|
||||
|
||||
<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>
|
||||
|
||||
<button type="button" class="mt-2 text-xs text-blue-600 hover:underline" @click="showMore = !showMore">
|
||||
{{ showMore ? 'Skrij' : 'Prikaži več' }}
|
||||
</transition>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 inline-flex items-center text-[11px] font-medium text-indigo-600 hover:text-indigo-700 focus:outline-none"
|
||||
@click="showMore = !showMore"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faChevronDown"
|
||||
:class="[
|
||||
'w-3 h-3 mr-1 transition-transform',
|
||||
showMore ? 'rotate-180' : 'rotate-0',
|
||||
]"
|
||||
/>
|
||||
{{ showMore ? "Manj podrobnosti" : "Več podrobnosti" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mt-3">
|
||||
<div class="flex gap-2 overflow-x-auto">
|
||||
<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']">
|
||||
<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>
|
||||
Naslovi ({{ allAddresses.length }})
|
||||
</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']">
|
||||
<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>
|
||||
Telefoni ({{ allPhones.length }})
|
||||
</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']">
|
||||
<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>
|
||||
E-pošta ({{ allEmails.length }})
|
||||
</button>
|
||||
<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>
|
||||
TRR ({{ allBankAccounts.length }})
|
||||
</button>
|
||||
<!-- Segmented Tabs -->
|
||||
<div class="mt-5">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex w-full text-[11px] font-medium rounded-lg border bg-gray-50 overflow-hidden"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
@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
|
||||
type="button"
|
||||
@click="activeTab = 'emails'"
|
||||
:class="['seg-btn', activeTab === 'emails' && 'seg-active']"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faEnvelope" class="w-3.5 h-3.5 mr-1 shrink-0" />
|
||||
<span class="truncate">E‑pošta ({{ allEmails.length }})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<div class="mt-3 rounded-md border bg-white/60 p-2">
|
||||
<!-- Addresses -->
|
||||
<div v-if="activeTab==='addresses'">
|
||||
<div v-if="!allAddresses.length" class="text-gray-500 text-xs">Ni naslovov.</div>
|
||||
<div v-for="(a,idx) in allAddresses" :key="a.id || idx" class="py-1">
|
||||
<div class="text-gray-800">{{ a.address }}</div>
|
||||
<div v-if="a.country" class="text-gray-600 text-xs">{{ a.country }}</div>
|
||||
<div v-if="activeTab === 'addresses'">
|
||||
<div v-if="!allAddresses.length" class="empty">Ni naslovov.</div>
|
||||
<div v-for="(a, idx) in allAddresses" :key="a.id || idx" class="item-row">
|
||||
<div class="font-medium text-gray-800">{{ a.address }}</div>
|
||||
<div v-if="a.country" class="sub">{{ a.country }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phones -->
|
||||
<div v-else-if="activeTab==='phones'">
|
||||
<div v-if="!allPhones.length" class="text-gray-500 text-xs">Ni telefonov.</div>
|
||||
<div v-for="(p,idx) in allPhones" :key="p.id || idx" class="py-1">
|
||||
<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 v-else-if="activeTab === 'phones'">
|
||||
<div v-if="!allPhones.length" class="empty">Ni telefonov.</div>
|
||||
<div v-for="(p, idx) in allPhones" :key="p.id || idx" class="item-row">
|
||||
<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>
|
||||
|
||||
<!-- 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-for="(e,idx) in allEmails" :key="e.id || idx" class="py-1">
|
||||
<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>
|
||||
</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 v-else-if="activeTab === 'emails'">
|
||||
<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="item-row">
|
||||
<div class="font-medium text-gray-800">
|
||||
{{ e.value }}<span v-if="e.label" class="sub ml-1">({{ e.label }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- (TRR tab removed; last bank account surfaced in summary) -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,18 @@ import Breadcrumbs from "@/Components/Breadcrumbs.vue";
|
|||
import GlobalSearch from "./Partials/GlobalSearch.vue";
|
||||
import NotificationsBell from "./Partials/NotificationsBell.vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faMobileScreenButton } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faMobileScreenButton,
|
||||
faGaugeHigh,
|
||||
faLayerGroup,
|
||||
faUserGroup,
|
||||
faFolderOpen,
|
||||
faFileImport,
|
||||
faTableList,
|
||||
faFileCirclePlus,
|
||||
faMap,
|
||||
faGear,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
|
|
@ -173,7 +184,7 @@ const rawMenuGroups = [
|
|||
],
|
||||
},
|
||||
{
|
||||
label: "Terensko",
|
||||
label: "Terensko delo",
|
||||
items: [
|
||||
{
|
||||
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) {
|
||||
try {
|
||||
return patterns?.some((p) => route().current(p));
|
||||
|
|
@ -267,161 +291,12 @@ function isActive(patterns) {
|
|||
]"
|
||||
:title="item.title"
|
||||
>
|
||||
<!-- Icons -->
|
||||
<template v-if="item.key === 'dashboard'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 12l8.954-8.955a1.125 1.125 0 011.592 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.5c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v4.5h4.125c.621 0 1.125-.504 1.125-1.125V9.75"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'segments'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 3h7v7H3V3zm11 0h7v7h-7V3zM3 14h7v7H3v-7zm11 0h7v7h-7v-7z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'clients'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 003.745-.479 3.375 3.375 0 00-6.49-1.072M15 19.128V18a4.5 4.5 0 00-4.5-4.5H8.25A4.5 4.5 0 003.75 18v1.128M15 19.128V21m0-1.872V21M6.75 7.5a3 3 0 116 0 3 3 0 01-6 0z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'cases'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-6a2.25 2.25 0 00-2.25-2.25H8.25A2.25 2.25 0 006 8.25v7.5A2.25 2.25 0 008.25 18h9a2.25 2.25 0 002.25-2.25z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 9h6m-6 3h6m-6 3h3"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'imports'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 10.5L12 6l4.5 4.5M12 6v12"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'import-templates'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 4.5h5.25l1.5 2.25H20.25A1.5 1.5 0 0121.75 8.25v9A2.25 2.25 0 0119.5 19.5H4.5A2.25 2.25 0 012.25 17.25V6A1.5 1.5 0 013.75 4.5z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'import-templates-new'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'fieldjobs'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.5-7.5 10.5-7.5 10.5S4.5 18 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'settings'">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93l.8.334c.486.203.682.78.4 1.223l-.5.805c-.214.343-.17.784.108 1.09l.596.654c.36.395.37 1.002.024 1.41l-.657.76c-.285.33-.347.79-.158 1.182l.3.65c.216.468-.02 1.02-.507 1.21l-.89.345c-.4.155-.68.52-.74.94l-.12.89c-.08.55-.54.96-1.09.96h-1.09c-.55 0-1.01-.41-1.09-.96l-.12-.89c-.06-.42-.34-.785-.74-.94l-.89-.345c-.49-.19-.72-.74-.507-1.21l.3-.65c.19-.392.127-.852-.158-1.182l-.657-.76a1.125 1.125 0 01-.033-1.58l.596-.654c.278-.306.322-.747.108-1.09l-.5-.805c-.282-.443-.086-1.02.4-1.223l.8-.334c.396-.166.71-.506.78-.93l.149-.894zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<!-- Unified FontAwesome icon rendering -->
|
||||
<FontAwesomeIcon
|
||||
v-if="menuIconMap[item.key]"
|
||||
:icon="menuIconMap[item.key]"
|
||||
class="w-5 h-5 text-gray-600"
|
||||
/>
|
||||
<!-- Title -->
|
||||
<span v-if="!sidebarCollapsed">{{ item.title }}</span>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -73,11 +73,22 @@ const store = async () => {
|
|||
amount: form.amount,
|
||||
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
|
||||
.transform((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), {
|
||||
onSuccess: () => {
|
||||
|
|
|
|||
|
|
@ -179,17 +179,17 @@ const confirmDeleteAction = () => {
|
|||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 pl-2 pr-2 align-top text-right">
|
||||
<Dropdown align="right" width="30" :content-classes="['py-1', 'bg-white']">
|
||||
<td class="py-2 pl-2 pr-2 align-middle text-right">
|
||||
<Dropdown align="right" width="30">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-gray-100"
|
||||
aria-haspopup="menu"
|
||||
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
|
||||
:title="'Actions'"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'ellipsis-vertical']"
|
||||
class="text-gray-600 text-[20px]"
|
||||
:icon="faEllipsisVertical"
|
||||
class="h-4 w-4 text-gray-700"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
faTrash,
|
||||
faListCheck,
|
||||
faPlus,
|
||||
faBoxArchive,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -119,6 +120,10 @@ const confirmChange = ref({
|
|||
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 };
|
||||
};
|
||||
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="{
|
||||
'opacity-50 cursor-not-allowed':
|
||||
!segments || segments.length === 0,
|
||||
!segments || segments.length === 0 || !c.active,
|
||||
}"
|
||||
:title="
|
||||
segments && segments.length
|
||||
!c.active
|
||||
? 'Segmenta ni mogoče spremeniti za arhivirano pogodbo'
|
||||
: segments && segments.length
|
||||
? 'Spremeni segment'
|
||||
: 'Ni segmentov na voljo za ta primer'
|
||||
"
|
||||
:disabled="!c.active || !segments || !segments.length"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faPenToSquare"
|
||||
|
|
@ -313,6 +321,11 @@ const closePaymentsDialog = () => {
|
|||
</div>
|
||||
</template>
|
||||
</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>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell class="text-right">{{
|
||||
|
|
@ -433,6 +446,7 @@ const closePaymentsDialog = () => {
|
|||
<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"
|
||||
v-if="c.active"
|
||||
@click="onEdit(c)"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
|
|
@ -444,6 +458,7 @@ const closePaymentsDialog = () => {
|
|||
<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"
|
||||
v-if="c.active"
|
||||
@click="onAddActivity(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faListCheck" class="h-4 w-4 text-gray-600" />
|
||||
|
|
@ -468,6 +483,7 @@ const closePaymentsDialog = () => {
|
|||
<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"
|
||||
v-if="c.active"
|
||||
@click="openObjectDialog(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" />
|
||||
|
|
@ -492,12 +508,62 @@ const closePaymentsDialog = () => {
|
|||
<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"
|
||||
v-if="c.active && c?.account"
|
||||
@click="openPaymentDialog(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" />
|
||||
<span>Dodaj plačilo</span>
|
||||
</button>
|
||||
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
<!-- 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" />
|
||||
<!-- Destruktivno -->
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -373,7 +373,14 @@ function referenceOf(entityName, ent) {
|
|||
<span>{{ activeEntity }}</span>
|
||||
<span
|
||||
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
|
||||
>
|
||||
<span
|
||||
|
|
@ -502,10 +509,25 @@ function referenceOf(entityName, ent) {
|
|||
</div>
|
||||
<div>
|
||||
Akcija:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].action_label ||
|
||||
r.entities[activeEntity].action
|
||||
}}</span>
|
||||
<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
|
||||
}}
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const form = useForm({
|
|||
source_type: "csv",
|
||||
default_record_type: "",
|
||||
is_active: true,
|
||||
reactivate: false,
|
||||
client_uuid: null,
|
||||
entities: [],
|
||||
meta: {
|
||||
|
|
@ -285,6 +286,10 @@ watch(
|
|||
<label for="is_active" class="text-sm font-medium text-gray-700"
|
||||
>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 class="pt-4">
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const form = useForm({
|
|||
source_type: props.template.source_type,
|
||||
default_record_type: props.template.default_record_type || "",
|
||||
is_active: props.template.is_active,
|
||||
reactivate: props.template.reactivate ?? false,
|
||||
client_uuid: props.template.client_uuid || null,
|
||||
sample_headers: props.template.sample_headers || [],
|
||||
// Add meta with default delimiter support
|
||||
|
|
@ -434,9 +435,11 @@ watch(
|
|||
type="checkbox"
|
||||
class="rounded"
|
||||
/>
|
||||
<label for="is_active" class="text-sm font-medium text-gray-700"
|
||||
>Aktivna</label
|
||||
>
|
||||
<label for="is_active" class="text-sm font-medium text-gray-700">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
|
||||
@click.prevent="save"
|
||||
class="ml-auto px-3 py-2 bg-indigo-600 text-white rounded"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
const drawerAddActivity = ref(false);
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -172,10 +222,13 @@ const submitComplete = () => {
|
|||
<!-- Client details (account holder) -->
|
||||
<div class="bg-white rounded-lg shadow border overflow-hidden">
|
||||
<div class="p-3 sm:p-4">
|
||||
<SectionTitle>
|
||||
<template #title>Stranka</template>
|
||||
</SectionTitle>
|
||||
<div class="mt-2">
|
||||
<h3
|
||||
class="text-base font-semibold text-gray-900 leading-tight flex items-center gap-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
|
||||
:types="types"
|
||||
:person="client.person"
|
||||
|
|
@ -188,14 +241,17 @@ const submitComplete = () => {
|
|||
<!-- Person (case person) -->
|
||||
<div class="bg-white rounded-lg shadow border overflow-hidden">
|
||||
<div class="p-3 sm:p-4">
|
||||
<SectionTitle>
|
||||
<template #title>Primer - oseba</template>
|
||||
</SectionTitle>
|
||||
<div class="mt-2">
|
||||
<h3
|
||||
class="text-base font-semibold text-gray-900 leading-tight flex items-center gap-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
|
||||
:types="types"
|
||||
:person="client_case.person"
|
||||
default-tab="phones"
|
||||
default-tab="addresses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -211,48 +267,82 @@ const submitComplete = () => {
|
|||
<div
|
||||
v-for="c in contracts"
|
||||
: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">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{{ c.reference || c.uuid }}</p>
|
||||
<p class="text-sm text-gray-600">Tip: {{ c.type?.name || "—" }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="space-y-2">
|
||||
<p v-if="c.account" class="text-sm text-gray-700">
|
||||
Odprto: {{ formatAmount(c.account.balance_amount) }} €
|
||||
<!-- Header Row -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<p
|
||||
class="font-semibold text-gray-900 text-sm leading-tight truncate"
|
||||
>
|
||||
{{ c.reference || c.uuid }}
|
||||
</p>
|
||||
<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"
|
||||
@click="openDrawerAddActivity(c)"
|
||||
<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"
|
||||
>
|
||||
+ Aktivnost
|
||||
</button>
|
||||
<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"
|
||||
@click="openDocDialog(c)"
|
||||
{{ 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
|
||||
>
|
||||
+ Dokument
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 w-32 text-right shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
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)"
|
||||
>
|
||||
+ Aktivnost
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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)"
|
||||
>
|
||||
+ Dokument
|
||||
</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 v-if="c.last_object" class="mt-2 text-sm text-gray-700">
|
||||
<p class="font-medium">Predmet:</p>
|
||||
<p>
|
||||
<span class="text-gray-900">{{
|
||||
c.last_object.name || c.last_object.reference
|
||||
}}</span>
|
||||
<span v-if="c.last_object.type" class="ml-2 text-gray-500"
|
||||
<!-- Subject / Last Object -->
|
||||
<div v-if="c.last_object" class="mt-3 border-t pt-3">
|
||||
<p class="text-[11px] uppercase tracking-wide text-gray-400 mb-1">
|
||||
Zadnji predmet
|
||||
</p>
|
||||
<div class="text-sm font-medium text-gray-800">
|
||||
{{ c.last_object.name || c.last_object.reference }}
|
||||
<span
|
||||
v-if="c.last_object.type"
|
||||
class="ml-2 text-xs font-normal text-gray-500"
|
||||
>({{ c.last_object.type }})</span
|
||||
>
|
||||
</p>
|
||||
<p v-if="c.last_object.description" class="text-gray-600 mt-1">
|
||||
</div>
|
||||
<div
|
||||
v-if="c.last_object.description"
|
||||
class="mt-1 text-sm text-gray-600 leading-snug"
|
||||
>
|
||||
{{ c.last_object.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!contracts?.length" class="text-sm text-gray-600">
|
||||
|
|
@ -270,40 +360,66 @@ const submitComplete = () => {
|
|||
<template #title>Aktivnosti</template>
|
||||
</SectionTitle>
|
||||
<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()"
|
||||
>
|
||||
Nova
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 divide-y">
|
||||
<div v-for="a in activities" :key="a.id" class="py-2 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-gray-800">
|
||||
{{ a.action?.name
|
||||
}}<span v-if="a.decision"> → {{ a.decision?.name }}</span>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="a in activities"
|
||||
:key="a.id"
|
||||
class="rounded-md border border-gray-200 bg-gray-50/70 px-3 py-3 shadow-sm text-[13px]"
|
||||
>
|
||||
<!-- 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="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
|
||||
>
|
||||
<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 v-if="a.note" class="text-gray-600">{{ a.note }}</div>
|
||||
<div class="text-gray-500">
|
||||
<span v-if="a.due_date">Zapadlost: {{ a.due_date }}</span>
|
||||
<span v-if="a.amount != null" class="ml-2"
|
||||
|
||||
<!-- Badges row -->
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<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
|
||||
>
|
||||
<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 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.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -423,6 +539,65 @@ const submitComplete = () => {
|
|||
</template>
|
||||
</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 -->
|
||||
<DialogModal :show="docDialogOpen" @close="closeDocDialog">
|
||||
<template #title>Dodaj dokument</template>
|
||||
|
|
@ -493,4 +668,27 @@ const submitComplete = () => {
|
|||
</AppPhoneLayout>
|
||||
</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>
|
||||
|
|
|
|||
591
resources/js/Pages/Settings/Archive/Index.vue
Normal file
591
resources/js/Pages/Settings/Archive/Index.vue
Normal 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>
|
||||
|
|
@ -1,41 +1,86 @@
|
|||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Settings">
|
||||
<template #header></template>
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<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>
|
||||
<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 class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<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>
|
||||
<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 class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<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>
|
||||
<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 class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<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>
|
||||
<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 class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<AppLayout title="Settings">
|
||||
<template #header></template>
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<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>
|
||||
<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 class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<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>
|
||||
<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 class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<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>
|
||||
<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 class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<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>
|
||||
<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 class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<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>
|
||||
<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>
|
||||
</AppLayout>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Charts\ExampleChart;
|
||||
use App\Http\Controllers\AccountBookingController;
|
||||
use App\Http\Controllers\AccountPaymentController;
|
||||
use App\Http\Controllers\ArchiveSettingController;
|
||||
use App\Http\Controllers\CaseObjectController;
|
||||
use App\Http\Controllers\ClientCaseContoller;
|
||||
use App\Http\Controllers\ClientController;
|
||||
|
|
@ -149,6 +150,7 @@
|
|||
Route::get('client-cases', [ClientCaseContoller::class, 'index'])->name('clientCase');
|
||||
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}/archive', [ClientCaseContoller::class, 'archiveContract'])->name('clientCase.contract.archive');
|
||||
Route::post('client-cases', [ClientCaseContoller::class, 'store'])->name('clientCase.store');
|
||||
// client-case / contract
|
||||
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::post('settings/segments', [SegmentController::class, 'store'])->name('settings.segments.store');
|
||||
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/field-job', [FieldJobSettingController::class, 'index'])->name('settings.fieldjob.index');
|
||||
|
||||
|
|
|
|||
56
tests/Feature/ArchiveContractAccountChainTest.php
Normal file
56
tests/Feature/ArchiveContractAccountChainTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
84
tests/Feature/ArchiveContractChainedEntitiesTest.php
Normal file
84
tests/Feature/ArchiveContractChainedEntitiesTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
66
tests/Feature/ArchiveContractSegmentTest.php
Normal file
66
tests/Feature/ArchiveContractSegmentTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
48
tests/Feature/ArchiveContractTest.php
Normal file
48
tests/Feature/ArchiveContractTest.php
Normal 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);
|
||||
});
|
||||
48
tests/Feature/ArchiveRunNowTest.php
Normal file
48
tests/Feature/ArchiveRunNowTest.php
Normal 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);
|
||||
});
|
||||
61
tests/Feature/ArchiveSettingCrudTest.php
Normal file
61
tests/Feature/ArchiveSettingCrudTest.php
Normal 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);
|
||||
});
|
||||
50
tests/Feature/ReactivateContractTest.php
Normal file
50
tests/Feature/ReactivateContractTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user