From 369af34ad49c83c59df4311b20e2dda29ed0b9ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Sun, 26 Oct 2025 12:57:09 +0100 Subject: [PATCH] Package system sms --- app/Enums/PersonPhoneType.php | 10 + .../Controllers/Admin/PackageController.php | 470 ++++++++++++++++++ app/Http/Controllers/ClientCaseContoller.php | 91 ++++ app/Http/Controllers/PersonController.php | 26 +- .../StorePackageFromContractsRequest.php | 36 ++ app/Http/Requests/StorePackageRequest.php | 35 ++ app/Jobs/PackageItemSmsJob.php | 217 ++++++++ app/Jobs/SendSmsJob.php | 47 +- app/Models/Package.php | 48 ++ app/Models/PackageItem.php | 31 ++ app/Models/Person/PersonPhone.php | 24 +- app/Services/Contact/PhoneSelector.php | 53 ++ app/Services/Sms/SmsService.php | 46 +- config/services.php | 8 + .../factories/Person/PersonPhoneFactory.php | 28 ++ ...025_10_25_000001_create_packages_table.php | 37 ++ ...0_25_000002_create_package_items_table.php | 35 ++ ...idated_and_phone_type_to_person_phones.php | 85 ++++ resources/js/Components/AddressCreateForm.vue | 248 ++++----- resources/js/Components/PersonInfoGrid.vue | 193 ++++++- resources/js/Components/PhoneCreateForm.vue | 274 +++++----- resources/js/Layouts/AdminLayout.vue | 7 + resources/js/Pages/Admin/Index.vue | 119 +++-- resources/js/Pages/Admin/Packages/Index.vue | 359 +++++++++++++ resources/js/Pages/Admin/Packages/Show.vue | 282 +++++++++++ routes/web.php | 14 + tests/Unit/PackageItemSmsJobTest.php | 55 ++ tests/Unit/PhoneSelectorTest.php | 53 ++ tests/Unit/SmsServiceFormatEuTest.php | 38 ++ 29 files changed, 2639 insertions(+), 330 deletions(-) create mode 100644 app/Enums/PersonPhoneType.php create mode 100644 app/Http/Controllers/Admin/PackageController.php create mode 100644 app/Http/Requests/StorePackageFromContractsRequest.php create mode 100644 app/Http/Requests/StorePackageRequest.php create mode 100644 app/Jobs/PackageItemSmsJob.php create mode 100644 app/Models/Package.php create mode 100644 app/Models/PackageItem.php create mode 100644 app/Services/Contact/PhoneSelector.php create mode 100644 database/migrations/2025_10_25_000001_create_packages_table.php create mode 100644 database/migrations/2025_10_25_000002_create_package_items_table.php create mode 100644 database/migrations/2025_10_25_110000_add_validated_and_phone_type_to_person_phones.php create mode 100644 resources/js/Pages/Admin/Packages/Index.vue create mode 100644 resources/js/Pages/Admin/Packages/Show.vue create mode 100644 tests/Unit/PackageItemSmsJobTest.php create mode 100644 tests/Unit/PhoneSelectorTest.php create mode 100644 tests/Unit/SmsServiceFormatEuTest.php diff --git a/app/Enums/PersonPhoneType.php b/app/Enums/PersonPhoneType.php new file mode 100644 index 0000000..ce9852b --- /dev/null +++ b/app/Enums/PersonPhoneType.php @@ -0,0 +1,10 @@ +latest('id') + ->paginate(20); + // Minimal lookups for create form (active only) + $profiles = \App\Models\SmsProfile::query() + ->where('active', true) + ->orderBy('name') + ->get(['id', 'name']); + $senders = \App\Models\SmsSender::query() + ->where('active', true) + ->orderBy('sname') + ->get(['id', 'profile_id', 'sname', 'phone_number']); + $templates = \App\Models\SmsTemplate::query() + ->orderBy('name') + ->get(['id', 'name']); + $segments = \App\Models\Segment::query() + ->where('active', true) + ->orderBy('name') + ->get(['id', 'name']); + // Provide a lightweight list of recent clients with person names for filtering + $clients = \App\Models\Client::query() + ->with(['person' => function ($q) { + $q->select('id', 'uuid', 'full_name'); + }]) + ->latest('id') + ->get(['id', 'uuid', 'person_id']) + ->map(function ($c) { + return [ + 'id' => $c->id, + 'uuid' => $c->uuid, + 'name' => $c->person?->full_name ?? ('Client #'.$c->id), + ]; + }) + ->values(); + + return Inertia::render('Admin/Packages/Index', [ + 'packages' => $packages, + 'profiles' => $profiles, + 'senders' => $senders, + 'templates' => $templates, + 'segments' => $segments, + 'clients' => $clients, + ]); + } + + public function show(Package $package, SmsService $sms): Response + { + $items = $package->items()->latest('id')->paginate(25); + + // Preload contracts/accounts for current page items to compute per-item previews + $contractIds = collect($items->items()) + ->map(fn ($it) => (array) ($it->target_json ?? [])) + ->map(fn ($t) => $t['contract_id'] ?? null) + ->filter() + ->unique() + ->values(); + $contracts = $contractIds->isNotEmpty() + ? Contract::query()->with('account.type')->whereIn('id', $contractIds)->get()->keyBy('id') + : collect(); + + // Attach rendered_preview to each item + $collection = collect($items->items()); + $collection = $collection->transform(function ($it) use ($sms, $contracts) { + $payload = (array) ($it->payload_json ?? []); + $tgt = (array) ($it->target_json ?? []); + $vars = (array) ($payload['variables'] ?? []); + if (! empty($tgt['contract_id']) && $contracts->has($tgt['contract_id'])) { + $c = $contracts->get($tgt['contract_id']); + $vars['contract'] = [ + 'id' => $c->id, + 'uuid' => $c->uuid, + 'reference' => $c->reference, + 'start_date' => (string) ($c->start_date ?? ''), + 'end_date' => (string) ($c->end_date ?? ''), + ]; + if ($c->account) { + $initialRaw = (string) $c->account->initial_amount; + $balanceRaw = (string) $c->account->balance_amount; + $vars['account'] = [ + 'id' => $c->account->id, + 'reference' => $c->account->reference, + // Use EU formatted values for SMS previews + 'initial_amount' => $sms->formatAmountEu($initialRaw), + 'balance_amount' => $sms->formatAmountEu($balanceRaw), + // Also expose raw values + 'initial_amount_raw' => $initialRaw, + 'balance_amount_raw' => $balanceRaw, + 'type' => $c->account->type?->name, + ]; + } + } + + // Prefer recorded message from result_json if available (sent items) + $result = (array) ($it->result_json ?? []); + $rendered = $result['message'] ?? null; + if (! $rendered) { + $body = isset($payload['body']) ? trim((string) $payload['body']) : ''; + if ($body !== '') { + $rendered = $body; + } elseif (! empty($payload['template_id'])) { + $tpl = \App\Models\SmsTemplate::find((int) $payload['template_id']); + if ($tpl) { + $rendered = $sms->renderContent($tpl->content, $vars); + } + } + } + $it->rendered_preview = $rendered; + + return $it; + }); + // Replace paginator collection + if (method_exists($items, 'setCollection')) { + $items->setCollection($collection); + } + + // Build a preview of message content from the first item (shared payload across package) + $preview = null; + $firstItem = $package->items()->oldest('id')->first(); + if ($firstItem) { + $payload = (array) ($firstItem->payload_json ?? []); + $body = isset($payload['body']) ? trim((string) $payload['body']) : ''; + // Enrich variables with contract/account for preview if available + $vars = (array) ($payload['variables'] ?? []); + $tgt = (array) ($firstItem->target_json ?? []); + if (! empty($tgt['contract_id'])) { + $c = Contract::query()->with('account.type')->find($tgt['contract_id']); + if ($c) { + $vars['contract'] = [ + 'id' => $c->id, + 'uuid' => $c->uuid, + 'reference' => $c->reference, + 'start_date' => (string) ($c->start_date ?? ''), + 'end_date' => (string) ($c->end_date ?? ''), + ]; + if ($c->account) { + $initialRaw = (string) $c->account->initial_amount; + $balanceRaw = (string) $c->account->balance_amount; + $vars['account'] = [ + 'id' => $c->account->id, + 'reference' => $c->account->reference, + 'initial_amount' => $sms->formatAmountEu($initialRaw), + 'balance_amount' => $sms->formatAmountEu($balanceRaw), + 'initial_amount_raw' => $initialRaw, + 'balance_amount_raw' => $balanceRaw, + 'type' => $c->account->type?->name, + ]; + } + } + } + if ($body !== '') { + $preview = [ + 'source' => 'body', + 'content' => $body, + ]; + } elseif (! empty($payload['template_id'])) { + /** @var SmsTemplate|null $tpl */ + $tpl = SmsTemplate::find((int) $payload['template_id']); + if ($tpl) { + $content = $sms->renderContent($tpl->content, $vars); + $preview = [ + 'source' => 'template', + 'template' => [ + 'id' => $tpl->id, + 'name' => $tpl->name, + ], + 'content' => $content, + ]; + } + } + } + + return Inertia::render('Admin/Packages/Show', [ + 'package' => $package, + 'items' => $items, + 'preview' => $preview, + ]); + } + + public function store(StorePackageRequest $request): RedirectResponse + { + $data = $request->validated(); + + $package = Package::query()->create([ + 'uuid' => (string) Str::uuid(), + 'type' => $data['type'], + 'status' => Package::STATUS_DRAFT, + 'name' => $data['name'] ?? null, + 'description' => $data['description'] ?? null, + 'meta' => $data['meta'] ?? [], + 'created_by' => optional($request->user())->id, + ]); + + $items = collect($data['items']) + ->map(function (array $row) { + return new PackageItem([ + 'status' => 'queued', + 'target_json' => [ + 'number' => (string) $row['number'], + 'phone_id' => $row['phone_id'] ?? null, + ], + 'payload_json' => $row['payload'] ?? [], + ]); + }); + + $package->items()->saveMany($items); + $package->total_items = $items->count(); + $package->save(); + + return back()->with('success', 'Package created'); + } + + public function dispatch(Package $package): RedirectResponse + { + if (! in_array($package->status, [Package::STATUS_DRAFT, Package::STATUS_FAILED], true)) { + return back()->with('error', 'Package not in a dispatchable state.'); + } + + $jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) { + return new PackageItemSmsJob($item->id); + })->all(); + + if (empty($jobs)) { + return back()->with('error', 'No items to dispatch.'); + } + + $package->status = Package::STATUS_QUEUED; + $package->save(); + + Bus::batch($jobs) + ->name('pkg:'.$package->id.' ('.$package->type.')') + ->then(function () use ($package) { + // If finished counters not set by items (e.g., empty), finalize + $package->refresh(); + if (($package->sent_count + $package->failed_count) >= $package->total_items) { + $finalStatus = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED; + $package->status = $finalStatus; + $package->finished_at = now(); + $package->save(); + } else { + $package->status = Package::STATUS_RUNNING; + $package->save(); + } + }) + ->onQueue('sms') + ->dispatch(); + + return back()->with('success', 'Package dispatched'); + } + + public function cancel(Package $package): RedirectResponse + { + $package->status = Package::STATUS_CANCELED; + $package->save(); + + return back()->with('success', 'Package canceled'); + } + + /** + * List contracts for a given segment and include selected phone per person. + */ + public function contracts(Request $request, PhoneSelector $selector): \Illuminate\Http\JsonResponse + { + $request->validate([ + 'segment_id' => ['required', 'integer', 'exists:segments,id'], + 'q' => ['nullable', 'string'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + 'client_id' => ['nullable', 'integer', 'exists:clients,id'], + 'only_mobile' => ['nullable', 'boolean'], + 'only_validated' => ['nullable', 'boolean'], + ]); + + $segmentId = (int) $request->input('segment_id'); + $perPage = (int) ($request->input('per_page') ?? 25); + + $query = Contract::query() + ->join('contract_segment', function ($j) use ($segmentId) { + $j->on('contract_segment.contract_id', '=', 'contracts.id') + ->where('contract_segment.segment_id', '=', $segmentId) + ->where('contract_segment.active', true); + }) + ->with([ + 'clientCase.person.phones', + 'clientCase.client.person', + ]) + ->select('contracts.*') + ->latest('contracts.id'); + + if ($q = trim((string) $request->input('q'))) { + $query->where(function ($w) use ($q) { + $w->where('contracts.reference', 'ILIKE', "%{$q}%"); + }); + } + + if ($clientId = $request->integer('client_id')) { + $query->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id') + ->where('client_cases.client_id', $clientId); + } + + // Optional phone filters + if ($request->boolean('only_mobile') || $request->boolean('only_validated')) { + $query->whereHas('clientCase.person.phones', function ($q) use ($request) { + if ($request->boolean('only_mobile')) { + $q->where('person_phones.phone_type', 'mobile'); + } + if ($request->boolean('only_validated')) { + $q->where('person_phones.validated', true); + } + }); + } + + $contracts = $query->paginate($perPage); + + $data = collect($contracts->items())->map(function (Contract $contract) use ($selector) { + $person = $contract->clientCase?->person; + $selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person']; + $phone = $selected['phone']; + $clientPerson = $contract->clientCase?->client?->person; + + return [ + 'id' => $contract->id, + 'uuid' => $contract->uuid, + 'reference' => $contract->reference, + 'case' => [ + 'id' => $contract->clientCase?->id, + 'uuid' => $contract->clientCase?->uuid, + ], + // Primer: the case person + 'person' => [ + 'id' => $person?->id, + 'uuid' => $person?->uuid, + 'full_name' => $person?->full_name, + ], + // Stranka: the client person + 'client' => $clientPerson ? [ + 'id' => $contract->clientCase?->client?->id, + 'uuid' => $contract->clientCase?->client?->uuid, + 'name' => $clientPerson->full_name, + ] : null, + 'selected_phone' => $phone ? [ + 'id' => $phone->id, + 'number' => $phone->nu, + 'validated' => $phone->validated, + 'type' => $phone->phone_type?->value, + ] : null, + 'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'), + ]; + }); + + return response()->json([ + 'data' => $data, + 'meta' => [ + 'current_page' => $contracts->currentPage(), + 'last_page' => $contracts->lastPage(), + 'per_page' => $contracts->perPage(), + 'total' => $contracts->total(), + ], + ]); + } + + /** + * Create an SMS package from a list of contracts by selecting recipient phones. + */ + public function storeFromContracts(StorePackageFromContractsRequest $request, PhoneSelector $selector): RedirectResponse + { + $data = $request->validated(); + + // Load contracts with people, phones and account (for template placeholders) + $contracts = Contract::query() + ->with(['clientCase.person.phones', 'account.type']) + ->whereIn('id', $data['contract_ids']) + ->get(); + + $items = []; + $seen = collect(); // de-dup by phone_id or number + $skipped = 0; + foreach ($contracts as $contract) { + $person = $contract->clientCase?->person; + if (! $person) { + $skipped++; + + continue; + } + $selected = $selector->selectForPerson($person); + /** @var ?\App\Models\Person\PersonPhone $phone */ + $phone = $selected['phone']; + if (! $phone) { + $skipped++; + + continue; + } + $key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu; + if ($seen->contains($key)) { + // skip duplicates across multiple contracts/persons + $skipped++; + + continue; + } + $seen->push($key); + $items[] = [ + 'number' => (string) $phone->nu, + 'phone_id' => $phone->id, + 'payload' => $data['payload'] ?? [], + // Keep context for variable rendering during send + 'contract_id' => $contract->id, + 'account_id' => $contract->account?->id, + ]; + } + + if (empty($items)) { + return back()->with('error', 'No recipients found for selected contracts.'); + } + + $package = Package::query()->create([ + 'uuid' => (string) Str::uuid(), + 'type' => $data['type'], + 'status' => Package::STATUS_DRAFT, + 'name' => $data['name'] ?? null, + 'description' => $data['description'] ?? null, + 'meta' => array_merge($data['meta'] ?? [], [ + 'source' => 'contracts', + 'skipped' => $skipped, + ]), + 'created_by' => optional($request->user())->id, + ]); + + $packageItems = collect($items)->map(function (array $row) { + return new PackageItem([ + 'status' => 'queued', + 'target_json' => [ + 'number' => $row['number'], + 'phone_id' => $row['phone_id'], + 'contract_id' => $row['contract_id'] ?? null, + 'account_id' => $row['account_id'] ?? null, + ], + 'payload_json' => $row['payload'] ?? [], + ]); + }); + + $package->items()->saveMany($packageItems); + $package->total_items = $packageItems->count(); + $package->save(); + + return back()->with('success', 'Package created from contracts'); + } +} diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index 039a1c1..efec603 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -7,6 +7,7 @@ use App\Models\ClientCase; use App\Models\Contract; use App\Models\Document; +use App\Services\Sms\SmsService; use Exception; use Illuminate\Database\QueryException; use Illuminate\Http\Request; @@ -1693,6 +1694,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph 'template_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_templates,id'], 'profile_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_profiles,id'], 'sender_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_senders,id'], + 'contract_uuid' => ['sometimes', 'nullable', 'uuid'], ]); // Ensure the phone belongs to the person of this case @@ -1801,4 +1803,93 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph return back()->with('error', 'SMS ni bil dodan v čakalno vrsto.'); } } + + /** + * Return contracts for the given client case (for SMS dialog dropdown). + */ + public function listContracts(ClientCase $clientCase) + { + $contracts = $clientCase->contracts() + ->with('account.type') + ->select('id', 'uuid', 'reference', 'active', 'start_date', 'end_date') + ->latest('id') + ->get() + ->map(function ($c) { + /** @var SmsService $sms */ + $sms = app(SmsService::class); + $acc = $c->account; + $initialRaw = $acc?->initial_amount !== null ? (string) $acc->initial_amount : null; + $balanceRaw = $acc?->balance_amount !== null ? (string) $acc->balance_amount : null; + + return [ + 'uuid' => $c->uuid, + 'reference' => $c->reference, + 'active' => (bool) $c->active, + 'start_date' => (string) ($c->start_date ?? ''), + 'end_date' => (string) ($c->end_date ?? ''), + 'account' => $acc ? [ + 'reference' => $acc->reference, + 'type' => $acc->type?->name, + 'initial_amount' => $initialRaw !== null ? $sms->formatAmountEu($initialRaw) : null, + 'balance_amount' => $balanceRaw !== null ? $sms->formatAmountEu($balanceRaw) : null, + 'initial_amount_raw' => $initialRaw, + 'balance_amount_raw' => $balanceRaw, + ] : null, + ]; + }); + + return response()->json(['data' => $contracts]); + } + + /** + * Render an SMS template preview with optional contract/account placeholders filled. + */ + public function previewSms(ClientCase $clientCase, Request $request, SmsService $sms) + { + $validated = $request->validate([ + 'template_id' => ['required', 'integer', 'exists:sms_templates,id'], + 'contract_uuid' => ['sometimes', 'nullable', 'uuid'], + ]); + + /** @var \App\Models\SmsTemplate $template */ + $template = \App\Models\SmsTemplate::findOrFail((int) $validated['template_id']); + + $vars = []; + $contractUuid = $validated['contract_uuid'] ?? null; + if ($contractUuid) { + // Ensure the contract belongs to this client case + $contract = $clientCase->contracts()->where('uuid', $contractUuid) + ->with('account.type') + ->first(); + if ($contract) { + $vars['contract'] = [ + 'id' => $contract->id, + 'uuid' => $contract->uuid, + 'reference' => $contract->reference, + 'start_date' => (string) ($contract->start_date ?? ''), + 'end_date' => (string) ($contract->end_date ?? ''), + ]; + if ($contract->account) { + $initialRaw = (string) $contract->account->initial_amount; + $balanceRaw = (string) $contract->account->balance_amount; + $vars['account'] = [ + 'id' => $contract->account->id, + 'reference' => $contract->account->reference, + 'initial_amount' => $sms->formatAmountEu($initialRaw), + 'balance_amount' => $sms->formatAmountEu($balanceRaw), + 'initial_amount_raw' => $initialRaw, + 'balance_amount_raw' => $balanceRaw, + 'type' => $contract->account->type?->name, + ]; + } + } + } + + $content = $sms->renderContent($template->content, $vars); + + return response()->json([ + 'content' => $content, + 'variables' => $vars, + ]); + } } diff --git a/app/Http/Controllers/PersonController.php b/app/Http/Controllers/PersonController.php index 59be3ca..05d1e08 100644 --- a/app/Http/Controllers/PersonController.php +++ b/app/Http/Controllers/PersonController.php @@ -112,6 +112,8 @@ public function createPhone(Person $person, Request $request) 'country_code' => 'nullable|integer', 'type_id' => 'required|integer|exists:phone_types,id', 'description' => 'nullable|string|max:125', + 'validated' => 'sometimes|boolean', + 'phone_type' => 'nullable|in:mobile,landline,voip', ]); // Dedup: avoid duplicate phone per person by (nu, country_code) @@ -120,13 +122,7 @@ public function createPhone(Person $person, Request $request) 'country_code' => $attributes['country_code'] ?? null, ], $attributes); - if ($request->header('X-Inertia')) { - return back()->with('success', 'Phone added successfully'); - } - - return response()->json([ - 'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id), - ]); + return back()->with('success', 'Phone added successfully'); } public function updatePhone(Person $person, int $phone_id, Request $request) @@ -136,19 +132,15 @@ public function updatePhone(Person $person, int $phone_id, Request $request) 'country_code' => 'nullable|integer', 'type_id' => 'required|integer|exists:phone_types,id', 'description' => 'nullable|string|max:125', + 'validated' => 'sometimes|boolean', + 'phone_type' => 'nullable|in:mobile,landline,voip', ]); $phone = $person->phones()->with(['type'])->findOrFail($phone_id); $phone->update($attributes); - if ($request->header('X-Inertia')) { - return back()->with('success', 'Phone updated successfully'); - } - - return response()->json([ - 'phone' => $phone, - ]); + return back()->with('success', 'Phone updated successfully'); } public function deletePhone(Person $person, int $phone_id, Request $request) @@ -156,11 +148,7 @@ public function deletePhone(Person $person, int $phone_id, Request $request) $phone = $person->phones()->findOrFail($phone_id); $phone->delete(); // soft delete - if ($request->header('X-Inertia')) { - return back()->with('success', 'Phone deleted'); - } - - return response()->json(['status' => 'ok']); + return back()->with('success', 'Phone deleted'); } public function createEmail(Person $person, Request $request) diff --git a/app/Http/Requests/StorePackageFromContractsRequest.php b/app/Http/Requests/StorePackageFromContractsRequest.php new file mode 100644 index 0000000..6e8d79c --- /dev/null +++ b/app/Http/Requests/StorePackageFromContractsRequest.php @@ -0,0 +1,36 @@ +user()?->can('manage-settings') ?? false; + } + + public function rules(): array + { + return [ + 'type' => ['required', 'in:sms'], + 'name' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'meta' => ['nullable', 'array'], + + // Common payload for all items + 'payload' => ['required', 'array'], + 'payload.profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'], + 'payload.sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'], + 'payload.template_id' => ['nullable', 'integer', 'exists:sms_templates,id'], + 'payload.delivery_report' => ['nullable', 'boolean'], + 'payload.variables' => ['nullable', 'array'], + 'payload.body' => ['nullable', 'string'], + + // Source contracts to derive items from + 'contract_ids' => ['required', 'array', 'min:1'], + 'contract_ids.*' => ['integer', 'exists:contracts,id'], + ]; + } +} diff --git a/app/Http/Requests/StorePackageRequest.php b/app/Http/Requests/StorePackageRequest.php new file mode 100644 index 0000000..b9419fa --- /dev/null +++ b/app/Http/Requests/StorePackageRequest.php @@ -0,0 +1,35 @@ +user()?->can('manage-settings') ?? false; + } + + public function rules(): array + { + return [ + 'type' => ['required', 'in:sms'], + 'name' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'meta' => ['nullable', 'array'], + + // items + 'items' => ['required', 'array', 'min:1'], + 'items.*.number' => ['required', 'string'], + 'items.*.phone_id' => ['nullable', 'integer'], + 'items.*.payload' => ['nullable', 'array'], + 'items.*.payload.profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'], + 'items.*.payload.sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'], + 'items.*.payload.template_id' => ['nullable', 'integer', 'exists:sms_templates,id'], + 'items.*.payload.delivery_report' => ['nullable', 'boolean'], + 'items.*.payload.variables' => ['nullable', 'array'], + 'items.*.payload.body' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Jobs/PackageItemSmsJob.php b/app/Jobs/PackageItemSmsJob.php new file mode 100644 index 0000000..b0bf22f --- /dev/null +++ b/app/Jobs/PackageItemSmsJob.php @@ -0,0 +1,217 @@ +onQueue('sms'); + } + + public function handle(SmsService $sms): void + { + /** @var PackageItem|null $item */ + $item = PackageItem::query()->find($this->packageItemId); + if (! $item) { + return; + } + + /** @var Package $package */ + $package = $item->package; + if (! $package || $package->status === Package::STATUS_CANCELED) { + return; // canceled or missing + } + + // Skip if already finalized to avoid double counting on retries + if (in_array($item->status, ['sent', 'failed', 'canceled', 'skipped'], true)) { + return; + } + // Mark processing on first entry + if ($item->status === 'queued') { + $item->status = 'processing'; + $item->save(); + $package->increment('processing_count'); + } + + $payload = (array) $item->payload_json; + $target = (array) $item->target_json; + + $profileId = $payload['profile_id'] ?? null; + $senderId = $payload['sender_id'] ?? null; + $templateId = $payload['template_id'] ?? null; + $deliveryReport = (bool) ($payload['delivery_report'] ?? false); + $variables = (array) ($payload['variables'] ?? []); + // Enrich variables with contract/account context when available (contracts-based packages) + if (! empty($target['contract_id'])) { + $contract = Contract::query()->with('account.type')->find($target['contract_id']); + if ($contract) { + $variables['contract'] = [ + 'id' => $contract->id, + 'uuid' => $contract->uuid, + 'reference' => $contract->reference, + 'start_date' => (string) ($contract->start_date ?? ''), + 'end_date' => (string) ($contract->end_date ?? ''), + ]; + if ($contract->account) { + // Preserve raw values and provide EU-formatted versions for SMS rendering + $initialRaw = (string) $contract->account->initial_amount; + $balanceRaw = (string) $contract->account->balance_amount; + $variables['account'] = [ + 'id' => $contract->account->id, + 'reference' => $contract->account->reference, + // Override placeholders with EU formatted values for SMS + 'initial_amount' => $sms->formatAmountEu($initialRaw), + 'balance_amount' => $sms->formatAmountEu($balanceRaw), + // Expose raw values too in case templates need them explicitly + 'initial_amount_raw' => $initialRaw, + 'balance_amount_raw' => $balanceRaw, + 'type' => $contract->account->type?->name, + ]; + } + } + } + $bodyOverride = isset($payload['body']) ? trim((string) $payload['body']) : null; + if ($bodyOverride === '') { + $bodyOverride = null; + } + + /** @var SmsProfile|null $profile */ + $profile = $profileId ? SmsProfile::find($profileId) : null; + /** @var SmsSender|null $sender */ + $sender = $senderId ? SmsSender::find($senderId) : null; + /** @var SmsTemplate|null $template */ + $template = $templateId ? SmsTemplate::find($templateId) : null; + + $to = $target['number'] ?? null; + if (! is_string($to) || $to === '') { + $item->status = 'failed'; + $item->last_error = 'Missing recipient number.'; + $item->save(); + + return; + } + + // Compute throttle key + $scope = config('services.sms.throttle.scope', 'global'); + $provider = config('services.sms.throttle.provider_key', 'smsapi_si'); + $allow = (int) config('services.sms.throttle.allow', 30); + $every = (int) config('services.sms.throttle.every', 60); + $jitter = (int) config('services.sms.throttle.jitter_seconds', 2); + $key = $scope === 'per_profile' && $profile ? "sms:{$provider}:{$profile->id}" : "sms:{$provider}"; + + // Throttle + $sendClosure = function () use ($sms, $item, $package, $profile, $sender, $template, $to, $variables, $deliveryReport, $bodyOverride) { + // Idempotency key (optional external use) + if (empty($item->idempotency_key)) { + $hash = sha1(implode('|', [ + 'sms', (string) ($profile?->id ?? ''), (string) ($sender?->id ?? ''), (string) ($template?->id ?? ''), $to, (string) ($bodyOverride ?? ''), json_encode($variables), + ])); + $item->idempotency_key = "pkgitem:{$item->id}:{$hash}"; + $item->save(); + } + + // Decide whether to use template or raw content + $useTemplate = false; + if ($template) { + if ($bodyOverride) { + // If custom body is provided but template does not allow it, force template + $useTemplate = ! (bool) ($template->allow_custom_body ?? false); + } else { + // No custom body provided -> use template + $useTemplate = true; + } + } + + if ($useTemplate) { + $log = $sms->sendFromTemplate( + template: $template, + to: $to, + variables: $variables, + profile: $profile, + sender: $sender, + countryCode: null, + deliveryReport: $deliveryReport, + clientReference: "pkg:{$item->package_id}:item:{$item->id}", + ); + } else { + // Either explicit body override or no template + $effectiveBody = (string) ($bodyOverride ?? ''); + if ($effectiveBody === '') { + // Avoid provider error for empty body + throw new \RuntimeException('Empty SMS body and no template provided.'); + } + if (! $profile && $template) { + $profile = $template->defaultProfile; + } + if (! $profile) { + throw new \RuntimeException('Missing SMS profile for raw send.'); + } + $log = $sms->sendRaw( + profile: $profile, + to: $to, + content: $effectiveBody, + sender: $sender, + countryCode: null, + deliveryReport: $deliveryReport, + clientReference: "pkg:{$item->package_id}:item:{$item->id}", + ); + } + + $newStatus = $log->status === 'sent' ? 'sent' : 'failed'; + $item->status = $newStatus; + $item->provider_message_id = $log->provider_message_id; + $item->cost = $log->cost; + $item->currency = $log->currency; + // Persist useful result info including final rendered message for auditing + $result = $log->meta ?? []; + $result['message'] = $log->message ?? (($useTemplate && isset($template)) ? $sms->renderContent($template->content, $variables) : ($bodyOverride ?? null)); + $result['template_id'] = $template?->id; + $result['render_source'] = $useTemplate ? 'template' : 'body'; + $item->result_json = $result; + $item->last_error = $log->status === 'sent' ? null : ($log->meta['error_message'] ?? 'Failed'); + $item->save(); + + // Update package counters atomically + if ($newStatus === 'sent') { + $package->increment('sent_count'); + } else { + $package->increment('failed_count'); + } + + // If all items processed, finalize package + $package->refresh(); + if (($package->sent_count + $package->failed_count) >= $package->total_items) { + $finalStatus = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED; + $package->status = $finalStatus; + $package->finished_at = now(); + $package->save(); + } + }; + + try { + Redis::throttle($key)->allow($allow)->every($every)->then($sendClosure, function () use ($jitter) { + return $this->release(max(1, rand(1, $jitter))); + }); + } catch (\Throwable $e) { + // Fallback to direct send when Redis unavailable (e.g., test environment) + $sendClosure(); + } + } +} diff --git a/app/Jobs/SendSmsJob.php b/app/Jobs/SendSmsJob.php index 9bc20ce..8d416af 100644 --- a/app/Jobs/SendSmsJob.php +++ b/app/Jobs/SendSmsJob.php @@ -12,6 +12,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Redis; class SendSmsJob implements ShouldQueue { @@ -49,16 +50,42 @@ public function handle(SmsService $sms): void /** @var SmsSender|null $sender */ $sender = $this->senderId ? SmsSender::find($this->senderId) : null; - // Send and get log (handles queued->sent/failed transitions internally) - $log = $sms->sendRaw( - profile: $profile, - to: $this->to, - content: $this->content, - sender: $sender, - countryCode: $this->countryCode, - deliveryReport: $this->deliveryReport, - clientReference: $this->clientReference, - ); + // Apply Redis throttle from config to avoid provider rate limits + $scope = config('services.sms.throttle.scope', 'global'); + $provider = config('services.sms.throttle.provider_key', 'smsapi_si'); + $allow = (int) config('services.sms.throttle.allow', 30); + $every = (int) config('services.sms.throttle.every', 60); + $jitter = (int) config('services.sms.throttle.jitter_seconds', 2); + $key = $scope === 'per_profile' && $profile ? "sms:{$provider}:{$profile->id}" : "sms:{$provider}"; + + $log = null; + try { + Redis::throttle($key)->allow($allow)->every($every)->then(function () use (&$log, $sms, $profile, $sender) { + // Send and get log (handles queued->sent/failed transitions internally) + $log = $sms->sendRaw( + profile: $profile, + to: $this->to, + content: $this->content, + sender: $sender, + countryCode: $this->countryCode, + deliveryReport: $this->deliveryReport, + clientReference: $this->clientReference, + ); + }, function () use ($jitter) { + return $this->release(max(1, rand(1, $jitter))); + }); + } catch (\Throwable $e) { + // Fallback if Redis is unavailable in test or local env + $log = $sms->sendRaw( + profile: $profile, + to: $this->to, + content: $this->content, + sender: $sender, + countryCode: $this->countryCode, + deliveryReport: $this->deliveryReport, + clientReference: $this->clientReference, + ); + } // If invoked from the case UI with a selected template, create an Activity if ($this->templateId && $this->clientCaseId && $log) { try { diff --git a/app/Models/Package.php b/app/Models/Package.php new file mode 100644 index 0000000..d5be77a --- /dev/null +++ b/app/Models/Package.php @@ -0,0 +1,48 @@ + 'array', + 'finished_at' => 'datetime', + 'total_items' => 'integer', + 'processing_count' => 'integer', + 'sent_count' => 'integer', + 'failed_count' => 'integer', + ]; + } + + public function items() + { + return $this->hasMany(PackageItem::class); + } + + public const TYPE_SMS = 'sms'; + + public const STATUS_DRAFT = 'draft'; + + public const STATUS_QUEUED = 'queued'; + + public const STATUS_RUNNING = 'running'; + + public const STATUS_COMPLETED = 'completed'; + + public const STATUS_FAILED = 'failed'; + + public const STATUS_CANCELED = 'canceled'; +} diff --git a/app/Models/PackageItem.php b/app/Models/PackageItem.php new file mode 100644 index 0000000..5c6df4e --- /dev/null +++ b/app/Models/PackageItem.php @@ -0,0 +1,31 @@ + 'array', + 'payload_json' => 'array', + 'result_json' => 'array', + 'attempts' => 'integer', + 'cost' => 'decimal:4', + ]; + } + + public function package() + { + return $this->belongsTo(Package::class); + } +} diff --git a/app/Models/Person/PersonPhone.php b/app/Models/Person/PersonPhone.php index 0730308..04017b0 100644 --- a/app/Models/Person/PersonPhone.php +++ b/app/Models/Person/PersonPhone.php @@ -2,7 +2,7 @@ namespace App\Models\Person; -use Blade; +use App\Enums\PersonPhoneType; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -13,6 +13,7 @@ class PersonPhone extends Model { /** @use HasFactory<\Database\Factories\Person/PersonPhoneFactory> */ use HasFactory; + use Searchable; use SoftDeletes; @@ -22,25 +23,28 @@ class PersonPhone extends Model 'type_id', 'description', 'person_id', - 'user_id' + 'user_id', + 'validated', + 'phone_type', ]; protected $hidden = [ 'user_id', 'person_id', - 'deleted' + 'deleted', ]; public function toSearchableArray(): array { return [ - 'nu' => $this->nu + 'nu' => $this->nu, ]; } - protected static function booted(){ + protected static function booted() + { static::creating(function (PersonPhone $personPhone) { - if(!isset($personPhone->user_id)){ + if (! isset($personPhone->user_id)) { $personPhone->user_id = auth()->id(); } }); @@ -55,4 +59,12 @@ public function type(): BelongsTo { return $this->belongsTo(\App\Models\Person\PhoneType::class, 'type_id'); } + + protected function casts(): array + { + return [ + 'validated' => 'boolean', + 'phone_type' => PersonPhoneType::class, + ]; + } } diff --git a/app/Services/Contact/PhoneSelector.php b/app/Services/Contact/PhoneSelector.php new file mode 100644 index 0000000..6e6210f --- /dev/null +++ b/app/Services/Contact/PhoneSelector.php @@ -0,0 +1,53 @@ + ?PersonPhone, 'reason' => ?string] + */ + public function selectForPerson(Person $person): array + { + // Load active phones only (Person relation already filters active=1) + $phones = $person->phones; + + if ($phones->isEmpty()) { + return ['phone' => null, 'reason' => 'no_active_phones']; + } + + // 1) validated mobile + $phone = $phones->first(function (PersonPhone $p) { + return ($p->validated === true) && ($p->phone_type === PersonPhoneType::Mobile); + }); + if ($phone) { + return ['phone' => $phone, 'reason' => null]; + } + + // 2) validated (any type) + $phone = $phones->first(fn (PersonPhone $p) => $p->validated === true); + if ($phone) { + return ['phone' => $phone, 'reason' => null]; + } + + // 3) mobile (any validation) + $phone = $phones->first(fn (PersonPhone $p) => $p->phone_type === PersonPhoneType::Mobile); + if ($phone) { + return ['phone' => $phone, 'reason' => null]; + } + + // 4) first active + return ['phone' => $phones->first(), 'reason' => null]; + } +} diff --git a/app/Services/Sms/SmsService.php b/app/Services/Sms/SmsService.php index da6ed5d..eaf7d04 100644 --- a/app/Services/Sms/SmsService.php +++ b/app/Services/Sms/SmsService.php @@ -79,16 +79,52 @@ public function sendFromTemplate(SmsTemplate $template, string $to, array $varia return $log; } - protected function renderContent(string $content, array $vars): string + public function renderContent(string $content, array $vars): string { - // Simple token replacement: {token} - return preg_replace_callback('/\{([a-zA-Z0-9_\.]+)\}/', function ($m) use ($vars) { - $key = $m[1]; + // Support {token} and {nested.keys} using dot-notation lookup + $resolver = function (array $arr, string $path) { + if (array_key_exists($path, $arr)) { + return $arr[$path]; + } + $segments = explode('.', $path); + $cur = $arr; + foreach ($segments as $seg) { + if (is_array($cur) && array_key_exists($seg, $cur)) { + $cur = $cur[$seg]; + } else { + return null; + } + } - return array_key_exists($key, $vars) ? (string) $vars[$key] : $m[0]; + return $cur; + }; + + return preg_replace_callback('/\{([a-zA-Z0-9_\.]+)\}/', function ($m) use ($vars, $resolver) { + $key = $m[1]; + $val = $resolver($vars, $key); + + return $val !== null ? (string) $val : $m[0]; }, $content); } + /** + * Format a number to EU style: thousands separated by '.', decimals by ','. + */ + public function formatAmountEu(mixed $value, int $decimals = 2): string + { + if ($value === null || $value === '') { + return number_format(0, $decimals, ',', '.'); + } + $str = (string) $value; + // Normalize possible EU-style input like "1.234,56" to standard for float casting + if (str_contains($str, ',')) { + $str = str_replace(['.', ','], ['', '.'], $str); + } + $num = (float) $str; + + return number_format($num, $decimals, ',', '.'); + } + /** * Get current credit balance from provider. */ diff --git a/config/services.php b/config/services.php index 81b8427..0c8bd80 100644 --- a/config/services.php +++ b/config/services.php @@ -50,6 +50,14 @@ 'timeout' => (int) env('SMSAPI_SI_TIMEOUT', 10), ], ], + // Throttling defaults for queued SMS jobs (per provider or per profile) + 'throttle' => [ + 'scope' => env('SMS_THROTTLE_SCOPE', 'global'), // global|per_profile + 'allow' => (int) env('SMS_THROTTLE_ALLOW', 30), // requests allowed + 'every' => (int) env('SMS_THROTTLE_EVERY', 60), // per seconds + 'jitter_seconds' => (int) env('SMS_THROTTLE_JITTER', 2), + 'provider_key' => env('SMS_THROTTLE_PROVIDER_KEY', 'smsapi_si'), + ], ], ]; diff --git a/database/factories/Person/PersonPhoneFactory.php b/database/factories/Person/PersonPhoneFactory.php index b3b5062..3ea3345 100644 --- a/database/factories/Person/PersonPhoneFactory.php +++ b/database/factories/Person/PersonPhoneFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories\Person; +use App\Enums\PersonPhoneType; use App\Models\Person\PhoneType; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -22,6 +23,33 @@ public function definition(): array 'nu' => $this->faker->numerify('06########'), 'type_id' => PhoneType::factory(), 'user_id' => User::factory(), + 'validated' => $this->faker->boolean(80), + 'phone_type' => PersonPhoneType::Mobile->value, ]; } + + public function mobile(): self + { + return $this->state(fn () => ['phone_type' => PersonPhoneType::Mobile->value]); + } + + public function landline(): self + { + return $this->state(fn () => ['phone_type' => PersonPhoneType::Landline->value]); + } + + public function voip(): self + { + return $this->state(fn () => ['phone_type' => PersonPhoneType::Voip->value]); + } + + public function validated(): self + { + return $this->state(fn () => ['validated' => true]); + } + + public function notValidated(): self + { + return $this->state(fn () => ['validated' => false]); + } } diff --git a/database/migrations/2025_10_25_000001_create_packages_table.php b/database/migrations/2025_10_25_000001_create_packages_table.php new file mode 100644 index 0000000..a313e9c --- /dev/null +++ b/database/migrations/2025_10_25_000001_create_packages_table.php @@ -0,0 +1,37 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('type'); // sms, email, archive, segment + $table->string('status')->default('draft'); // draft, queued, running, completed, failed, canceled + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->json('meta')->nullable(); + $table->unsignedInteger('total_items')->default(0); + $table->unsignedInteger('processing_count')->default(0); + $table->unsignedInteger('sent_count')->default(0); + $table->unsignedInteger('failed_count')->default(0); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + + $table->index(['type', 'status']); + $table->index(['created_by']); + $table->index(['finished_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('packages'); + } +}; diff --git a/database/migrations/2025_10_25_000002_create_package_items_table.php b/database/migrations/2025_10_25_000002_create_package_items_table.php new file mode 100644 index 0000000..1ae3ab8 --- /dev/null +++ b/database/migrations/2025_10_25_000002_create_package_items_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('package_id')->constrained('packages')->cascadeOnDelete(); + $table->string('status')->default('queued'); // queued, processing, sent, failed, canceled, skipped + $table->json('target_json'); // e.g., {"phone_id": 1, "number": "+386..."} + $table->json('payload_json')->nullable(); // per-item overrides and variables + $table->unsignedTinyInteger('attempts')->default(0); + $table->text('last_error')->nullable(); + $table->json('result_json')->nullable(); + $table->string('provider_message_id')->nullable(); + $table->decimal('cost', 10, 4)->nullable(); + $table->string('currency', 10)->nullable(); + $table->string('idempotency_key')->nullable(); + $table->timestamps(); + + $table->index(['package_id', 'status']); + $table->unique(['idempotency_key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('package_items'); + } +}; diff --git a/database/migrations/2025_10_25_110000_add_validated_and_phone_type_to_person_phones.php b/database/migrations/2025_10_25_110000_add_validated_and_phone_type_to_person_phones.php new file mode 100644 index 0000000..4c48e56 --- /dev/null +++ b/database/migrations/2025_10_25_110000_add_validated_and_phone_type_to_person_phones.php @@ -0,0 +1,85 @@ +boolean('validated')->default(false)->after('active'); + } + }); + + // Add phone_type column depending on driver + $hasPhoneType = $driver === 'pgsql' + ? (bool) DB::selectOne("SELECT 1 as exists FROM information_schema.columns WHERE table_name = 'person_phones' AND column_name = 'phone_type'") + : Schema::hasColumn('person_phones', 'phone_type'); + if (! $hasPhoneType) { + if ($driver === 'pgsql') { + // enum-typed column for Postgres + DB::statement('ALTER TABLE person_phones ADD COLUMN phone_type phone_type_enum NULL'); + } else { + // Fallback for sqlite/mysql in tests/dev: simple string column + Schema::table('person_phones', function (Blueprint $table): void { + $table->string('phone_type', 20)->nullable()->after('validated'); + }); + } + } + } + + public function down(): void + { + $driver = DB::getDriverName(); + // Drop column phone_type if exists + $hasPhoneType = $driver === 'pgsql' + ? (bool) DB::selectOne("SELECT 1 as exists FROM information_schema.columns WHERE table_name = 'person_phones' AND column_name = 'phone_type'") + : Schema::hasColumn('person_phones', 'phone_type'); + if ($hasPhoneType) { + if ($driver === 'pgsql') { + DB::statement('ALTER TABLE person_phones DROP COLUMN phone_type'); + } else { + Schema::table('person_phones', function (Blueprint $table): void { + $table->dropColumn('phone_type'); + }); + } + } + + Schema::table('person_phones', function (Blueprint $table): void { + if (Schema::hasColumn('person_phones', 'validated')) { + $table->dropColumn('validated'); + } + }); + + if ($driver === 'pgsql') { + // Drop type if no longer used (best-effort) + DB::statement(<<<'SQL' +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'phone_type_enum') THEN + -- ensure no remaining dependencies + EXECUTE 'DROP TYPE phone_type_enum'; + END IF; +END$$; +SQL); + } + } +}; diff --git a/resources/js/Components/AddressCreateForm.vue b/resources/js/Components/AddressCreateForm.vue index 1a782aa..bb39718 100644 --- a/resources/js/Components/AddressCreateForm.vue +++ b/resources/js/Components/AddressCreateForm.vue @@ -1,90 +1,72 @@ diff --git a/resources/js/Components/PersonInfoGrid.vue b/resources/js/Components/PersonInfoGrid.vue index 8483d45..572f9fe 100644 --- a/resources/js/Components/PersonInfoGrid.vue +++ b/resources/js/Components/PersonInfoGrid.vue @@ -228,12 +228,123 @@ const pageSmsTemplates = computed(() => { : null; return fromProps ?? pageProps.value?.sms_templates ?? []; }); +// Helpers: EU formatter and token renderer +const formatEu = (value, decimals = 2) => { + if (value === null || value === undefined || value === "") { + return new Intl.NumberFormat("de-DE", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(0); + } + const num = + typeof value === "number" + ? value + : parseFloat(String(value).replace(/\./g, "").replace(",", ".")); + return new Intl.NumberFormat("de-DE", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(isNaN(num) ? 0 : num); +}; + +const renderTokens = (text, vars) => { + if (!text) return ""; + const resolver = (obj, path) => { + if (!obj) return null; + if (Object.prototype.hasOwnProperty.call(obj, path)) return obj[path]; + const segs = path.split("."); + let cur = obj; + for (const s of segs) { + if (cur && typeof cur === "object" && s in cur) { + cur = cur[s]; + } else { + return null; + } + } + return cur; + }; + return text.replace(/\{([a-zA-Z0-9_\.]+)\}/g, (_, key) => { + const val = resolver(vars, key); + return val !== null && val !== undefined ? String(val) : `{${key}}`; + }); +}; + +const buildVarsFromSelectedContract = () => { + const uuid = selectedContractUuid.value; + if (!uuid) return {}; + const c = (contractsForCase.value || []).find((x) => x.uuid === uuid); + if (!c) return {}; + const vars = { + contract: { + uuid: c.uuid, + reference: c.reference, + start_date: c.start_date || "", + end_date: c.end_date || "", + }, + }; + if (c.account) { + vars.account = { + reference: c.account.reference, + type: c.account.type, + initial_amount: + c.account.initial_amount ?? + (c.account.initial_amount_raw ? formatEu(c.account.initial_amount_raw) : null), + balance_amount: + c.account.balance_amount ?? + (c.account.balance_amount_raw ? formatEu(c.account.balance_amount_raw) : null), + initial_amount_raw: c.account.initial_amount_raw ?? null, + balance_amount_raw: c.account.balance_amount_raw ?? null, + }; + } + return vars; +}; + +const updateSmsFromSelection = async () => { + if (!selectedTemplateId.value) return; + // Try server preview first + try { + const url = route("clientCase.sms.preview", { client_case: props.clientCaseUuid }); + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-TOKEN": + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + "", + }, + body: JSON.stringify({ + template_id: selectedTemplateId.value, + contract_uuid: selectedContractUuid.value || null, + }), + credentials: "same-origin", + }); + if (res.ok) { + const data = await res.json(); + if (typeof data?.content === "string" && data.content.trim() !== "") { + smsMessage.value = data.content; + return; + } + } + } catch (e) { + // ignore and fallback + } + // Fallback: client-side render using template content and selected contract vars + const tpl = (pageSmsTemplates.value || []).find( + (t) => t.id === selectedTemplateId.value + ); + if (tpl && typeof tpl.content === "string") { + smsMessage.value = renderTokens(tpl.content, buildVarsFromSelectedContract()); + } +}; // Selections const selectedProfileId = ref(null); const selectedSenderId = ref(null); const deliveryReport = ref(false); const selectedTemplateId = ref(null); +// Contract selection for placeholder rendering +const contractsForCase = ref([]); +const selectedContractUuid = ref(null); const sendersForSelectedProfile = computed(() => { if (!selectedProfileId.value) return pageSmsSenders.value; @@ -257,14 +368,49 @@ watch(sendersForSelectedProfile, (list) => { } }); +const renderSmsPreview = async () => { + if (!selectedTemplateId.value) return; + try { + const url = route("clientCase.sms.preview", { client_case: props.clientCaseUuid }); + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-TOKEN": + document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || + "", + }, + body: JSON.stringify({ + template_id: selectedTemplateId.value, + contract_uuid: selectedContractUuid.value || null, + }), + credentials: "same-origin", + }); + if (!res.ok) throw new Error(`Preview failed: ${res.status}`); + const data = await res.json(); + if (typeof data?.content === "string") { + smsMessage.value = data.content; + } + } catch (e) { + // If preview fails and template has inline content, fallback + const tpl = (pageSmsTemplates.value || []).find( + (t) => t.id === selectedTemplateId.value + ); + if (tpl && typeof tpl.content === "string") { + smsMessage.value = tpl.content; + } + } +}; + watch(selectedTemplateId, () => { if (!selectedTemplateId.value) return; - const tpl = (pageSmsTemplates.value || []).find( - (t) => t.id === selectedTemplateId.value - ); - if (tpl && typeof tpl.content === "string") { - smsMessage.value = tpl.content; - } + updateSmsFromSelection(); +}); + +watch(selectedContractUuid, () => { + if (!selectedTemplateId.value) return; + updateSmsFromSelection(); }); // If templates array changes and none is chosen, pick the first by default @@ -304,6 +450,22 @@ const openSmsDialog = (phone) => { // Default template selection to first available selectedTemplateId.value = (pageSmsTemplates.value && pageSmsTemplates.value[0]?.id) || null; + // Load contracts for this case (for contract/account placeholders) + loadContractsForCase(); +}; +const loadContractsForCase = async () => { + try { + const url = route("clientCase.contracts.list", { client_case: props.clientCaseUuid }); + const res = await fetch(url, { + headers: { "X-Requested-With": "XMLHttpRequest" }, + credentials: "same-origin", + }); + const json = await res.json(); + contractsForCase.value = Array.isArray(json?.data) ? json.data : []; + // Do not auto-select a contract; let user pick explicitly + } catch (e) { + contractsForCase.value = []; + } }; const closeSmsDialog = () => { showSmsDialog.value = false; @@ -323,6 +485,7 @@ const submitSms = () => { { message: smsMessage.value, template_id: selectedTemplateId.value, + contract_uuid: selectedContractUuid.value, profile_id: selectedProfileId.value, sender_id: selectedSenderId.value, delivery_report: !!deliveryReport.value, @@ -710,6 +873,24 @@ const submitSms = () => { + +
+ + +

+ Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in {account.*} + mest. +

+
+
diff --git a/resources/js/Components/PhoneCreateForm.vue b/resources/js/Components/PhoneCreateForm.vue index 64c906b..cda7bae 100644 --- a/resources/js/Components/PhoneCreateForm.vue +++ b/resources/js/Components/PhoneCreateForm.vue @@ -1,132 +1,121 @@ diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue index b79ff1a..eace7bf 100644 --- a/resources/js/Layouts/AdminLayout.vue +++ b/resources/js/Layouts/AdminLayout.vue @@ -172,6 +172,13 @@ const navGroups = computed(() => [ icon: faGears, active: ["admin.sms-profiles.index"], }, + { + key: "admin.packages.index", + label: "SMS paketi", + route: "admin.packages.index", + icon: faMessage, + active: ["admin.packages.index", "admin.packages.show"], + }, ], }, ]); diff --git a/resources/js/Pages/Admin/Index.vue b/resources/js/Pages/Admin/Index.vue index f9f4d5c..707dae9 100644 --- a/resources/js/Pages/Admin/Index.vue +++ b/resources/js/Pages/Admin/Index.vue @@ -1,103 +1,123 @@