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 @@
-
+ Uporabi podatke pogodbe (in računa) za zapolnitev {contract.*} in {account.*} + mest. +
+{{ item.description }}
++ {{ item.description }} +
| + | Pogodba | +Primer | +Stranka | +Izbrana številka | +Opomba | +
|---|---|---|---|---|---|
| + + | +
+ {{ c.uuid }}
+ {{ c.reference }}
+ |
+
+ {{ c.person?.full_name || '—' }}
+ |
+
+ {{ c.client?.name || '—' }}
+ |
+
+
+ {{ c.selected_phone.number }}
+ {{ c.selected_phone.validated ? 'validated' : 'unverified' }} / {{ c.selected_phone.type || 'unknown' }}
+
+ —
+ |
+ {{ c.no_phone_reason || '—' }} | +
| Ni rezultatov. | +|||||
| Nalaganje... | |||||
| ID | +UUID | +Ime | +Tip | +Status | +Skupaj | +Poslano | +Neuspešno | +Zaključeno | ++ |
|---|---|---|---|---|---|---|---|---|---|
| {{ p.id }} | +{{ p.uuid }} | +{{ p.name ?? '—' }} | +{{ p.type }} | ++ {{ p.status }} + | +{{ p.total_items }} | +{{ p.sent_count }} | +{{ p.failed_count }} | +{{ p.finished_at ?? '—' }} | ++ + | +
| Ni paketov za prikaz. | +|||||||||
+ UUID: {{ package.uuid }} +
+Status
+{{ package.status }}
+Skupaj
+{{ package.total_items }}
+Poslano
++ {{ package.sent_count }} +
+Neuspešno
+{{ package.failed_count }}
+Sporočilo
+ +Meta / Nastavitve pošiljanja
+| ID | +Prejemnik | +Sporočilo | +Status | +Napaka | +Provider ID | +Cena | +Valuta | +
|---|---|---|---|---|---|---|---|
| {{ it.id }} | ++ {{ (it.target_json && it.target_json.number) || "—" }} + | +
+
+
+ {{ it.rendered_preview || '—' }}
+
+ |
+ + {{ it.status }} + | +{{ it.last_error ?? "—" }} | ++ {{ it.provider_message_id ?? "—" }} + | +{{ it.cost ?? "—" }} | +{{ it.currency ?? "—" }} | +
| + Ni elementov za prikaz. + | +|||||||