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', 'content']); $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 ?? ''), ]; // Include contract.meta as flattened key-value pairs if (is_array($c->meta) && ! empty($c->meta)) { $vars['contract']['meta'] = $this->flattenMeta($c->meta); } 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 = $sms->renderContent($body, $vars); } 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 ?? ''), ]; // Include contract.meta as flattened key-value pairs if (is_array($c->meta) && ! empty($c->meta)) { $vars['contract']['meta'] = $this->flattenMeta($c->meta); } 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' => $sms->renderContent($body, $vars), ]; } 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'); } public function destroy(Package $package): RedirectResponse { // Allow deletion only for drafts (not yet dispatched) if ($package->status !== Package::STATUS_DRAFT) { return back()->with('error', 'Package not in a deletable state.'); } // Remove items first to avoid FK issues $package->items()->delete(); $package->delete(); return back()->with('success', 'Package deleted'); } /** * 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' => ['nullable', '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'], 'start_date_from' => ['nullable', 'date'], 'start_date_to' => ['nullable', 'date'], 'promise_date_from' => ['nullable', 'date'], 'promise_date_to' => ['nullable', 'date'], ]); $segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null; $perPage = (int) ($request->input('per_page') ?? 25); $query = Contract::query() ->with([ 'clientCase.person.phones', 'clientCase.client.person', 'account', ]) ->select('contracts.*') ->latest('contracts.id'); // Optional segment filter if ($segmentId) { $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); }); } 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); } // Date range filters for start_date if ($startDateFrom = $request->input('start_date_from')) { $query->where('contracts.start_date', '>=', $startDateFrom); } if ($startDateTo = $request->input('start_date_to')) { $query->where('contracts.start_date', '<=', $startDateTo); } // Date range filters for account.promise_date $promiseDateFrom = $request->input('promise_date_from'); $promiseDateTo = $request->input('promise_date_to'); if ($promiseDateFrom || $promiseDateTo) { $query->whereHas('account', function ($q) use ($promiseDateFrom, $promiseDateTo) { if ($promiseDateFrom) { $q->where('promise_date', '>=', $promiseDateFrom); } if ($promiseDateTo) { $q->where('promise_date', '<=', $promiseDateTo); } }); } // 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, 'start_date' => $contract->start_date, 'promise_date' => $contract->account?->promise_date, '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'); } /** * Flatten nested meta structure into dot-notation key-value pairs. * Extracts 'value' from objects with {title, value, type} structure. * Also creates direct access aliases for nested fields (skipping numeric keys). */ private function flattenMeta(array $meta, string $prefix = ''): array { $result = []; foreach ($meta as $key => $value) { $newKey = $prefix === '' ? $key : "{$prefix}.{$key}"; if (is_array($value)) { // Check if it's a structured meta entry with 'value' field if (isset($value['value'])) { $result[$newKey] = $value['value']; // If parent key is numeric, also create direct alias without the number if ($prefix !== '' && is_numeric($key)) { $result[$key] = $value['value']; } } else { // Recursively flatten nested arrays $nested = $this->flattenMeta($value, $newKey); $result = array_merge($result, $nested); // If current key is numeric, also flatten without it for easier access if (is_numeric($key)) { $directNested = $this->flattenMeta($value, $prefix); foreach ($directNested as $dk => $dv) { // Only add if not already set (prefer first occurrence) if (! isset($result[$dk])) { $result[$dk] = $dv; } } } } } else { $result[$newKey] = $value; } } return $result; } }