From 67ebe4b225f2a7d4f6a09eb1527653f32d0a0591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Wed, 22 Oct 2025 23:20:04 +0200 Subject: [PATCH] Big changes added events for decisions --- app/Events/ActivityDecisionApplied.php | 10 + app/Events/ChangeContractSegment.php | 17 + app/Events/ClientCaseToTerrain.php | 42 -- app/Events/ContractToTerrain.php | 43 -- app/Http/Controllers/ImportController.php | 84 +++ app/Http/Controllers/SegmentController.php | 36 +- app/Http/Controllers/WorkflowController.php | 133 ++++- app/Jobs/EndFieldJob.php | 100 ++++ app/Jobs/RunDecisionEvent.php | 88 +++ app/Listeners/AddClientCaseToTerrain.php | 41 -- app/Listeners/AddContractToTerrain.php | 37 -- app/Listeners/ApplyChangeContractSegment.php | 47 ++ app/Listeners/TriggerDecisionEvents.php | 77 +++ app/Models/Activity.php | 6 + app/Models/Decision.php | 4 +- app/Models/Event.php | 12 + app/Providers/EventServiceProvider.php | 10 + .../Contracts/DecisionEventHandler.php | 10 + .../DecisionEvents/DecisionEventContext.php | 22 + .../Handlers/AddSegmentHandler.php | 58 ++ .../Handlers/ArchiveContractHandler.php | 110 ++++ .../Handlers/EndFieldJobHandler.php | 36 ++ app/Services/DecisionEvents/Registry.php | 36 ++ ..._decision_event_tables_and_create_logs.php | 97 ++++ database/seeders/EventSeeder.php | 37 +- .../Pages/Cases/Partials/ActivityDrawer.vue | 4 +- resources/js/Pages/Imports/Import.vue | 107 ++++ resources/js/Pages/Phone/Case/Index.vue | 36 +- resources/js/Pages/Segments/Show.vue | 66 ++- .../Pages/Settings/Partials/DecisionTable.vue | 533 +++++++++++++++++- .../js/Pages/Settings/Workflow/Index.vue | 5 + routes/web.php | 2 + .../ActivityTriggersDecisionEventsTest.php | 131 +++++ .../ActivityTriggersEndFieldJobTest.php | 122 ++++ .../Workflow/StoreDecisionEventsTest.php | 91 +++ .../Workflow/UpdateDecisionEventsTest.php | 139 +++++ 36 files changed, 2240 insertions(+), 189 deletions(-) create mode 100644 app/Events/ActivityDecisionApplied.php create mode 100644 app/Events/ChangeContractSegment.php delete mode 100644 app/Events/ClientCaseToTerrain.php delete mode 100644 app/Events/ContractToTerrain.php create mode 100644 app/Jobs/EndFieldJob.php create mode 100644 app/Jobs/RunDecisionEvent.php delete mode 100644 app/Listeners/AddClientCaseToTerrain.php delete mode 100644 app/Listeners/AddContractToTerrain.php create mode 100644 app/Listeners/ApplyChangeContractSegment.php create mode 100644 app/Listeners/TriggerDecisionEvents.php create mode 100644 app/Services/DecisionEvents/Contracts/DecisionEventHandler.php create mode 100644 app/Services/DecisionEvents/DecisionEventContext.php create mode 100644 app/Services/DecisionEvents/Handlers/AddSegmentHandler.php create mode 100644 app/Services/DecisionEvents/Handlers/ArchiveContractHandler.php create mode 100644 app/Services/DecisionEvents/Handlers/EndFieldJobHandler.php create mode 100644 app/Services/DecisionEvents/Registry.php create mode 100644 database/migrations/2025_10_22_000001_update_events_and_decision_event_tables_and_create_logs.php create mode 100644 tests/Feature/Activities/ActivityTriggersDecisionEventsTest.php create mode 100644 tests/Feature/Activities/ActivityTriggersEndFieldJobTest.php create mode 100644 tests/Feature/Workflow/StoreDecisionEventsTest.php create mode 100644 tests/Feature/Workflow/UpdateDecisionEventsTest.php diff --git a/app/Events/ActivityDecisionApplied.php b/app/Events/ActivityDecisionApplied.php new file mode 100644 index 0000000..ec62636 --- /dev/null +++ b/app/Events/ActivityDecisionApplied.php @@ -0,0 +1,10 @@ +clientCase = $clientCase; - } - - /** - * Get the channels the event should broadcast on. - * - * @return array - */ - public function broadcastOn(): PrivateChannel - { - return new PrivateChannel('segments'.$this->clientCase->id); - } - - public function broadcastAs(){ - return 'client_case.terrain.add'; - } -} diff --git a/app/Events/ContractToTerrain.php b/app/Events/ContractToTerrain.php deleted file mode 100644 index bd6378d..0000000 --- a/app/Events/ContractToTerrain.php +++ /dev/null @@ -1,43 +0,0 @@ -contract = $contract; - $this->segment = $segment; - } - - /** - * Get the channels the event should broadcast on. - * - * @return array - */ - public function broadcastOn(): PrivateChannel - { - return new PrivateChannel('contracts.'.$this->segment->id); - } - - public function broadcastAs(){ - return 'contract.terrain.add'; - } -} diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index 381d886..455cc39 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -489,6 +489,90 @@ public function getEvents(Import $import) return response()->json(['events' => $events]); } + // List unresolved keyref contract rows (based on events containing keyref-not-found) + public function missingKeyrefRows(Import $import) + { + // Identify row IDs from events. Prefer specific event key, fallback to message pattern + $rowIds = \App\Models\ImportEvent::query() + ->where('import_id', $import->id) + ->where(function ($q) { + $q->where('event', 'contract_keyref_not_found') + ->orWhereRaw('LOWER(message) LIKE ?', ['%keyref%not found%']); + }) + ->whereNotNull('import_row_id') + ->pluck('import_row_id') + ->filter() + ->unique() + ->values(); + + if ($rowIds->isEmpty()) { + return response()->json([ + 'columns' => (array) ($import->meta['columns'] ?? []), + 'rows' => [], + ]); + } + + $rows = \App\Models\ImportRow::query() + ->where('import_id', $import->id) + ->whereIn('id', $rowIds) + ->orderBy('row_number') + ->get(['id', 'row_number', 'raw_data']); + + $columns = (array) ($import->meta['columns'] ?? []); + // If no stored header, derive from first row raw_data keys + if (empty($columns)) { + $first = $rows->first(); + if ($first && is_array($first->raw_data)) { + $columns = array_keys($first->raw_data); + } + } + + // Normalize each row to ordered array by $columns + $dataRows = []; + foreach ($rows as $r) { + $line = []; + foreach ($columns as $col) { + $line[] = (string) ($r->raw_data[$col] ?? ''); + } + $dataRows[] = [ + 'id' => $r->id, + 'row_number' => $r->row_number, + 'values' => $line, + ]; + } + + return response()->json([ + 'columns' => $columns, + 'rows' => $dataRows, + ]); + } + + // Export unresolved keyref rows as CSV (includes header if available) + public function exportMissingKeyrefCsv(Import $import) + { + $json = $this->missingKeyrefRows($import)->getData(true); + $columns = $json['columns'] ?? []; + $rows = $json['rows'] ?? []; + + $fh = fopen('php://temp', 'r+'); + if (! empty($columns)) { + fputcsv($fh, $columns); + } + foreach ($rows as $r) { + fputcsv($fh, $r['values'] ?? []); + } + rewind($fh); + $csv = stream_get_contents($fh); + fclose($fh); + + $filename = 'missing-keyref-rows-'.$import->id.'.csv'; + + return response($csv, 200, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + ]); + } + // Preview (up to N) raw CSV rows for an import for mapping review public function preview(Import $import, Request $request) { diff --git a/app/Http/Controllers/SegmentController.php b/app/Http/Controllers/SegmentController.php index 3046b94..d02fef5 100644 --- a/app/Http/Controllers/SegmentController.php +++ b/app/Http/Controllers/SegmentController.php @@ -48,6 +48,7 @@ public function show(\App\Models\Segment $segment) { // Retrieve contracts that are active in this segment, eager-loading required relations $search = request('search'); + $clientFilter = request('client') ?? request('client_id'); // support either ?client= or ?client_id= $contractsQuery = \App\Models\Contract::query() ->whereHas('segments', function ($q) use ($segment) { $q->where('segments.id', $segment->id) @@ -61,7 +62,18 @@ public function show(\App\Models\Segment $segment) ]) ->latest('id'); - if (!empty($search)) { + // Optional filter by client (accepts numeric id or client uuid) + if (! empty($clientFilter)) { + $contractsQuery->whereHas('clientCase.client', function ($q) use ($clientFilter) { + if (is_numeric($clientFilter)) { + $q->where('clients.id', (int) $clientFilter); + } else { + $q->where('clients.uuid', $clientFilter); + } + }); + } + + if (! empty($search)) { $contractsQuery->where(function ($qq) use ($search) { $qq->where('contracts.reference', 'ilike', '%'.$search.'%') ->orWhereHas('clientCase.person', function ($p) use ($search) { @@ -88,9 +100,27 @@ public function show(\App\Models\Segment $segment) $contracts->setCollection($items); } + // Build a full client list for this segment (not limited to current page) for the dropdown + $clients = \App\Models\Client::query() + ->whereHas('clientCases.contracts.segments', function ($q) use ($segment) { + $q->where('segments.id', $segment->id) + ->where('contract_segment.active', '=', 1); + }) + ->with(['person:id,full_name']) + ->get(['uuid', 'person_id']) + ->map(function ($c) { + return [ + 'uuid' => (string) $c->uuid, + 'name' => (string) optional($c->person)->full_name, + ]; + }) + ->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE) + ->values(); + return Inertia::render('Segments/Show', [ - 'segment' => $segment->only(['id','name','description']), + 'segment' => $segment->only(['id', 'name', 'description']), 'contracts' => $contracts, + 'clients' => $clients, ]); } @@ -120,7 +150,7 @@ public function update(UpdateSegmentRequest $request, Segment $segment) 'name' => $data['name'], 'description' => $data['description'] ?? null, 'active' => $data['active'] ?? $segment->active, - 'exclude' => $data['exclude'] ?? $segment->exclude + 'exclude' => $data['exclude'] ?? $segment->exclude, ]); return to_route('settings.segments')->with('success', 'Segment updated'); diff --git a/app/Http/Controllers/WorkflowController.php b/app/Http/Controllers/WorkflowController.php index 2498478..a6d6083 100644 --- a/app/Http/Controllers/WorkflowController.php +++ b/app/Http/Controllers/WorkflowController.php @@ -3,10 +3,12 @@ namespace App\Http\Controllers; use App\Models\Action; +use App\Models\ArchiveSetting; use App\Models\Decision; use App\Models\EmailTemplate; use App\Models\Segment; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; use Inertia\Inertia; class WorkflowController extends Controller @@ -15,9 +17,11 @@ public function index(Request $request) { return Inertia::render('Settings/Workflow/Index', [ 'actions' => Action::query()->with(['decisions', 'segment'])->withCount('activities')->orderBy('id')->get(), - 'decisions' => Decision::query()->with('actions')->withCount('activities')->orderBy('id')->get(), + 'decisions' => Decision::query()->with(['actions', 'events'])->withCount('activities')->orderBy('id')->get(), 'segments' => Segment::query()->get(), 'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']), + 'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']), + 'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']), ]); } @@ -87,11 +91,43 @@ public function storeDecision(Request $request) 'actions' => 'nullable|array', 'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id', 'actions.*.name' => 'required_with:actions.*|string|max:50', + 'events' => 'nullable|array', + 'events.*.id' => 'required_with:events.*|integer|exists:events,id', + 'events.*.active' => 'sometimes|boolean', + 'events.*.run_order' => 'nullable|integer', + 'events.*.config' => 'nullable|array', ]); $actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray(); + $eventsPayload = collect($attributes['events'] ?? []); - \DB::transaction(function () use ($attributes, $actionIds) { + // Extra server-side validation for event-specific config keys + $validationErrors = []; + foreach ($eventsPayload as $i => $ev) { + $idEv = isset($ev['id']) ? (int) $ev['id'] : null; + if (! $idEv) { + continue; + } + $eventModel = \App\Models\Event::find($idEv); + $key = $eventModel?->key ?? ($ev['key'] ?? null); + if ($key === 'add_segment') { + $seg = $ev['config']['segment_id'] ?? null; + if (empty($seg) || ! Segment::where('id', $seg)->exists()) { + $validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.'; + } + } elseif ($key === 'archive_contract') { + $as = $ev['config']['archive_setting_id'] ?? null; + if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) { + $validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.'; + } + } + } + + if (! empty($validationErrors)) { + throw ValidationException::withMessages($validationErrors); + } + + \DB::transaction(function () use ($attributes, $actionIds, $eventsPayload) { /** @var \App\Models\Decision $row */ $row = Decision::create([ 'name' => $attributes['name'], @@ -103,6 +139,32 @@ public function storeDecision(Request $request) if (! empty($actionIds)) { $row->actions()->sync($actionIds); } + + // Attach decision events with pivot attributes + if ($eventsPayload->isNotEmpty()) { + $sync = []; + foreach ($eventsPayload as $ev) { + $id = (int) ($ev['id'] ?? 0); + if ($id <= 0) { + continue; + } + $cfg = $ev['config'] ?? null; + if (is_array($cfg)) { + $cfg = json_encode($cfg); + } elseif (is_string($cfg)) { + $trim = trim($cfg); + $cfg = $trim === '' ? null : $cfg; + } else { + $cfg = null; + } + $sync[$id] = [ + 'active' => (bool) ($ev['active'] ?? true), + 'run_order' => isset($ev['run_order']) ? (int) $ev['run_order'] : null, + 'config' => $cfg, + ]; + } + $row->events()->sync($sync); + } }); return to_route('settings.workflow')->with('success', 'Decision created successfully!'); @@ -120,11 +182,43 @@ public function updateDecision(int $id, Request $request) 'actions' => 'nullable|array', 'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id', 'actions.*.name' => 'required_with:actions.*|string|max:50', + 'events' => 'nullable|array', + 'events.*.id' => 'required_with:events.*|integer|exists:events,id', + 'events.*.active' => 'sometimes|boolean', + 'events.*.run_order' => 'nullable|integer', + 'events.*.config' => 'nullable|array', ]); $actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray(); + $eventsPayload = collect($attributes['events'] ?? []); - \DB::transaction(function () use ($attributes, $actionIds, $row) { + // Extra server-side validation for event-specific config keys + $validationErrors = []; + foreach ($eventsPayload as $i => $ev) { + $idEv = isset($ev['id']) ? (int) $ev['id'] : null; + if (! $idEv) { + continue; + } + $eventModel = \App\Models\Event::find($idEv); + $key = $eventModel?->key ?? ($ev['key'] ?? null); + if ($key === 'add_segment') { + $seg = $ev['config']['segment_id'] ?? null; + if (empty($seg) || ! Segment::where('id', $seg)->exists()) { + $validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.'; + } + } elseif ($key === 'archive_contract') { + $as = $ev['config']['archive_setting_id'] ?? null; + if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) { + $validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.'; + } + } + } + + if (! empty($validationErrors)) { + throw ValidationException::withMessages($validationErrors); + } + + \DB::transaction(function () use ($attributes, $actionIds, $eventsPayload, $row) { $row->update([ 'name' => $attributes['name'], 'color_tag' => $attributes['color_tag'] ?? null, @@ -132,6 +226,39 @@ public function updateDecision(int $id, Request $request) 'email_template_id' => $attributes['email_template_id'] ?? null, ]); $row->actions()->sync($actionIds); + + // Sync decision events with pivot attributes + if ($eventsPayload->isNotEmpty()) { + $sync = []; + foreach ($eventsPayload as $ev) { + $id = (int) ($ev['id'] ?? 0); + if ($id <= 0) { + continue; + } + $cfg = $ev['config'] ?? null; + // ensure string JSON stored; accept already-JSON strings + if (is_array($cfg)) { + $cfg = json_encode($cfg); + } elseif (is_string($cfg)) { + $trim = trim($cfg); + // If not valid JSON, keep raw string (handler side can parse/ignore) + $cfg = $trim === '' ? null : $cfg; + } else { + $cfg = null; + } + $sync[$id] = [ + 'active' => (bool) ($ev['active'] ?? true), + 'run_order' => isset($ev['run_order']) ? (int) $ev['run_order'] : null, + 'config' => $cfg, + ]; + } + $row->events()->sync($sync); + } else { + // If empty provided explicitly, detach all to reflect UI intent + if (array_key_exists('events', $attributes)) { + $row->events()->detach(); + } + } }); return to_route('settings.workflow')->with('success', 'Decision updated successfully!'); diff --git a/app/Jobs/EndFieldJob.php b/app/Jobs/EndFieldJob.php new file mode 100644 index 0000000..cec08ca --- /dev/null +++ b/app/Jobs/EndFieldJob.php @@ -0,0 +1,100 @@ +find($this->activityId); + + // Determine target contract ID + $contractId = $this->contractId; + if (! $contractId && $activity) { + $contractId = $activity->contract_id; + } + if (! $contractId) { + \Log::warning('EndFieldJob: missing contract id', ['activity_id' => $this->activityId]); + + return; + } + + // Use latest FieldJobSetting as the source of action/decision/segments + $setting = FieldJobSetting::query()->latest('id')->first(); + + $triggeredByEvent = (bool) $activity; // this job is invoked from a decision event when an Activity exists + + DB::transaction(function () use ($contractId, $setting, $triggeredByEvent): void { + // Find active field job for this contract + $job = FieldJob::query() + ->where('contract_id', $contractId) + ->whereNull('completed_at') + ->whereNull('cancelled_at') + ->latest('id') + ->first(); + + if ($job) { + // Complete the job (updated hook moves segment appropriately) + $job->completed_at = now(); + $job->save(); + + // Optionally log a completion activity. + // By default, we SKIP creating an extra activity when triggered by a decision event (to avoid duplicates). + // To force creation from an event, set config['create_activity_from_event'] = true on the decision event. + // For non-event triggers, set config['create_activity'] = true to allow creation. + $shouldCreateActivity = $triggeredByEvent + ? (bool) ($this->config['create_activity_from_event'] ?? false) + : (bool) ($this->config['create_activity'] ?? false); + + if ($shouldCreateActivity) { + $job->loadMissing('contract'); + $actionId = optional($job->setting)->action_id ?? optional($setting)->action_id; + $decisionId = optional($job->setting)->complete_decision_id ?? optional($setting)->complete_decision_id; + if ($actionId && $decisionId && $job->contract) { + Activity::create([ + 'due_date' => null, + 'amount' => null, + 'note' => 'Terensko opravilo zaključeno', + 'action_id' => $actionId, + 'decision_id' => $decisionId, + 'client_case_id' => $job->contract->client_case_id, + 'contract_id' => $job->contract_id, + ]); + } + } + } else { + // No active job: still move contract to the configured return segment if available + if ($setting && $setting->return_segment_id) { + $tmp = new FieldJob; + $tmp->contract_id = $contractId; + $tmp->moveContractToSegment($setting->return_segment_id); + } + } + }); + + \Log::info('EndFieldJob executed', [ + 'activity_id' => $this->activityId, + 'contract_id' => $contractId, + 'config' => $this->config, + ]); + } +} diff --git a/app/Jobs/RunDecisionEvent.php b/app/Jobs/RunDecisionEvent.php new file mode 100644 index 0000000..b4c26f3 --- /dev/null +++ b/app/Jobs/RunDecisionEvent.php @@ -0,0 +1,88 @@ +activityId.'|'.$this->eventId.'|'.$this->eventKey); + + // Ensure log table record and uniqueness + $exists = DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->first(); + if ($exists && ($exists->status ?? null) === 'succeeded') { + return; // already processed successfully + } + try { + $activity = Activity::with(['decision', 'contract', 'clientCase.client', 'user'])->findOrFail($this->activityId); + + if (! $exists) { + DB::table('decision_event_logs')->insert([ + 'decision_id' => optional($activity->decision)->id, + 'event_id' => $this->eventId, + 'activity_id' => $this->activityId, + 'handler' => $this->eventKey, + 'status' => 'queued', + 'idempotency_key' => $idempotencyKey, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $exists = (object) ['status' => 'queued']; + } + + DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([ + 'status' => 'running', + 'started_at' => now(), + 'updated_at' => now(), + ]); + $event = DecisionEventModel::findOrFail($this->eventId); + + $handler = Registry::resolve($this->eventKey); + $context = new DecisionEventContext( + activity: $activity, + decision: $activity->decision, + contract: $activity->contract, + clientCase: $activity->clientCase, + client: optional($activity->clientCase)->client, + user: $activity->user, + ); + + $handler->handle($context, $this->config); + + DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([ + 'status' => 'succeeded', + 'finished_at' => now(), + 'updated_at' => now(), + ]); + } catch (\Throwable $e) { + DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([ + 'status' => 'failed', + 'message' => substr($e->getMessage(), 0, 2000), + 'finished_at' => now(), + 'updated_at' => now(), + ]); + throw $e; // allow retry + } + } +} diff --git a/app/Listeners/AddClientCaseToTerrain.php b/app/Listeners/AddClientCaseToTerrain.php deleted file mode 100644 index 3e66ccb..0000000 --- a/app/Listeners/AddClientCaseToTerrain.php +++ /dev/null @@ -1,41 +0,0 @@ -clientCase; - $segment = \App\Models\Segment::where('name','terrain')->firstOrFail(); - - if( $segment ) { - $clientCase->segments()->detach($segment->id); - $clientCase->segments()->attach( - $segment->id, - ); - - \Log::info("Added contract to terrain", ['contract_id' => $clientCase->id, 'segment' => $segment->name ]); - } - } - - public function failed(ClientCaseToTerrain $event, $exception) - { - \Log::error('Failed to update inventory', ['contract_id' => $event->clientCase->id, 'error' => $exception->getMessage()]); - } -} diff --git a/app/Listeners/AddContractToTerrain.php b/app/Listeners/AddContractToTerrain.php deleted file mode 100644 index 3faecc1..0000000 --- a/app/Listeners/AddContractToTerrain.php +++ /dev/null @@ -1,37 +0,0 @@ -contract; - $segment = $event->segment->where('name', 'terrain')->firstOrFail(); - - if($segment) { - $contract->segments()->attach($segment->id); - //\Log::info("Added contract to terrain", ['contract_id' => $contract->id, 'segment' => $segment->name ]); - } - } - - public function failed(ContractToTerrain $event, $exception) - { - //\Log::error('Failed to update inventory', ['contract_id' => $event->contract->id, 'error' => $exception->getMessage()]); - } -} diff --git a/app/Listeners/ApplyChangeContractSegment.php b/app/Listeners/ApplyChangeContractSegment.php new file mode 100644 index 0000000..72902ea --- /dev/null +++ b/app/Listeners/ApplyChangeContractSegment.php @@ -0,0 +1,47 @@ +contract; + $segmentId = (int) $event->segmentId; + if ($segmentId <= 0) { + return; + } + + DB::transaction(function () use ($contract, $segmentId, $event) { + if ($event->deactivatePrevious) { + DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('active', 1) + ->update(['active' => 0, 'updated_at' => now()]); + } + + $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' => 1, 'updated_at' => now()]); + } else { + DB::table('contract_segment')->insert([ + 'contract_id' => $contract->id, + 'segment_id' => $segmentId, + 'active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + }); + } +} diff --git a/app/Listeners/TriggerDecisionEvents.php b/app/Listeners/TriggerDecisionEvents.php new file mode 100644 index 0000000..d2a7ae0 --- /dev/null +++ b/app/Listeners/TriggerDecisionEvents.php @@ -0,0 +1,77 @@ +activity->loadMissing(['decision.events' => function ($q) { + $q->wherePivot('active', true); + }, 'contract', 'clientCase.client', 'user']); + + $decision = $activity->decision; + if (! $decision) { + return; + } + + $events = $decision->events; + if ($events->isEmpty()) { + return; + } + + // Sort by run_order when provided; otherwise keep natural order + $sorted = $events->sortBy(function ($ev) { + return $ev->pivot?->run_order ?? PHP_INT_MAX; + })->values(); + + $jobs = []; + foreach ($sorted as $ev) { + $base = is_array($ev->config ?? null) ? $ev->config : []; + $pivotCfgRaw = $ev->pivot?->config ?? null; + $pivotCfg = is_array($pivotCfgRaw) + ? $pivotCfgRaw + : (is_string($pivotCfgRaw) ? (json_decode($pivotCfgRaw, true) ?: []) : []); + $effectiveConfig = array_replace_recursive($base, $pivotCfg); + + $jobs[] = new RunDecisionEvent( + activityId: $activity->id, + eventId: $ev->id, + eventKey: (string) ($ev->key ?? ''), + config: $effectiveConfig, + ); + } + + // If any event has a finite run_order, chain to enforce order; else dispatch in parallel + $hasOrder = $sorted->contains(fn ($ev) => $ev->pivot?->run_order !== null); + + // Run synchronously for local/dev/testing (or when debug is on) to ensure immediate effects without a queue worker + $shouldRunSync = app()->environment(['local', 'development', 'dev', 'testing']) + || (bool) config('app.debug') + || config('queue.default') === 'sync'; + + if ($hasOrder) { + if ($shouldRunSync) { + foreach ($jobs as $job) { + Bus::dispatchSync($job); + } + } else { + Bus::chain($jobs)->dispatch(); + } + } else { + if ($shouldRunSync) { + foreach ($jobs as $job) { + Bus::dispatchSync($job); + } + } else { + foreach ($jobs as $job) { + dispatch($job); + } + } + } + } +} diff --git a/app/Models/Activity.php b/app/Models/Activity.php index bef1bf6..d8c8950 100644 --- a/app/Models/Activity.php +++ b/app/Models/Activity.php @@ -49,6 +49,12 @@ protected static function booted() ); } }); + + static::created(function (Activity $activity) { + if (! empty($activity->decision_id)) { + event(new \App\Events\ActivityDecisionApplied($activity)); + } + }); } public function action(): BelongsTo diff --git a/app/Models/Decision.php b/app/Models/Decision.php index 7cfaa76..1fb9d40 100644 --- a/app/Models/Decision.php +++ b/app/Models/Decision.php @@ -29,7 +29,9 @@ public function actions(): BelongsToMany public function events(): BelongsToMany { - return $this->belongsToMany(\App\Models\Event::class); + return $this->belongsToMany(\App\Models\Event::class) + ->withPivot(['run_order', 'active', 'config']) + ->withTimestamps(); } public function activities(): HasMany diff --git a/app/Models/Event.php b/app/Models/Event.php index 4c79302..306bcf2 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -11,6 +11,18 @@ class Event extends Model /** @use HasFactory<\Database\Factories\EventFactory> */ use HasFactory; + protected $fillable = [ + 'name', 'key', 'description', 'active', 'config', + ]; + + protected function casts(): array + { + return [ + 'active' => 'boolean', + 'config' => 'array', + ]; + } + public function decisions(): BelongsToMany { return $this->belongsToMany(\App\Models\Decision::class); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 815f4c2..38b5618 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,8 +2,12 @@ namespace App\Providers; +use App\Events\ActivityDecisionApplied; +use App\Events\ChangeContractSegment; use App\Events\DocumentGenerated; +use App\Listeners\ApplyChangeContractSegment; use App\Listeners\LogDocumentGenerated; +use App\Listeners\TriggerDecisionEvents; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; class EventServiceProvider extends ServiceProvider @@ -12,5 +16,11 @@ class EventServiceProvider extends ServiceProvider DocumentGenerated::class => [ LogDocumentGenerated::class, ], + ActivityDecisionApplied::class => [ + TriggerDecisionEvents::class, + ], + ChangeContractSegment::class => [ + ApplyChangeContractSegment::class, + ], ]; } diff --git a/app/Services/DecisionEvents/Contracts/DecisionEventHandler.php b/app/Services/DecisionEvents/Contracts/DecisionEventHandler.php new file mode 100644 index 0000000..dde99bf --- /dev/null +++ b/app/Services/DecisionEvents/Contracts/DecisionEventHandler.php @@ -0,0 +1,10 @@ +contract; + if (! $contract) { + // If no contract on activity, nothing to apply + return; + } + + $segmentId = (int) ($config['segment_id'] ?? 0); + if ($segmentId <= 0) { + throw new InvalidArgumentException('add_segment requires a valid segment_id'); + } + + $deactivatePrevious = array_key_exists('deactivate_previous', $config) + ? (bool) $config['deactivate_previous'] + : true; + + DB::transaction(function () use ($contract, $segmentId, $deactivatePrevious) { + if ($deactivatePrevious) { + DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('active', 1) + ->update(['active' => 0, 'updated_at' => now()]); + } + + // Ensure pivot exists and mark active=1 + $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' => 1, 'updated_at' => now()]); + } else { + DB::table('contract_segment')->insert([ + 'contract_id' => $contract->id, + 'segment_id' => $segmentId, + 'active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + }); + } +} diff --git a/app/Services/DecisionEvents/Handlers/ArchiveContractHandler.php b/app/Services/DecisionEvents/Handlers/ArchiveContractHandler.php new file mode 100644 index 0000000..0d05e0c --- /dev/null +++ b/app/Services/DecisionEvents/Handlers/ArchiveContractHandler.php @@ -0,0 +1,110 @@ +reactivate per run + */ + public function handle(DecisionEventContext $context, array $config = []): void + { + $activity = $context->activity; + + // Require a contract_id from the activity per request requirements + $contractId = (int) ($activity->contract_id ?? 0); + if ($contractId <= 0) { + throw new \InvalidArgumentException('ArchiveContractHandler requires activity.contract_id'); + } + + $settingId = (int) ($config['archive_setting_id'] ?? 0); + if ($settingId <= 0) { + throw new \InvalidArgumentException('ArchiveContractHandler requires config.archive_setting_id'); + } + + $setting = ArchiveSetting::query()->findOrFail($settingId); + + // Optionally override reactivate flag for this run + if (array_key_exists('reactivate', $config)) { + $setting->reactivate = (bool) $config['reactivate']; + } + + $results = app(ArchiveExecutor::class)->executeSetting( + $setting, + ['contract_id' => $contractId], + $activity->user_id ?? null, + null, + ); + + // If the ArchiveSetting specifies a segment, move the contract to that segment (mirror controller logic) + if (! empty($setting->segment_id)) { + try { + $segmentId = (int) $setting->segment_id; + $clientCase = $context->clientCase; // expected per context contract + $contract = $context->contract; // already loaded on context + if ($clientCase && $contract) { + \DB::transaction(function () use ($clientCase, $contract, $segmentId) { + // 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 target 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) { + // Do not fail the event; log warning for diagnostics + logger()->warning('ArchiveContractHandler: failed to move contract to segment', [ + 'error' => $e->getMessage(), + 'setting_id' => $setting->id, + 'segment_id' => $setting->segment_id, + 'contract_id' => $contractId, + ]); + } + } + } +} diff --git a/app/Services/DecisionEvents/Handlers/EndFieldJobHandler.php b/app/Services/DecisionEvents/Handlers/EndFieldJobHandler.php new file mode 100644 index 0000000..e875641 --- /dev/null +++ b/app/Services/DecisionEvents/Handlers/EndFieldJobHandler.php @@ -0,0 +1,36 @@ +activity; + $job = new EndFieldJob( + activityId: (int) $activity->id, + contractId: $activity->contract_id ? (int) $activity->contract_id : null, + config: $config, + ); + + // Run synchronously in local/dev/testing or when debug is on or queue driver is sync; otherwise queue it. + $shouldRunSync = app()->environment(['local', 'development', 'dev', 'testing']) + || (bool) config('app.debug') + || config('queue.default') === 'sync'; + + if ($shouldRunSync) { + Bus::dispatchSync($job); + } else { + dispatch($job); + } + } +} diff --git a/app/Services/DecisionEvents/Registry.php b/app/Services/DecisionEvents/Registry.php new file mode 100644 index 0000000..a2cabff --- /dev/null +++ b/app/Services/DecisionEvents/Registry.php @@ -0,0 +1,36 @@ +> + */ + protected static array $map = [ + 'add_segment' => AddSegmentHandler::class, + 'archive_contract' => \App\Services\DecisionEvents\Handlers\ArchiveContractHandler::class, + 'end_field_job' => \App\Services\DecisionEvents\Handlers\EndFieldJobHandler::class, + ]; + + public static function resolve(string $key): DecisionEventHandler + { + $key = trim(strtolower($key)); + $class = static::$map[$key] ?? null; + if (! $class || ! class_exists($class)) { + throw new InvalidArgumentException("Unknown decision event handler for key: {$key}"); + } + $handler = app($class); + if (! $handler instanceof DecisionEventHandler) { + throw new InvalidArgumentException("Handler for key {$key} must implement DecisionEventHandler"); + } + + return $handler; + } +} diff --git a/database/migrations/2025_10_22_000001_update_events_and_decision_event_tables_and_create_logs.php b/database/migrations/2025_10_22_000001_update_events_and_decision_event_tables_and_create_logs.php new file mode 100644 index 0000000..a47266a --- /dev/null +++ b/database/migrations/2025_10_22_000001_update_events_and_decision_event_tables_and_create_logs.php @@ -0,0 +1,97 @@ +string('key')->nullable()->after('id'); + } + if (! Schema::hasColumn('events', 'description')) { + $table->text('description')->nullable()->after('name'); + } + if (! Schema::hasColumn('events', 'active')) { + $table->boolean('active')->default(true)->after('name'); + } + if (! Schema::hasColumn('events', 'config')) { + $table->json('config')->nullable()->after('active'); + } + }); + + // decision_event pivot enhancements + if (Schema::hasTable('decision_event')) { + Schema::table('decision_event', function (Blueprint $table) { + if (! Schema::hasColumn('decision_event', 'run_order')) { + $table->integer('run_order')->nullable()->after('event_id'); + } + if (! Schema::hasColumn('decision_event', 'active')) { + $table->boolean('active')->default(true)->after('run_order'); + } + if (! Schema::hasColumn('decision_event', 'config')) { + $table->json('config')->nullable()->after('active'); + } + }); + } + + // logs table + if (! Schema::hasTable('decision_event_logs')) { + Schema::create('decision_event_logs', function (Blueprint $table) { + $table->id(); + $table->foreignIdFor(\App\Models\Decision::class)->nullable(); + $table->foreignIdFor(\App\Models\Event::class, 'event_id'); + $table->foreignIdFor(\App\Models\Activity::class); + $table->string('handler')->nullable(); + $table->enum('status', ['queued', 'running', 'succeeded', 'failed', 'skipped'])->default('queued'); + $table->text('message')->nullable(); + $table->string('idempotency_key')->unique(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + $table->index(['decision_id', 'event_id']); + $table->index(['activity_id']); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('decision_event_logs')) { + Schema::drop('decision_event_logs'); + } + + if (Schema::hasTable('decision_event')) { + Schema::table('decision_event', function (Blueprint $table) { + if (Schema::hasColumn('decision_event', 'config')) { + $table->dropColumn('config'); + } + if (Schema::hasColumn('decision_event', 'active')) { + $table->dropColumn('active'); + } + if (Schema::hasColumn('decision_event', 'run_order')) { + $table->dropColumn('run_order'); + } + }); + } + + Schema::table('events', function (Blueprint $table) { + if (Schema::hasColumn('events', 'config')) { + $table->dropColumn('config'); + } + if (Schema::hasColumn('events', 'active')) { + $table->dropColumn('active'); + } + if (Schema::hasColumn('events', 'description')) { + $table->dropColumn('description'); + } + if (Schema::hasColumn('events', 'key')) { + $table->dropColumn('key'); + } + }); + } +}; diff --git a/database/seeders/EventSeeder.php b/database/seeders/EventSeeder.php index 4ca981e..ca508c4 100644 --- a/database/seeders/EventSeeder.php +++ b/database/seeders/EventSeeder.php @@ -3,8 +3,8 @@ namespace Database\Seeders; use App\Models\Event; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\DB; class EventSeeder extends Seeder { @@ -13,12 +13,39 @@ class EventSeeder extends Seeder */ public function run(): void { - $events = [ - [ 'name' => 'client_case.terrain.add', 'options' => json_encode([]) ] + // Seed the new decision event type(s) + // Use query builder to satisfy legacy NOT NULL "options" column + $rows = [ + [ + 'key' => 'add_segment', + 'name' => 'Add segment', + 'description' => 'Activates a target segment for a contract and deactivates previous ones.', + ], + [ + 'key' => 'archive_contract', + 'name' => 'Archive contract', + 'description' => 'Runs ArchiveExecutor for the activity\'s contract using a specified ArchiveSetting.', + ], + [ + 'key' => 'end_field_job', + 'name' => 'End field job', + 'description' => 'Dispatches a queued job to finalize field-related processing (implementation-specific).', + ], ]; - foreach($events as $e) { - Event::create($e); + foreach ($rows as $row) { + DB::table('events')->updateOrInsert( + ['key' => $row['key']], + [ + 'name' => $row['name'], + 'description' => $row['description'], + 'active' => true, + 'config' => json_encode([]), + 'options' => json_encode([]), + 'updated_at' => now(), + 'created_at' => now(), + ] + ); } } } diff --git a/resources/js/Pages/Cases/Partials/ActivityDrawer.vue b/resources/js/Pages/Cases/Partials/ActivityDrawer.vue index a8d4666..b671cbe 100644 --- a/resources/js/Pages/Cases/Partials/ActivityDrawer.vue +++ b/resources/js/Pages/Cases/Partials/ActivityDrawer.vue @@ -28,7 +28,7 @@ const decisions = ref( : [] ); -const emit = defineEmits(["close"]); +const emit = defineEmits(["close", "saved"]); const close = () => emit("close"); const form = useForm({ @@ -123,6 +123,8 @@ const store = async () => { onSuccess: () => { close(); form.reset("due_date", "amount", "note"); + // Notify parent to react (e.g., refresh, redirect in phone mode when no contracts left) + emit("saved"); }, }); }; diff --git a/resources/js/Pages/Imports/Import.vue b/resources/js/Pages/Imports/Import.vue index 1081cf3..d7e089f 100644 --- a/resources/js/Pages/Imports/Import.vue +++ b/resources/js/Pages/Imports/Import.vue @@ -8,6 +8,7 @@ import SavedMappingsTable from "./Partials/SavedMappingsTable.vue"; import LogsTable from "./Partials/LogsTable.vue"; import ProcessResult from "./Partials/ProcessResult.vue"; import { ref, computed, onMounted, watch } from "vue"; +import { router } from "@inertiajs/vue3"; import Multiselect from "vue-multiselect"; import axios from "axios"; import Modal from "@/Components/Modal.vue"; // still potentially used elsewhere @@ -150,6 +151,40 @@ async function openMissingContracts() { } } +// Unresolved keyref rows (contracts not found) UI state +const showUnresolved = ref(false); +const unresolvedLoading = ref(false); +const unresolvedColumns = ref([]); +const unresolvedRows = ref([]); // [{id,row_number,values:[]}] +async function openUnresolved() { + if (!importId.value || !contractRefIsKeyref.value) return; + showUnresolved.value = true; + unresolvedLoading.value = true; + try { + const { data } = await axios.get( + route("imports.missing-keyref-rows", { import: importId.value }), + { headers: { Accept: "application/json" }, withCredentials: true } + ); + unresolvedColumns.value = Array.isArray(data?.columns) ? data.columns : []; + unresolvedRows.value = Array.isArray(data?.rows) ? data.rows : []; + } catch (e) { + console.error( + "Unresolved keyref rows fetch failed", + e.response?.status || "", + e.response?.data || e + ); + unresolvedColumns.value = []; + unresolvedRows.value = []; + } finally { + unresolvedLoading.value = false; + } +} +function downloadUnresolvedCsv() { + if (!importId.value) return; + // Direct download + window.location.href = route("imports.missing-keyref-csv", { import: importId.value }); +} + // Determine if all detected columns are mapped with entity+field function evaluateMappingSaved() { console.log("here the evaluation happen of mapping save!"); @@ -881,6 +916,9 @@ async function processImport() { } ); processResult.value = data; + // Immediately refresh the page props to reflect the completed state + // Reload only the 'import' prop to minimize payload; don't preserve state so UI reflects new status + router.reload({ only: ["import"], preserveScroll: true, preserveState: false }); } catch (e) { console.error( "Process import error", @@ -1153,6 +1191,14 @@ async function fetchSimulation() { > Ogled manjkajoče +
@@ -1331,6 +1377,67 @@ async function fetchSimulation() {
+ + + +
+
+

+ Vrstice z neobstoječim contract.reference (KEYREF) +

+
+ + +
+
+
+ Nalagam … +
+
+
+ Ni zadetkov. +
+
+ + + + + + + + + + + + + +
# vrstica + {{ c }} +
{{ r.row_number }} + {{ r.values?.[i] ?? "" }} +
+
+
+
+
{ drawerAddActivity.value = false; }; +// After an activity is saved, if there are no contracts left for this case (assigned to me), +// redirect back to the Phone index to pick the next case. +const onActivitySaved = () => { + // Inertia onSuccess already refreshed props, so props.contracts reflects current state. + const count = Array.isArray(props.contracts) ? props.contracts.length : 0; + if (count === 0) { + router.visit(route("phone.index")); + } +}; + +// Also react if the page is reloaded with zero contracts (e.g., after EndFieldJob moves the only contract): +const redirectIfNoContracts = () => { + const count = Array.isArray(props.contracts) ? props.contracts.length : 0; + if (count === 0) { + router.visit(route("phone.index")); + } +}; + +onMounted(() => { + redirectIfNoContracts(); +}); + +watch( + () => (Array.isArray(props.contracts) ? props.contracts.length : null), + (len) => { + if (len === 0) { + router.visit(route("phone.index")); + } + } +); + // Document upload state const docDialogOpen = ref(false); const docForm = useForm({ @@ -533,6 +564,7 @@ const clientSummary = computed(() => { import AppLayout from "@/Layouts/AppLayout.vue"; -import { Link } from "@inertiajs/vue3"; -import { ref } from "vue"; +import { Link, router } from "@inertiajs/vue3"; +import { ref, computed, watch } from "vue"; import DataTableServer from "@/Components/DataTable/DataTableServer.vue"; const props = defineProps({ segment: Object, contracts: Object, // LengthAwarePaginator payload from Laravel + clients: Array, // Full list of clients with contracts in this segment }); -// Initialize search from current URL so input reflects server filter -const search = ref(new URLSearchParams(window.location.search).get("search") || ""); +// Initialize search and client filter from current URL so inputs reflect server filters +const urlParams = new URLSearchParams(window.location.search); +const search = ref(urlParams.get("search") || ""); +const initialClient = urlParams.get("client") || urlParams.get("client_id") || ""; +const selectedClient = ref(initialClient); // Column definitions for the server-driven table const columns = [ @@ -23,6 +27,29 @@ const columns = [ { key: "account", label: "Stanje", align: "right" }, ]; +// Build client options from the full list provided by the server, so the dropdown isn't limited by current filters +const clientOptions = computed(() => { + const list = Array.isArray(props.clients) ? props.clients : []; + const opts = list.map((c) => ({ + value: c.uuid || "", + label: c.name || "(neznana stranka)", + })); + return opts.sort((a, b) => (a.label || "").localeCompare(b.label || "")); +}); + +// React to client selection changes by visiting the same route with updated query +watch(selectedClient, (val) => { + const query = { search: search.value }; + if (val) { + query.client = val; + } + router.get( + route("segments.show", { segment: props.segment?.id ?? props.segment }), + query, + { preserveState: true, preserveScroll: true, only: ["contracts"], replace: true } + ); +}); + function formatDate(value) { if (!value) { return "-"; @@ -54,6 +81,36 @@ function formatCurrency(value) {

{{ segment.name }}

{{ segment?.description }}
+ +
+
+ +
+ + +
+
+
+ // flowbite-vue table imports removed; using DataTableClient -import { EditIcon, TrashBinIcon } from "@/Utilities/Icons"; +import { EditIcon, TrashBinIcon, DottedMenu } from "@/Utilities/Icons"; import DialogModal from "@/Components/DialogModal.vue"; import ConfirmationModal from "@/Components/ConfirmationModal.vue"; -import { computed, onMounted, ref, watch } from "vue"; +import { computed, onMounted, ref, watch, nextTick } from "vue"; import { router, useForm } from "@inertiajs/vue3"; import InputLabel from "@/Components/InputLabel.vue"; import TextInput from "@/Components/TextInput.vue"; @@ -12,11 +12,15 @@ import PrimaryButton from "@/Components/PrimaryButton.vue"; import ActionMessage from "@/Components/ActionMessage.vue"; import DataTableClient from "@/Components/DataTable/DataTableClient.vue"; import InlineColorPicker from "@/Components/InlineColorPicker.vue"; +import Dropdown from "@/Components/Dropdown.vue"; const props = defineProps({ decisions: Array, actions: Array, emailTemplates: { type: Array, default: () => [] }, + availableEvents: { type: Array, default: () => [] }, + segments: { type: Array, default: () => [] }, + archiveSettings: { type: Array, default: () => [] }, }); const drawerEdit = ref(false); @@ -38,6 +42,7 @@ const columns = [ { key: "id", label: "#", sortable: true, class: "w-16" }, { key: "name", label: "Ime", sortable: true }, { key: "color_tag", label: "Barva", sortable: false }, + { key: "events", label: "Dogodki", sortable: false, class: "w-40" }, { key: "belongs", label: "Pripada akcijam", sortable: false, class: "w-40" }, { key: "auto_mail", label: "Auto mail", sortable: false, class: "w-46" }, ]; @@ -49,6 +54,7 @@ const form = useForm({ actions: [], auto_mail: false, email_template_id: null, + events: [], }); const createForm = useForm({ @@ -57,6 +63,7 @@ const createForm = useForm({ actions: [], auto_mail: false, email_template_id: null, + events: [], }); // When auto mail is disabled, also detach email template selection (edit form) @@ -86,6 +93,27 @@ const openEditDrawer = (item) => { form.color_tag = item.color_tag; form.auto_mail = !!item.auto_mail; form.email_template_id = item.email_template_id || null; + form.events = (item.events || []).map((ev) => { + let cfgObj = {}; + const pCfg = ev.pivot?.config; + if (typeof pCfg === "string" && pCfg.trim() !== "") { + try { + cfgObj = JSON.parse(pCfg); + } catch (e) { + cfgObj = {}; + } + } else if (typeof pCfg === "object" && pCfg !== null) { + cfgObj = pCfg; + } + return { + id: ev.id, + key: ev.key, + name: ev.name, + active: ev.pivot?.active ?? true, + run_order: ev.pivot?.run_order ?? null, + config: cfgObj, + }; + }); drawerEdit.value = true; item.actions.forEach((a) => { @@ -120,6 +148,69 @@ onMounted(() => { }); }); +function eventById(id) { + return (props.availableEvents || []).find((e) => Number(e.id) === Number(id)); +} + +function eventKey(ev) { + const id = ev?.id; + const found = eventById(id); + return (found?.key || ev?.key || "").toString(); +} + +function defaultConfigForKey(key) { + switch (key) { + case "add_segment": + return { segment_id: null, deactivate_previous: true }; + case "archive_contract": + return { archive_setting_id: null, reactivate: false }; + case "end_field_job": + return {}; + default: + return {}; + } +} + +function adoptKeyAndName(ev) { + const found = eventById(ev.id); + if (found) { + ev.key = found.key; + ev.name = found.name; + } +} + +function onEventChange(ev) { + adoptKeyAndName(ev); + const key = eventKey(ev); + // Reset config to sensible defaults for the new key + ev.config = defaultConfigForKey(key); + // Clear any raw JSON cache + if (ev.__rawJson !== undefined) delete ev.__rawJson; +} + +function defaultEventPayload() { + const first = (props.availableEvents || [])[0] || { id: null, key: null, name: null }; + return { + id: first.id || null, + key: first.key || null, + name: first.name || null, + active: true, + run_order: null, + config: defaultConfigForKey(first.key || ""), + }; +} + +function tryAdoptRaw(ev) { + try { + const obj = JSON.parse(ev.__rawJson || "{}"); + if (obj && typeof obj === "object") { + ev.config = obj; + } + } catch (e) { + // ignore parse error, leave raw as-is + } +} + const filtered = computed(() => { const term = search.value?.toLowerCase() ?? ""; const tplId = selectedTemplateId.value ? Number(selectedTemplateId.value) : null; @@ -135,21 +226,104 @@ const filtered = computed(() => { }); const update = () => { + const clientErrors = validateEventsClientSide(form.events || []); + if (Object.keys(clientErrors).length > 0) { + // attach errors to form for display + form.setErrors(clientErrors); + scrollToFirstEventError(clientErrors, "edit"); + return; + } + form.put(route("settings.decisions.update", { id: form.id }), { onSuccess: () => { closeEditDrawer(); }, + onError: (errors) => { + // preserve server errors for display + scrollToFirstEventError(form.errors, "edit"); + }, }); }; const store = () => { + const clientErrors = validateEventsClientSide(createForm.events || []); + if (Object.keys(clientErrors).length > 0) { + createForm.setErrors(clientErrors); + scrollToFirstEventError(clientErrors, "create"); + return; + } + createForm.post(route("settings.decisions.store"), { onSuccess: () => { closeCreateDrawer(); }, + onError: () => { + scrollToFirstEventError(createForm.errors, "create"); + }, }); }; +function validateEventsClientSide(events) { + const errors = {}; + (events || []).forEach((ev, idx) => { + const key = eventKey(ev); + if (key === "add_segment") { + if (!ev.config || !ev.config.segment_id) { + errors[`events.${idx}.config.segment_id`] = "Izberite segment."; + } + } else if (key === "archive_contract") { + if (!ev.config || !ev.config.archive_setting_id) { + errors[`events.${idx}.config.archive_setting_id`] = "Izberite nastavitve arhiva."; + } + } + }); + return errors; +} + +function scrollToFirstEventError(errorsBag, mode /* 'edit' | 'create' */) { + const keys = Object.keys(errorsBag || {}); + // find first event-related error key + const first = keys.find((k) => + /^events\.\d+\.config\.(segment_id|archive_setting_id)$/.test(k) + ); + if (!first) return; + const match = first.match(/^events\.(\d+)\.config\.(segment_id|archive_setting_id)$/); + if (!match) return; + const idx = Number(match[1]); + const field = match[2]; + let targetId = null; + if (field === "segment_id") { + targetId = mode === "create" ? `cseg-${idx}` : `seg-${idx}`; + } else if (field === "archive_setting_id") { + targetId = mode === "create" ? `cas-${idx}` : `as-${idx}`; + } + if (!targetId) return; + nextTick(() => { + const el = document.getElementById(targetId); + if (el && "scrollIntoView" in el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + // also focus the control for accessibility + if ("focus" in el) { + try { + el.focus(); + } catch (e) { + /* noop */ + } + } + } + }); +} + +const eventsValidEdit = computed(() => { + const errs = validateEventsClientSide(form.events || []); + return Object.keys(errs).length === 0; +}); + +const eventsValidCreate = computed(() => { + const errs = validateEventsClientSide(createForm.events || []); + return Object.keys(errs).length === 0; +}); + const confirmDelete = (decision) => { toDelete.value = decision; showDelete.value = true; @@ -218,6 +392,52 @@ const destroyDecision = () => { +