From 930ac836042bf6c858c15c4aee255b18d9e3da7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Fri, 24 Oct 2025 21:39:10 +0200 Subject: [PATCH] SMS service --- .../Controllers/Admin/SmsLogController.php | 82 +++++ .../Admin/SmsProfileController.php | 108 ++++++ .../Controllers/Admin/SmsSenderController.php | 88 +++++ .../Admin/SmsTemplateController.php | 199 +++++++++++ app/Http/Controllers/ClientCaseContoller.php | 136 +++++++ app/Http/Controllers/DashboardController.php | 51 ++- .../Controllers/NotificationController.php | 60 ++-- app/Http/Controllers/SmsWebhookController.php | 85 +++++ app/Http/Middleware/HandleInertiaRequests.php | 2 +- app/Http/Requests/StoreSmsProfileRequest.php | 23 ++ app/Http/Requests/StoreSmsSenderRequest.php | 30 ++ app/Http/Requests/StoreSmsTemplateRequest.php | 31 ++ app/Http/Requests/TestSendSmsRequest.php | 24 ++ .../Requests/TestSendSmsTemplateRequest.php | 26 ++ app/Http/Requests/UpdateSmsSenderRequest.php | 33 ++ .../Requests/UpdateSmsTemplateRequest.php | 33 ++ app/Jobs/SendSmsJob.php | 100 ++++++ app/Models/SmsLog.php | 52 +++ app/Models/SmsProfile.php | 56 +++ app/Models/SmsSender.php | 30 ++ app/Models/SmsTemplate.php | 53 +++ app/Providers/AppServiceProvider.php | 11 +- app/Services/Sms/SmsApiSiClient.php | 202 +++++++++++ app/Services/Sms/SmsClient.php | 23 ++ app/Services/Sms/SmsMessage.php | 16 + app/Services/Sms/SmsResult.php | 14 + app/Services/Sms/SmsService.php | 108 ++++++ config/services.php | 17 + database/factories/SmsProfileFactory.php | 28 ++ database/factories/SmsSenderFactory.php | 25 ++ ...10_23_000001_create_sms_profiles_table.php | 33 ++ ..._10_23_000002_create_sms_senders_table.php | 28 ++ ...0_23_000003_create_sms_templates_table.php | 31 ++ ...025_10_23_000004_create_sms_logs_table.php | 42 +++ ..._add_phone_number_to_sms_senders_table.php | 22 ++ ...00011_alter_sms_senders_sname_nullable.php | 24 ++ ...y_and_activity_fields_to_sms_templates.php | 26 ++ resources/js/Components/PersonInfoGrid.vue | 227 +++++++++++- resources/js/Layouts/AdminLayout.vue | 40 +++ resources/js/Pages/Admin/Index.vue | 31 +- resources/js/Pages/Admin/SmsLogs/Index.vue | 164 +++++++++ resources/js/Pages/Admin/SmsLogs/Show.vue | 80 +++++ .../js/Pages/Admin/SmsProfiles/Index.vue | 292 +++++++++++++++ resources/js/Pages/Admin/SmsSenders/Index.vue | 199 +++++++++++ .../js/Pages/Admin/SmsTemplates/Edit.vue | 263 ++++++++++++++ .../js/Pages/Admin/SmsTemplates/Index.vue | 332 ++++++++++++++++++ resources/js/Pages/Cases/Show.vue | 2 + resources/js/Pages/Dashboard.vue | 58 +++ resources/js/Pages/Notifications/Unread.vue | 9 +- routes/web.php | 56 +++ tests/Feature/SmsProfileTest.php | 120 +++++++ tests/Unit/SmsServiceTest.php | 41 +++ 52 files changed, 3830 insertions(+), 36 deletions(-) create mode 100644 app/Http/Controllers/Admin/SmsLogController.php create mode 100644 app/Http/Controllers/Admin/SmsProfileController.php create mode 100644 app/Http/Controllers/Admin/SmsSenderController.php create mode 100644 app/Http/Controllers/Admin/SmsTemplateController.php create mode 100644 app/Http/Controllers/SmsWebhookController.php create mode 100644 app/Http/Requests/StoreSmsProfileRequest.php create mode 100644 app/Http/Requests/StoreSmsSenderRequest.php create mode 100644 app/Http/Requests/StoreSmsTemplateRequest.php create mode 100644 app/Http/Requests/TestSendSmsRequest.php create mode 100644 app/Http/Requests/TestSendSmsTemplateRequest.php create mode 100644 app/Http/Requests/UpdateSmsSenderRequest.php create mode 100644 app/Http/Requests/UpdateSmsTemplateRequest.php create mode 100644 app/Jobs/SendSmsJob.php create mode 100644 app/Models/SmsLog.php create mode 100644 app/Models/SmsProfile.php create mode 100644 app/Models/SmsSender.php create mode 100644 app/Models/SmsTemplate.php create mode 100644 app/Services/Sms/SmsApiSiClient.php create mode 100644 app/Services/Sms/SmsClient.php create mode 100644 app/Services/Sms/SmsMessage.php create mode 100644 app/Services/Sms/SmsResult.php create mode 100644 app/Services/Sms/SmsService.php create mode 100644 database/factories/SmsProfileFactory.php create mode 100644 database/factories/SmsSenderFactory.php create mode 100644 database/migrations/2025_10_23_000001_create_sms_profiles_table.php create mode 100644 database/migrations/2025_10_23_000002_create_sms_senders_table.php create mode 100644 database/migrations/2025_10_23_000003_create_sms_templates_table.php create mode 100644 database/migrations/2025_10_23_000004_create_sms_logs_table.php create mode 100644 database/migrations/2025_10_23_000010_add_phone_number_to_sms_senders_table.php create mode 100644 database/migrations/2025_10_23_000011_alter_sms_senders_sname_nullable.php create mode 100644 database/migrations/2025_10_23_120000_add_custom_body_and_activity_fields_to_sms_templates.php create mode 100644 resources/js/Pages/Admin/SmsLogs/Index.vue create mode 100644 resources/js/Pages/Admin/SmsLogs/Show.vue create mode 100644 resources/js/Pages/Admin/SmsProfiles/Index.vue create mode 100644 resources/js/Pages/Admin/SmsSenders/Index.vue create mode 100644 resources/js/Pages/Admin/SmsTemplates/Edit.vue create mode 100644 resources/js/Pages/Admin/SmsTemplates/Index.vue create mode 100644 tests/Feature/SmsProfileTest.php create mode 100644 tests/Unit/SmsServiceTest.php diff --git a/app/Http/Controllers/Admin/SmsLogController.php b/app/Http/Controllers/Admin/SmsLogController.php new file mode 100644 index 0000000..f49f9ed --- /dev/null +++ b/app/Http/Controllers/Admin/SmsLogController.php @@ -0,0 +1,82 @@ +with(['profile:id,name', 'template:id,name,slug']); + + // Filters + $status = $request->string('status')->toString(); + $profileId = $request->integer('profile_id'); + $templateId = $request->integer('template_id'); + $search = trim((string) $request->input('search', '')); + $from = $request->date('from'); + $to = $request->date('to'); + + if ($status !== '') { + $query->where('status', $status); + } + if ($profileId) { + $query->where('profile_id', $profileId); + } + if ($templateId) { + $query->where('template_id', $templateId); + } + if ($search !== '') { + $query->where(function ($q) use ($search): void { + $q->where('to_number', 'ILIKE', "%$search%") + ->orWhere('sender', 'ILIKE', "%$search%") + ->orWhere('provider_message_id', 'ILIKE', "%$search%") + ->orWhere('message', 'ILIKE', "%$search%"); + }); + } + if ($from) { + $query->whereDate('created_at', '>=', $from); + } + if ($to) { + $query->whereDate('created_at', '<=', $to); + } + + $logs = $query->orderByDesc('id')->paginate(20)->withQueryString(); + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json($logs); + } + + $profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']); + $templates = SmsTemplate::query()->orderBy('name')->get(['id', 'name', 'slug']); + + return Inertia::render('Admin/SmsLogs/Index', [ + 'logs' => $logs, + 'profiles' => $profiles, + 'templates' => $templates, + 'filters' => [ + 'status' => $status ?: null, + 'profile_id' => $profileId ?: null, + 'template_id' => $templateId ?: null, + 'search' => $search ?: null, + 'from' => $from ? $from->format('Y-m-d') : null, + 'to' => $to ? $to->format('Y-m-d') : null, + ], + ]); + } + + public function show(SmsLog $smsLog) + { + $smsLog->load(['profile:id,name', 'template:id,name,slug']); + + return Inertia::render('Admin/SmsLogs/Show', [ + 'log' => $smsLog, + ]); + } +} diff --git a/app/Http/Controllers/Admin/SmsProfileController.php b/app/Http/Controllers/Admin/SmsProfileController.php new file mode 100644 index 0000000..3dc7ca3 --- /dev/null +++ b/app/Http/Controllers/Admin/SmsProfileController.php @@ -0,0 +1,108 @@ +with(['senders:id,profile_id,sname,active'])->orderBy('name')->get([ + 'id', 'uuid', 'name', 'active', 'api_username', 'default_sender_id', 'settings', 'created_at', 'updated_at', + ]); + + // Inertia requests must receive an Inertia response + if ($request->headers->has('X-Inertia')) { + return Inertia::render('Admin/SmsProfiles/Index', [ + 'initialProfiles' => $profiles, + ]); + } + + // JSON/AJAX API + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json(['profiles' => $profiles]); + } + + // Default to Inertia page for normal browser navigation + return Inertia::render('Admin/SmsProfiles/Index', [ + 'initialProfiles' => $profiles, + ]); + } + + public function store(StoreSmsProfileRequest $request) + { + $data = $request->validated(); + $profile = new SmsProfile; + $profile->uuid = (string) Str::uuid(); + $profile->name = $data['name']; + $profile->active = (bool) ($data['active'] ?? true); + $profile->api_username = $data['api_username']; + // write-only attribute setter will encrypt and store to encrypted_api_password + $profile->api_password = $data['api_password']; + $profile->save(); + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json(['profile' => $profile], 201); + } + + return back()->with('success', 'SMS profil je ustvarjen.'); + } + + public function testSend(SmsProfile $profile, TestSendSmsRequest $request, SmsService $sms) + { + $data = $request->validated(); + $sender = null; + if (! empty($data['sender_id'])) { + $sender = SmsSender::query()->where('id', $data['sender_id'])->where('profile_id', $profile->id)->firstOrFail(); + } + + // Queue the SMS send (admin test send - no activity created) + SendSmsJob::dispatch( + profileId: $profile->id, + to: (string) $data['to'], + content: (string) $data['message'], + senderId: $sender?->id, + countryCode: $data['country_code'] ?? null, + deliveryReport: (bool) ($data['delivery_report'] ?? false), + clientReference: null, + ); + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json(['queued' => true]); + } + + return back()->with('success', 'Testni SMS je bil dodan v čakalno vrsto.'); + } + + public function balance(SmsProfile $smsProfile, SmsService $sms) + { + try { + $balance = (string) $sms->getCreditBalance($smsProfile); + + return response()->json(['balance' => $balance]); + } catch (\Throwable $e) { + // Return a graceful payload so UI doesn't break; also include message for optional UI/tooling + return response()->json([ + 'balance' => '—', + 'error' => 'Unable to fetch balance: '.$e->getMessage(), + ]); + } + } + + public function price(SmsProfile $smsProfile, SmsService $sms) + { + $quotes = $sms->getPriceQuotes($smsProfile); + + return response()->json(['quotes' => $quotes]); + } +} diff --git a/app/Http/Controllers/Admin/SmsSenderController.php b/app/Http/Controllers/Admin/SmsSenderController.php new file mode 100644 index 0000000..7dfc34d --- /dev/null +++ b/app/Http/Controllers/Admin/SmsSenderController.php @@ -0,0 +1,88 @@ +with(['profile:id,name']) + ->orderBy('id', 'desc') + ->get(['id', 'profile_id', 'sname', 'phone_number', 'description', 'active', 'created_at']); + + $profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']); + + return Inertia::render('Admin/SmsSenders/Index', [ + 'initialSenders' => $senders, + 'profiles' => $profiles, + ]); + } + + public function store(StoreSmsSenderRequest $request) + { + $data = $request->validated(); + $sender = SmsSender::create([ + 'profile_id' => $data['profile_id'], + 'sname' => $data['sname'], + 'phone_number' => $data['phone_number'] ?? null, + 'description' => $data['description'] ?? null, + 'active' => (bool) ($data['active'] ?? true), + ]); + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json(['sender' => $sender], 201); + } + + return back()->with('success', 'Pošiljatelj je ustvarjen.'); + } + + public function update(UpdateSmsSenderRequest $request, SmsSender $smsSender) + { + $data = $request->validated(); + $smsSender->forceFill([ + 'profile_id' => $data['profile_id'], + 'sname' => $data['sname'], + 'phone_number' => $data['phone_number'] ?? null, + 'description' => $data['description'] ?? null, + 'active' => (bool) ($data['active'] ?? $smsSender->active), + ])->save(); + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json(['sender' => $smsSender]); + } + + return back()->with('success', 'Pošiljatelj je posodobljen.'); + } + + public function toggle(Request $request, SmsSender $smsSender) + { + $smsSender->active = ! $smsSender->active; + $smsSender->save(); + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json(['sender' => $smsSender]); + } + + return back()->with('success', 'Stanje pošiljatelja je posodobljeno.'); + } + + public function destroy(Request $request, SmsSender $smsSender) + { + $smsSender->delete(); + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json(['deleted' => true]); + } + + return back()->with('success', 'Pošiljatelj je izbrisan.'); + } +} diff --git a/app/Http/Controllers/Admin/SmsTemplateController.php b/app/Http/Controllers/Admin/SmsTemplateController.php new file mode 100644 index 0000000..ff1f9d6 --- /dev/null +++ b/app/Http/Controllers/Admin/SmsTemplateController.php @@ -0,0 +1,199 @@ +with(['defaultProfile:id,name', 'defaultSender:id,sname']) + ->orderBy('name') + ->get(['id', 'uuid', 'name', 'slug', 'content', 'variables_json', 'is_active', 'default_profile_id', 'default_sender_id', 'created_at']); + + $profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']); + $senders = SmsSender::query()->orderBy('sname')->get(['id', 'profile_id', 'sname', 'active']); + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json([ + 'templates' => $templates, + 'profiles' => $profiles, + 'senders' => $senders, + ]); + } + + return Inertia::render('Admin/SmsTemplates/Index', [ + 'initialTemplates' => $templates, + 'profiles' => $profiles, + 'senders' => $senders, + ]); + } + + public function create() + { + $profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']); + $senders = SmsSender::query()->orderBy('sname')->get(['id', 'profile_id', 'sname', 'active']); + $actions = \App\Models\Action::query() + ->with(['decisions:id,name']) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Admin/SmsTemplates/Edit', [ + 'template' => null, + 'profiles' => $profiles, + 'senders' => $senders, + 'actions' => $actions, + ]); + } + + public function edit(SmsTemplate $smsTemplate) + { + $profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']); + $senders = SmsSender::query()->orderBy('sname')->get(['id', 'profile_id', 'sname', 'active']); + $actions = \App\Models\Action::query() + ->with(['decisions:id,name']) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Admin/SmsTemplates/Edit', [ + 'template' => $smsTemplate->only(['id', 'uuid', 'name', 'slug', 'content', 'variables_json', 'is_active', 'default_profile_id', 'default_sender_id', 'allow_custom_body', 'action_id', 'decision_id']), + 'profiles' => $profiles, + 'senders' => $senders, + 'actions' => $actions, + ]); + } + + public function store(StoreSmsTemplateRequest $request) + { + $data = $request->validated(); + $tpl = new SmsTemplate; + $tpl->uuid = (string) Str::uuid(); + $tpl->name = $data['name']; + $tpl->slug = $data['slug']; + $tpl->content = $data['content'] ?? ''; + $tpl->variables_json = $data['variables_json'] ?? null; + $tpl->is_active = (bool) ($data['is_active'] ?? true); + $tpl->default_profile_id = $data['default_profile_id'] ?? null; + $tpl->default_sender_id = $data['default_sender_id'] ?? null; + $tpl->allow_custom_body = (bool) ($data['allow_custom_body'] ?? false); + $tpl->action_id = $data['action_id'] ?? null; + $tpl->decision_id = $data['decision_id'] ?? null; + $tpl->save(); + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json(['template' => $tpl], 201); + } + + return redirect()->route('admin.sms-templates.edit', $tpl); + } + + public function update(UpdateSmsTemplateRequest $request, SmsTemplate $smsTemplate) + { + $data = $request->validated(); + $smsTemplate->forceFill([ + 'name' => $data['name'], + 'slug' => $data['slug'], + 'content' => $data['content'] ?? '', + 'variables_json' => $data['variables_json'] ?? null, + 'is_active' => (bool) ($data['is_active'] ?? $smsTemplate->is_active), + 'default_profile_id' => $data['default_profile_id'] ?? null, + 'default_sender_id' => $data['default_sender_id'] ?? null, + 'allow_custom_body' => (bool) ($data['allow_custom_body'] ?? $smsTemplate->allow_custom_body), + 'action_id' => $data['action_id'] ?? null, + 'decision_id' => $data['decision_id'] ?? null, + ])->save(); + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json(['template' => $smsTemplate]); + } + + return back()->with('success', 'SMS predloga je posodobljena.'); + } + + public function toggle(Request $request, SmsTemplate $smsTemplate) + { + $smsTemplate->is_active = ! $smsTemplate->is_active; + $smsTemplate->save(); + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json(['template' => $smsTemplate]); + } + + return back()->with('success', 'Stanje predloge je posodobljeno.'); + } + + public function destroy(Request $request, SmsTemplate $smsTemplate) + { + $smsTemplate->delete(); + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json(['deleted' => true]); + } + + return back()->with('success', 'Predloga je izbrisana.'); + } + + public function sendTest(TestSendSmsTemplateRequest $request, SmsTemplate $smsTemplate, SmsService $sms) + { + $data = $request->validated(); + + $profile = null; + if (! empty($data['profile_id'])) { + $profile = SmsProfile::query()->findOrFail($data['profile_id']); + } + $sender = null; + if (! empty($data['sender_id'])) { + $sender = SmsSender::query()->findOrFail($data['sender_id']); + } + + $variables = (array) ($data['variables'] ?? []); + + if (! empty($data['custom_content']) && $smsTemplate->allow_custom_body) { + // Use custom content when allowed + if (! $profile) { + $profile = $smsTemplate->defaultProfile; + } + if (! $profile) { + throw new \InvalidArgumentException('SMS profile is required to send a message.'); + } + $log = $sms->sendRaw( + profile: $profile, + to: $data['to'], + content: (string) $data['custom_content'], + sender: $sender, + countryCode: $data['country_code'] ?? null, + deliveryReport: (bool) ($data['delivery_report'] ?? false), + ); + $log->template_id = $smsTemplate->id; + $log->save(); + } else { + $log = $sms->sendFromTemplate( + template: $smsTemplate, + to: $data['to'], + variables: $variables, + profile: $profile, + sender: $sender, + countryCode: $data['country_code'] ?? null, + deliveryReport: (bool) ($data['delivery_report'] ?? false), + ); + } + + if ($request->wantsJson() || $request->expectsJson()) { + return response()->json(['log' => $log]); + } + + return back()->with('success', 'Testni SMS je bil poslan.'); + } +} diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index af29dcb..039a1c1 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -1343,6 +1343,22 @@ function ($p) { 'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']), 'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']), 'current_segment' => $currentSegment, + // SMS helpers for per-case sending UI + 'sms_profiles' => \App\Models\SmsProfile::query() + ->select(['id', 'name', 'default_sender_id']) + ->where('active', true) + ->orderBy('name') + ->get(), + 'sms_senders' => \App\Models\SmsSender::query() + ->select(['id', 'profile_id']) + ->addSelect(\DB::raw('sname as name')) + ->addSelect(\DB::raw('phone_number as phone')) + ->orderBy('sname') + ->get(), + 'sms_templates' => \App\Models\SmsTemplate::query() + ->select(['id', 'name', 'content', 'allow_custom_body']) + ->orderBy('name') + ->get(), ]); } @@ -1665,4 +1681,124 @@ public function emergencyCreatePerson(ClientCase $clientCase, Request $request) 'person_uuid' => $newPerson?->uuid, ]); } + + /** + * Send an SMS to a specific phone that belongs to the client case person. + */ + public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $phone_id) + { + $validated = $request->validate([ + 'message' => ['required', 'string', 'max:1000'], + 'delivery_report' => ['sometimes', 'boolean'], + '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'], + ]); + + // Ensure the phone belongs to the person of this case + /** @var \App\Models\Person\PersonPhone|null $phone */ + $phone = \App\Models\Person\PersonPhone::query() + ->where('id', $phone_id) + ->where('person_id', $clientCase->person_id) + ->first(); + + if (! $phone) { + abort(404); + } + + // Resolve explicit profile/sender if provided; otherwise fallback to first active profile and its default sender + /** @var \App\Models\SmsProfile|null $profile */ + $profile = null; + /** @var \App\Models\SmsSender|null $sender */ + $sender = null; + + if (! empty($validated['sender_id']) && empty($validated['profile_id'])) { + // Infer profile from sender if not explicitly provided + $sender = \App\Models\SmsSender::query()->find($validated['sender_id']); + if ($sender) { + $profile = \App\Models\SmsProfile::query()->find($sender->profile_id); + } + } + if (! empty($validated['profile_id'])) { + $profile = \App\Models\SmsProfile::query()->where('id', $validated['profile_id'])->first(); + if (! $profile) { + return back()->with('error', 'Izbran SMS profil ne obstaja.'); + } + if (property_exists($profile, 'active') && ! $profile->active) { + return back()->with('error', 'Izbran SMS profil ni aktiven.'); + } + } + if (! empty($validated['sender_id'])) { + $sender = \App\Models\SmsSender::query()->find($validated['sender_id']); + if (! $sender) { + return back()->with('error', 'Izbran pošiljatelj ne obstaja.'); + } + if ($profile && (int) $sender->profile_id !== (int) $profile->id) { + return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.'); + } + } + if (! $profile) { + $profile = \App\Models\SmsProfile::query() + ->where('active', true) + ->orderByRaw('CASE WHEN default_sender_id IS NULL THEN 1 ELSE 0 END') + ->orderBy('id') + ->first(); + } + if (! $profile) { + return back()->with('warning', 'Ni aktivnega SMS profila.'); + } + if (! $sender && ! empty($profile->default_sender_id)) { + $sender = \App\Models\SmsSender::query()->find($profile->default_sender_id); + } + + try { + /** @var \App\Services\Sms\SmsService $sms */ + $sms = app(\App\Services\Sms\SmsService::class); + // Check available credits before enqueueing (fail-closed) + try { + $raw = (string) $sms->getCreditBalance($profile); + $num = null; + if ($raw !== '') { + $normalized = str_replace(',', '.', trim($raw)); + if (preg_match('/-?\d+(?:\.\d+)?/', $normalized, $m)) { + $num = (float) ($m[0] ?? null); + } + } + if (! is_null($num) && $num <= 0.0) { + return back()->with('error', 'No credits left.'); + } + } catch (\Throwable $e) { + \Log::warning('SMS credit balance check failed', [ + 'error' => $e->getMessage(), + 'profile_id' => $profile->id, + ]); + + return back()->with('error', 'Unable to verify SMS credits.'); + } + + // Queue the SMS send; activity will be created in the job on success if a template is provided + \App\Jobs\SendSmsJob::dispatch( + profileId: $profile->id, + to: (string) $phone->nu, + content: (string) $validated['message'], + senderId: $sender?->id, + countryCode: $phone->country_code ?: null, + deliveryReport: (bool) ($validated['delivery_report'] ?? false), + clientReference: null, + templateId: $validated['template_id'] ?? null, + clientCaseId: $clientCase->id, + userId: optional($request->user())->id, + ); + + return back()->with('success', 'SMS je bil dodan v čakalno vrsto.'); + } catch (\Throwable $e) { + \Log::warning('SMS enqueue failed', [ + 'error' => $e->getMessage(), + 'case_id' => $clientCase->id, + 'phone_id' => $phone_id, + ]); + + return back()->with('error', 'SMS ni bil dodan v čakalno vrsto.'); + } + } } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 15aa7b1..efab421 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -8,6 +8,9 @@ use App\Models\Document; // assuming model name Import use App\Models\FieldJob; // if this model exists use App\Models\Import; +use App\Models\SmsLog; +use App\Models\SmsProfile; +use App\Services\Sms\SmsService; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Inertia\Inertia; @@ -15,7 +18,7 @@ class DashboardController extends Controller { - public function __invoke(): Response + public function __invoke(SmsService $sms): Response { $today = now()->startOfDay(); $yesterday = now()->subDay()->startOfDay(); @@ -223,6 +226,52 @@ public function __invoke(): Response 'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday, 'importsInProgress' => fn () => $importsInProgress, 'activeTemplates' => fn () => $activeTemplates, + 'smsStats' => function () use ($sms, $today) { + // Aggregate counts per profile for today + $counts = SmsLog::query() + ->whereDate('created_at', $today) + ->selectRaw('profile_id, status, COUNT(*) as c') + ->groupBy('profile_id', 'status') + ->get() + ->groupBy('profile_id') + ->map(function ($rows) { + $map = [ + 'queued' => 0, + 'sent' => 0, + 'delivered' => 0, + 'failed' => 0, + ]; + foreach ($rows as $r) { + $map[$r->status] = (int) $r->c; + } + $map['total'] = array_sum($map); + + return $map; + }); + + // Important: include credential fields so provider calls have proper credentials + $profiles = SmsProfile::query() + ->orderBy('name') + ->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']); + + return $profiles->map(function (SmsProfile $p) use ($sms, $counts) { + // Provider balance may fail; guard and present a placeholder. + try { + $balance = $sms->getCreditBalance($p); + } catch (\Throwable $e) { + $balance = '—'; + } + $c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0]; + + return [ + 'id' => $p->id, + 'name' => $p->name, + 'active' => (bool) $p->active, + 'balance' => $balance, + 'today' => $c, + ]; + })->values(); + }, ]); } } diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php index 9508345..56838af 100644 --- a/app/Http/Controllers/NotificationController.php +++ b/app/Http/Controllers/NotificationController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Activity; +use App\Models\Client; use App\Models\ClientCase; use App\Models\Contract; use Illuminate\Http\Request; @@ -21,9 +22,13 @@ public function unread(Request $request) $perPage = max(1, min(100, (int) $request->integer('perPage', 15))); $search = trim((string) $request->input('search', '')); $clientUuid = trim((string) $request->input('client', '')); - $clientCaseId = null; + $clientId = null; + $clientCaseIdsForFilter = collect(); if ($clientUuid !== '') { - $clientCaseId = ClientCase::query()->where('uuid', $clientUuid)->value('id'); + $clientId = Client::query()->where('uuid', $clientUuid)->value('id'); + if ($clientId) { + $clientCaseIdsForFilter = ClientCase::query()->where('client_id', $clientId)->pluck('id'); + } } $query = Activity::query() @@ -36,13 +41,13 @@ public function unread(Request $request) ->where('anr.user_id', $user->id) ->whereColumn('anr.due_date', 'activities.due_date'); }) - ->when($clientCaseId, function ($q) use ($clientCaseId) { - // Match activities for the client case directly OR via contracts belonging to the case - $q->where(function ($qq) use ($clientCaseId) { - $qq->where('activities.client_case_id', $clientCaseId) + ->when($clientCaseIdsForFilter->isNotEmpty(), function ($q) use ($clientCaseIdsForFilter) { + // Filter by clients: activities directly on any of the client's cases OR via contracts under those cases + $q->where(function ($qq) use ($clientCaseIdsForFilter) { + $qq->whereIn('activities.client_case_id', $clientCaseIdsForFilter) ->orWhereIn('activities.contract_id', Contract::query() ->select('id') - ->where('client_case_id', $clientCaseId) + ->whereIn('client_case_id', $clientCaseIdsForFilter) ); }); }) @@ -65,7 +70,7 @@ public function unread(Request $request) $qq->select(['client_cases.id', 'client_cases.uuid', 'client_cases.client_id']) ->with([ 'client' => function ($qqq) { - $qqq->select(['clients.id', 'clients.person_id']) + $qqq->select(['clients.id', 'clients.uuid', 'clients.person_id']) ->with([ 'person' => function ($qqqq) { $qqqq->select(['person.id', 'person.full_name']); @@ -86,7 +91,7 @@ public function unread(Request $request) $qq->select(['person.id', 'person.full_name']); }, 'client' => function ($qq) { - $qq->select(['clients.id', 'clients.person_id']) + $qq->select(['clients.id', 'clients.uuid', 'clients.person_id']) ->with([ 'person' => function ($qqq) { $qqq->select(['person.id', 'person.full_name']); @@ -102,7 +107,7 @@ public function unread(Request $request) // Use a custom page parameter name to match the frontend DataTableServer $activities = $query->paginate($perPage, ['*'], 'unread-page')->withQueryString(); - // Build a distinct clients list for the filter (client_case UUID + person.full_name) + // Build a distinct clients list for the filter (client UUID + client.person.full_name) // Collect client_case_ids from both direct activities and via contracts $baseForClients = Activity::query() ->select(['contract_id', 'client_case_id']) @@ -114,10 +119,10 @@ public function unread(Request $request) ->where('anr.user_id', $user->id) ->whereColumn('anr.due_date', 'activities.due_date'); }) - ->when($clientCaseId, function ($q) use ($clientCaseId) { - $q->where(function ($qq) use ($clientCaseId) { - $qq->where('activities.client_case_id', $clientCaseId) - ->orWhereIn('activities.contract_id', Contract::query()->select('id')->where('client_case_id', $clientCaseId)); + ->when($clientCaseIdsForFilter->isNotEmpty(), function ($q) use ($clientCaseIdsForFilter) { + $q->where(function ($qq) use ($clientCaseIdsForFilter) { + $qq->whereIn('activities.client_case_id', $clientCaseIdsForFilter) + ->orWhereIn('activities.contract_id', Contract::query()->select('id')->whereIn('client_case_id', $clientCaseIdsForFilter)); }); }) ->get(); @@ -133,16 +138,23 @@ public function unread(Request $request) ->unique() ->values(); - $clients = ClientCase::query() - ->whereIn('id', $caseIds) - ->with(['person:id,full_name']) - ->get(['id', 'uuid', 'person_id']) - ->map(fn ($cc) => [ - 'value' => $cc->uuid, - 'label' => optional($cc->person)->full_name ?: '(neznana stranka)', - ]) - ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE) - ->values(); + // Map caseIds -> clientIds, then load clients and present as value(label) + $clientIds = $caseIds->isNotEmpty() + ? ClientCase::query()->whereIn('id', $caseIds)->pluck('client_id')->filter()->unique()->values() + : collect(); + + $clients = $clientIds->isNotEmpty() + ? Client::query() + ->whereIn('id', $clientIds) + ->with(['person:id,full_name']) + ->get(['id', 'uuid', 'person_id']) + ->map(fn ($c) => [ + 'value' => $c->uuid, + 'label' => optional($c->person)->full_name ?: '(neznana stranka)', + ]) + ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE) + ->values() + : collect(); return Inertia::render('Notifications/Unread', [ 'activities' => $activities, diff --git a/app/Http/Controllers/SmsWebhookController.php b/app/Http/Controllers/SmsWebhookController.php new file mode 100644 index 0000000..deb65b6 --- /dev/null +++ b/app/Http/Controllers/SmsWebhookController.php @@ -0,0 +1,85 @@ +query->has('id')) { + $providerId = (string) ((int) $request->query('id')); + $status = trim(strip_tags((string) $request->query('status', ''))); + + $log = SmsLog::query()->where('provider_message_id', $providerId)->first(); + if ($log) { + $meta = (array) $log->meta; + $meta['delivery_report'] = [ + 'raw_status' => $status, + 'received_at' => now()->toIso8601String(), + ]; + + // Naive mapping: mark delivered for common success statuses + $normalized = strtoupper($status); + if (in_array($normalized, ['DELIVERED', 'DELIVRD', 'OK'], true)) { + $log->status = 'delivered'; + $log->delivered_at = now(); + } elseif (in_array($normalized, ['FAILED', 'UNDELIV', 'UNDELIVERED', 'ERROR'], true)) { + $log->status = 'failed'; + $log->failed_at = now(); + $log->error_code = $normalized; + } + + $log->meta = $meta; + $log->save(); + } else { + Log::warning('sms.webhook.delivery.unknown_id', ['provider_id' => $providerId, 'status' => $status]); + } + + return response()->json(['ok' => true]); + } + + // Reply via POST: smsId, m (message), from, to, time + if ($request->isMethod('post') && $request->post('smsId')) { + $providerId = (string) ((int) $request->post('smsId')); + $msg = trim(strip_tags((string) $request->post('m', ''))); + $fromNumber = (string) $request->post('from', ''); + $toNumber = (string) $request->post('to', ''); + $timestamp = (int) $request->post('time', time()); + + $log = SmsLog::query()->where('provider_message_id', $providerId)->first(); + if ($log) { + $meta = (array) $log->meta; + $replies = isset($meta['replies']) && is_array($meta['replies']) ? $meta['replies'] : []; + $replies[] = [ + 'message' => $msg, + 'from' => $fromNumber, + 'to' => $toNumber, + 'time' => date('c', $timestamp), + ]; + $meta['replies'] = $replies; + $log->meta = $meta; + $log->save(); + } else { + Log::warning('sms.webhook.reply.unknown_id', [ + 'provider_id' => $providerId, + 'from' => $fromNumber, + 'to' => $toNumber, + ]); + } + + return response()->json(['ok' => true]); + } + + // Unknown payload + return response()->json(['ok' => false, 'reason' => 'unsupported payload'], 400); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index cb770d6..563808b 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -139,7 +139,7 @@ public function share(Request $request): array ? \App\Models\Client::query() ->whereIn('clients.id', $clientIds) ->with(['person:id,full_name']) - ->get(['clients.id', 'clients.person_id']) + ->get(['clients.id', 'clients.uuid', 'clients.person_id']) ->keyBy('id') : collect(); diff --git a/app/Http/Requests/StoreSmsProfileRequest.php b/app/Http/Requests/StoreSmsProfileRequest.php new file mode 100644 index 0000000..a70ad0b --- /dev/null +++ b/app/Http/Requests/StoreSmsProfileRequest.php @@ -0,0 +1,23 @@ +user()?->can('manage-settings') ?? false; + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:190'], + 'active' => ['sometimes', 'boolean'], + 'api_username' => ['required', 'string', 'max:190'], + 'api_password' => ['required', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/StoreSmsSenderRequest.php b/app/Http/Requests/StoreSmsSenderRequest.php new file mode 100644 index 0000000..8bcb75d --- /dev/null +++ b/app/Http/Requests/StoreSmsSenderRequest.php @@ -0,0 +1,30 @@ +user()?->can('manage-settings') ?? false; + } + + public function rules(): array + { + $pid = (int) $this->input('profile_id'); + + return [ + 'profile_id' => ['required', 'integer', 'exists:sms_profiles,id'], + 'sname' => [ + 'nullable', 'string', 'max:20', + Rule::unique('sms_senders', 'sname')->where(fn ($q) => $q->where('profile_id', $pid)), + ], + 'phone_number' => ['nullable', 'string', 'max:30'], + 'description' => ['nullable', 'string', 'max:190'], + 'active' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/StoreSmsTemplateRequest.php b/app/Http/Requests/StoreSmsTemplateRequest.php new file mode 100644 index 0000000..f2693e0 --- /dev/null +++ b/app/Http/Requests/StoreSmsTemplateRequest.php @@ -0,0 +1,31 @@ +user()?->can('manage-settings') ?? false; + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:190'], + 'slug' => ['required', 'string', 'max:190', 'alpha_dash', 'unique:sms_templates,slug'], + // Content is required unless template allows custom body + 'content' => [Rule::requiredIf(fn () => ! (bool) $this->input('allow_custom_body')), 'nullable', 'string', 'max:1000'], + 'variables_json' => ['nullable', 'array'], + 'is_active' => ['sometimes', 'boolean'], + 'default_profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'], + 'default_sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'], + 'allow_custom_body' => ['sometimes', 'boolean'], + 'action_id' => ['nullable', 'integer', 'exists:actions,id'], + 'decision_id' => ['nullable', 'integer', 'exists:decisions,id'], + ]; + } +} diff --git a/app/Http/Requests/TestSendSmsRequest.php b/app/Http/Requests/TestSendSmsRequest.php new file mode 100644 index 0000000..0bca557 --- /dev/null +++ b/app/Http/Requests/TestSendSmsRequest.php @@ -0,0 +1,24 @@ +user()?->can('manage-settings') ?? false; + } + + public function rules(): array + { + return [ + 'to' => ['required', 'string', 'max:30'], // E.164-ish; we can refine later + 'message' => ['required', 'string', 'max:1000'], + 'sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'], + 'delivery_report' => ['sometimes', 'boolean'], + 'country_code' => ['nullable', 'string', 'max:5'], + ]; + } +} diff --git a/app/Http/Requests/TestSendSmsTemplateRequest.php b/app/Http/Requests/TestSendSmsTemplateRequest.php new file mode 100644 index 0000000..e3da2ff --- /dev/null +++ b/app/Http/Requests/TestSendSmsTemplateRequest.php @@ -0,0 +1,26 @@ +user()?->can('manage-settings') ?? false; + } + + public function rules(): array + { + return [ + 'to' => ['required', 'string', 'max:30'], + 'variables' => ['nullable', 'array'], + 'profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'], + 'sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'], + 'delivery_report' => ['sometimes', 'boolean'], + 'country_code' => ['nullable', 'string', 'max:5'], + 'custom_content' => ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Http/Requests/UpdateSmsSenderRequest.php b/app/Http/Requests/UpdateSmsSenderRequest.php new file mode 100644 index 0000000..7138cf5 --- /dev/null +++ b/app/Http/Requests/UpdateSmsSenderRequest.php @@ -0,0 +1,33 @@ +user()?->can('manage-settings') ?? false; + } + + public function rules(): array + { + $pid = (int) $this->input('profile_id'); + $id = (int) ($this->route('smsSender')?->id ?? 0); + + return [ + 'profile_id' => ['required', 'integer', 'exists:sms_profiles,id'], + 'sname' => [ + 'nullable', 'string', 'max:20', + Rule::unique('sms_senders', 'sname') + ->ignore($id) + ->where(fn ($q) => $q->where('profile_id', $pid)), + ], + 'phone_number' => ['nullable', 'string', 'max:30'], + 'description' => ['nullable', 'string', 'max:190'], + 'active' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/UpdateSmsTemplateRequest.php b/app/Http/Requests/UpdateSmsTemplateRequest.php new file mode 100644 index 0000000..c653b4b --- /dev/null +++ b/app/Http/Requests/UpdateSmsTemplateRequest.php @@ -0,0 +1,33 @@ +user()?->can('manage-settings') ?? false; + } + + public function rules(): array + { + $id = (int) ($this->route('smsTemplate')?->id ?? 0); + + return [ + 'name' => ['required', 'string', 'max:190'], + 'slug' => ['required', 'string', 'max:190', 'alpha_dash', Rule::unique('sms_templates', 'slug')->ignore($id)], + // Content is required unless template allows custom body + 'content' => [Rule::requiredIf(fn () => ! (bool) $this->input('allow_custom_body')), 'nullable', 'string', 'max:1000'], + 'variables_json' => ['nullable', 'array'], + 'is_active' => ['sometimes', 'boolean'], + 'default_profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'], + 'default_sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'], + 'allow_custom_body' => ['sometimes', 'boolean'], + 'action_id' => ['nullable', 'integer', 'exists:actions,id'], + 'decision_id' => ['nullable', 'integer', 'exists:decisions,id'], + ]; + } +} diff --git a/app/Jobs/SendSmsJob.php b/app/Jobs/SendSmsJob.php new file mode 100644 index 0000000..9bc20ce --- /dev/null +++ b/app/Jobs/SendSmsJob.php @@ -0,0 +1,100 @@ +profileId); + if (! $profile) { + return; // nothing to do + } + + /** @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, + ); + // If invoked from the case UI with a selected template, create an Activity + if ($this->templateId && $this->clientCaseId && $log) { + try { + /** @var SmsTemplate|null $template */ + $template = SmsTemplate::find($this->templateId); + /** @var ClientCase|null $case */ + $case = ClientCase::find($this->clientCaseId); + if ($template && $case) { + $note = ''; + if ($log->status === 'sent') { + $note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content); + } elseif ($log->status === 'failed') { + $note = sprintf( + 'Št: %s | Telo: %s | Napaka: %s', + (string) $this->to, + (string) $this->content, + 'SMS ni bil poslan!' + ); + } + + $case->activities()->create([ + 'note' => $note, + 'action_id' => $template->action_id, + 'decision_id' => $template->decision_id, + 'user_id' => $this->userId, + ]); + } + + } catch (\Throwable $e) { + \Log::warning('SendSmsJob activity creation failed', [ + 'error' => $e->getMessage(), + 'client_case_id' => $this->clientCaseId, + 'template_id' => $this->templateId, + ]); + } + } + + } +} diff --git a/app/Models/SmsLog.php b/app/Models/SmsLog.php new file mode 100644 index 0000000..f14759b --- /dev/null +++ b/app/Models/SmsLog.php @@ -0,0 +1,52 @@ + 'array', + 'queued_at' => 'datetime', + 'sent_at' => 'datetime', + 'delivered_at' => 'datetime', + 'failed_at' => 'datetime', + 'cost' => 'decimal:2', + ]; + + public function profile() + { + return $this->belongsTo(SmsProfile::class, 'profile_id'); + } + + public function template() + { + return $this->belongsTo(SmsTemplate::class, 'template_id'); + } +} diff --git a/app/Models/SmsProfile.php b/app/Models/SmsProfile.php new file mode 100644 index 0000000..3e41d7f --- /dev/null +++ b/app/Models/SmsProfile.php @@ -0,0 +1,56 @@ + 'boolean', + 'settings' => 'array', + ]; + + protected $hidden = [ + 'encrypted_api_password', + ]; + + // Write-only password setter + public function setApiPasswordAttribute(string $plain): void + { + $this->attributes['encrypted_api_password'] = app(\App\Services\MailSecretEncrypter::class)->encrypt($plain); + } + + public function decryptApiPassword(): ?string + { + if (! isset($this->attributes['encrypted_api_password'])) { + return null; + } + + return app(\App\Services\MailSecretEncrypter::class)->decrypt($this->attributes['encrypted_api_password']); + } + + public function senders() + { + return $this->hasMany(SmsSender::class, 'profile_id'); + } + + public function logs() + { + return $this->hasMany(SmsLog::class, 'profile_id'); + } +} diff --git a/app/Models/SmsSender.php b/app/Models/SmsSender.php new file mode 100644 index 0000000..f72c507 --- /dev/null +++ b/app/Models/SmsSender.php @@ -0,0 +1,30 @@ + 'boolean', + ]; + + public function profile() + { + return $this->belongsTo(SmsProfile::class, 'profile_id'); + } +} diff --git a/app/Models/SmsTemplate.php b/app/Models/SmsTemplate.php new file mode 100644 index 0000000..cf37b6b --- /dev/null +++ b/app/Models/SmsTemplate.php @@ -0,0 +1,53 @@ + 'boolean', + 'variables_json' => 'array', + 'allow_custom_body' => 'boolean', + ]; + + public function defaultProfile() + { + return $this->belongsTo(SmsProfile::class, 'default_profile_id'); + } + + public function defaultSender() + { + return $this->belongsTo(SmsSender::class, 'default_sender_id'); + } + + public function action() + { + return $this->belongsTo(Action::class); + } + + public function decision() + { + return $this->belongsTo(Decision::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b6ec3eb..acd89f8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,8 +2,10 @@ namespace App\Providers; -use Illuminate\Support\ServiceProvider; +use App\Services\Sms\SmsApiSiClient; +use App\Services\Sms\SmsClient; use Illuminate\Foundation\Application; +use Illuminate\Support\ServiceProvider; use Inertia\Inertia; class AppServiceProvider extends ServiceProvider @@ -13,11 +15,14 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + // Inertia shared Inertia::share([ 'laravelVersion' => Application::VERSION, - 'phpVersion' => PHP_VERSION + 'phpVersion' => PHP_VERSION, ]); + + // SMS: bind provider client + $this->app->bind(SmsClient::class, SmsApiSiClient::class); } /** diff --git a/app/Services/Sms/SmsApiSiClient.php b/app/Services/Sms/SmsApiSiClient.php new file mode 100644 index 0000000..281dbf2 --- /dev/null +++ b/app/Services/Sms/SmsApiSiClient.php @@ -0,0 +1,202 @@ + $profile->api_username, + 'ps' => $profile->decryptApiPassword() ?? '', + // provider requires phone number in `from` + 'from' => isset($message->senderPhone) && $message->senderPhone !== '' ? urlencode($message->senderPhone) : '', + 'to' => $message->to, + 'm' => $message->content, + ]; + + // Log payload safely (mask password) for debugging; uses context so it will appear on supported channels + $logPayload = $payload; + if (isset($logPayload['ps'])) { + $logPayload['ps'] = '***'; + } + \Log::info('sms.send payload', ['payload' => $logPayload]); + + /*if (! empty($message->sender)) { + $payload['sname'] = $message->sender; + }*/ + // Default country code when not provided + $payload['cc'] = urlencode(! empty($message->countryCode) ? $message->countryCode : '386'); + if ($message->deliveryReport) { + $payload['dr'] = 1; + } + + $response = Http::asForm() + ->timeout($timeout) + ->post($url, $payload); + + $body = trim((string) $response->body()); + $statusCode = $response->status(); + + // Parse according to provider docs: ID##SMS_PRICE##FROM##TO + // Success: 123##0.03##040123456##040654321 + // Failure: -1##ERROR_ID##FROM##TO + $parts = array_values(array_filter(array_map(static fn ($s) => trim((string) $s), explode('##', $body)), static fn ($s) => $s !== '')); + + // Known error codes (Slovenian descriptions from docs) + $errorMap = [ + 1 => 'Napaka v validaciji.', + 2 => 'Sporočilo je predolgo ali prazno.', + 3 => 'Številka pošiljatelja ni pravilno tvorjena ali pa je prazna.', + 4 => 'Številka prejemnika ni pravilno tvorjena ali pa je prazna.', + 5 => 'Uporabnik nima dovolj kreditov.', + 6 => 'Napaka na serverju.', + 7 => 'Številka pošiljatelja ni registrirana.', + 8 => 'Referenca uporabnika ni veljavna.', + 9 => 'Koda države ni veljavna.', + 10 => 'ID pošiljatelja ni potrjen.', + 11 => 'Koda države ni podprta', + 12 => 'Številka pošiljatelja ni potrjena', + 13 => 'Številka oz. država pošiljatelja ne podpira MMS vsebin.', + 14 => 'MMS tip oblike (mime type) ni podprta', + 15 => 'MMS url je nedosegljiv ali datoteka ne obstaja.', + 16 => 'MMS vsebina je predolga', + ]; + + if ($response->successful() && ! empty($parts)) { + // Failure shape + if ($parts[0] === '-1') { + $errorId = isset($parts[1]) ? (int) $parts[1] : null; + $from = $parts[2] ?? null; + $to = $parts[3] ?? null; + + return new SmsResult( + status: 'failed', + providerMessageId: null, + cost: null, + currency: null, + meta: [ + 'status_code' => $statusCode, + 'body' => $body, + 'parts' => $parts, + 'error_id' => $errorId, + 'error_message' => $errorId ? ($errorMap[$errorId] ?? 'Neznana napaka') : 'Neznana napaka', + 'from' => $from, + 'to' => $to, + ] + ); + } + + // Success shape + if (preg_match('/^\d+$/', $parts[0])) { + $providerId = $parts[0]; + $price = null; + if (isset($parts[1])) { + $priceStr = str_replace(',', '.', $parts[1]); + $price = is_numeric($priceStr) ? (float) $priceStr : null; + } + $from = $parts[2] ?? null; + $to = $parts[3] ?? null; + + return new SmsResult( + status: 'sent', + providerMessageId: $providerId, + cost: $price, + currency: null, + meta: [ + 'status_code' => $statusCode, + 'body' => $body, + 'parts' => $parts, + 'from' => $from, + 'to' => $to, + ] + ); + } + } + + // HTTP error or unexpected body + return new SmsResult( + status: 'failed', + providerMessageId: null, + cost: null, + currency: null, + meta: [ + 'status_code' => $statusCode, + 'body' => $body, + 'parts' => $parts, + ] + ); + } + + public function getCreditBalance(SmsProfile $profile): int + { + $baseUrl = config('services.sms.providers.smsapi_si.base_url'); + $endpoint = config('services.sms.providers.smsapi_si.credits_endpoint', '/preveri-stanje-kreditov'); + $timeout = (int) config('services.sms.providers.smsapi_si.timeout', 10); + $url = rtrim($baseUrl, '/').$endpoint; + + $response = Http::asForm() + ->timeout($timeout) + ->post($url, [ + 'un' => $profile->api_username, + 'ps' => $profile->decryptApiPassword() ?? '', + ]); + + if (! $response->successful()) { + \Log::warning('SMS credits request failed', [ + 'status' => $response->status(), + 'url' => $url, + 'username' => $profile->api_username, + 'body' => (string) $response->body(), + ]); + throw new \RuntimeException('Credits endpoint returned HTTP '.$response->status()); + } + + $body = trim((string) $response->body()); + if ($body === '') { + return 0; + } + + // Per provider docs, response is '##' separated: first token contains the balance + $parts = explode('##', $body); + $first = trim($parts[0] ?? $body); + + // Fallback to the raw first token if no number found + return intval($first); + } + + public function getPriceQuotes(SmsProfile $profile): array + { + $baseUrl = config('services.sms.providers.smsapi_si.base_url'); + $endpoint = config('services.sms.providers.smsapi_si.price_endpoint', '/dobi-ceno'); + $timeout = (int) config('services.sms.providers.smsapi_si.timeout', 10); + $url = rtrim($baseUrl, '/').$endpoint; + + $response = Http::asForm() + ->timeout($timeout) + ->post($url, [ + 'un' => $profile->api_username, + 'ps' => $profile->decryptApiPassword() ?? '', + ]); + $body = trim((string) $response->body()); + if ($body === '') { + return []; + } + + // Provider returns '##' separated values; trim and drop empty tokens + $parts = explode('##', $body); + $parts = array_map(static fn ($s) => trim((string) $s), $parts); + $parts = array_values(array_filter($parts, static fn ($s) => $s !== '')); + + return $parts; + } +} diff --git a/app/Services/Sms/SmsClient.php b/app/Services/Sms/SmsClient.php new file mode 100644 index 0000000..2d24627 --- /dev/null +++ b/app/Services/Sms/SmsClient.php @@ -0,0 +1,23 @@ + (string) Str::uuid(), + 'profile_id' => $profile->id, + 'to_number' => /*$to*/'', + 'sender' => $sender?->sname, + 'message' => $content, + 'status' => 'queued', + 'queued_at' => now(), + ]); + $log->save(); + + $result = $this->client->send($profile, new SmsMessage( + to: $to, + content: $content, + sender: $sender?->sname, + senderPhone: /*$sender?->phone_number*/'', + countryCode: $countryCode, + deliveryReport: $deliveryReport, + clientReference: $clientReference, + )); + + if ($result->status === 'sent') { + $log->status = 'sent'; + $log->sent_at = now(); + } else { + $log->status = 'failed'; + $log->failed_at = now(); + } + $log->provider_message_id = $result->providerMessageId; + $log->cost = $result->cost; + $log->currency = $result->currency; + $log->meta = $result->meta; + $log->save(); + + return $log; + }); + } + + /** + * Render an SMS from template and send. + */ + public function sendFromTemplate(SmsTemplate $template, string $to, array $variables = [], ?SmsProfile $profile = null, ?SmsSender $sender = null, ?string $countryCode = null, bool $deliveryReport = false, ?string $clientReference = null): SmsLog + { + $profile = $profile ?: $template->defaultProfile; + if (! $profile) { + throw new \InvalidArgumentException('SMS profile is required to send a message.'); + } + + $sender = $sender ?: $template->defaultSender; + $content = $this->renderContent($template->content, $variables); + + $log = $this->sendRaw($profile, $to, $content, $sender, $countryCode, $deliveryReport, $clientReference); + $log->template_id = $template->id; + $log->save(); + + return $log; + } + + protected 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]; + + return array_key_exists($key, $vars) ? (string) $vars[$key] : $m[0]; + }, $content); + } + + /** + * Get current credit balance from provider. + */ + public function getCreditBalance(SmsProfile $profile): string + { + return $this->client->getCreditBalance($profile); + } + + /** + * Get price quote(s) from provider. + * Returns array of strings as provided by the API. + */ + public function getPriceQuotes(SmsProfile $profile): array + { + return $this->client->getPriceQuotes($profile); + } +} diff --git a/config/services.php b/config/services.php index 27a3617..81b8427 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,21 @@ ], ], + 'sms' => [ + 'providers' => [ + 'smsapi_si' => [ + // Base URL for SMS API provider + 'base_url' => env('SMSAPI_SI_BASE_URL', 'https://www.smsapi.si'), + // Relative endpoint for sending SMS messages + 'send_endpoint' => env('SMSAPI_SI_SEND_ENDPOINT', '/poslji-sms'), + // Endpoint for checking credit balance + 'credits_endpoint' => env('SMSAPI_SI_CREDITS_ENDPOINT', '/preveri-stanje-kreditov'), + // Endpoint for fetching price quotes + 'price_endpoint' => env('SMSAPI_SI_PRICE_ENDPOINT', '/dobi-ceno'), + // HTTP client timeout in seconds + 'timeout' => (int) env('SMSAPI_SI_TIMEOUT', 10), + ], + ], + ], + ]; diff --git a/database/factories/SmsProfileFactory.php b/database/factories/SmsProfileFactory.php new file mode 100644 index 0000000..140befe --- /dev/null +++ b/database/factories/SmsProfileFactory.php @@ -0,0 +1,28 @@ + + */ +class SmsProfileFactory extends Factory +{ + protected $model = SmsProfile::class; + + public function definition(): array + { + return [ + 'uuid' => (string) Str::uuid(), + 'name' => $this->faker->unique()->words(2, true), + 'active' => true, + 'api_username' => $this->faker->userName(), + // use attribute mutator to encrypt + 'api_password' => 'secret-'.Str::random(8), + 'settings' => [], + ]; + } +} diff --git a/database/factories/SmsSenderFactory.php b/database/factories/SmsSenderFactory.php new file mode 100644 index 0000000..01a2b14 --- /dev/null +++ b/database/factories/SmsSenderFactory.php @@ -0,0 +1,25 @@ + + */ +class SmsSenderFactory extends Factory +{ + protected $model = SmsSender::class; + + public function definition(): array + { + return [ + 'profile_id' => SmsProfile::factory(), + 'sname' => strtoupper($this->faker->lexify('SND??')), + 'description' => $this->faker->sentence(4), + 'active' => true, + ]; + } +} diff --git a/database/migrations/2025_10_23_000001_create_sms_profiles_table.php b/database/migrations/2025_10_23_000001_create_sms_profiles_table.php new file mode 100644 index 0000000..f74b387 --- /dev/null +++ b/database/migrations/2025_10_23_000001_create_sms_profiles_table.php @@ -0,0 +1,33 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('name'); + $table->boolean('active')->default(true); + // Credentials + $table->string('api_username'); + $table->text('encrypted_api_password')->nullable(); + // Defaults and settings + $table->unsignedBigInteger('default_sender_id')->nullable(); // no FK to avoid circular dep with sms_senders + $table->json('settings')->nullable(); + $table->timestamps(); + + $table->index(['active']); + $table->index(['name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('sms_profiles'); + } +}; diff --git a/database/migrations/2025_10_23_000002_create_sms_senders_table.php b/database/migrations/2025_10_23_000002_create_sms_senders_table.php new file mode 100644 index 0000000..104da84 --- /dev/null +++ b/database/migrations/2025_10_23_000002_create_sms_senders_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('profile_id')->constrained('sms_profiles')->cascadeOnDelete(); + $table->string('sname'); // provider sender ID + $table->string('description')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + + $table->unique(['profile_id', 'sname']); + $table->index(['active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('sms_senders'); + } +}; diff --git a/database/migrations/2025_10_23_000003_create_sms_templates_table.php b/database/migrations/2025_10_23_000003_create_sms_templates_table.php new file mode 100644 index 0000000..c88d491 --- /dev/null +++ b/database/migrations/2025_10_23_000003_create_sms_templates_table.php @@ -0,0 +1,31 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('content'); + $table->json('variables_json')->nullable(); + $table->boolean('is_active')->default(true); + $table->foreignId('default_profile_id')->nullable()->constrained('sms_profiles')->nullOnDelete(); + $table->foreignId('default_sender_id')->nullable()->constrained('sms_senders')->nullOnDelete(); + $table->timestamps(); + + $table->index(['is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('sms_templates'); + } +}; diff --git a/database/migrations/2025_10_23_000004_create_sms_logs_table.php b/database/migrations/2025_10_23_000004_create_sms_logs_table.php new file mode 100644 index 0000000..d084116 --- /dev/null +++ b/database/migrations/2025_10_23_000004_create_sms_logs_table.php @@ -0,0 +1,42 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('profile_id')->constrained('sms_profiles')->cascadeOnDelete(); + $table->foreignId('template_id')->nullable()->constrained('sms_templates')->nullOnDelete(); + $table->string('to_number'); + $table->string('sender')->nullable(); // sname used when available + $table->text('message'); + $table->enum('status', ['queued', 'sent', 'failed', 'delivered'])->default('queued'); + $table->string('provider_message_id')->nullable(); + $table->string('error_code')->nullable(); + $table->text('error_message')->nullable(); + $table->decimal('cost', 10, 2)->nullable(); + $table->char('currency', 3)->nullable(); + $table->json('meta')->nullable(); + $table->timestamp('queued_at')->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->timestamps(); + + $table->index(['status']); + $table->index(['provider_message_id']); + $table->index(['created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('sms_logs'); + } +}; diff --git a/database/migrations/2025_10_23_000010_add_phone_number_to_sms_senders_table.php b/database/migrations/2025_10_23_000010_add_phone_number_to_sms_senders_table.php new file mode 100644 index 0000000..b608ad2 --- /dev/null +++ b/database/migrations/2025_10_23_000010_add_phone_number_to_sms_senders_table.php @@ -0,0 +1,22 @@ +string('phone_number', 30)->nullable()->after('sname'); + }); + } + + public function down(): void + { + Schema::table('sms_senders', function (Blueprint $table): void { + $table->dropColumn('phone_number'); + }); + } +}; diff --git a/database/migrations/2025_10_23_000011_alter_sms_senders_sname_nullable.php b/database/migrations/2025_10_23_000011_alter_sms_senders_sname_nullable.php new file mode 100644 index 0000000..c773cb8 --- /dev/null +++ b/database/migrations/2025_10_23_000011_alter_sms_senders_sname_nullable.php @@ -0,0 +1,24 @@ +string('sname', 255)->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('sms_senders', function (Blueprint $table): void { + // Revert to not nullable + $table->string('sname', 255)->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2025_10_23_120000_add_custom_body_and_activity_fields_to_sms_templates.php b/database/migrations/2025_10_23_120000_add_custom_body_and_activity_fields_to_sms_templates.php new file mode 100644 index 0000000..15516b9 --- /dev/null +++ b/database/migrations/2025_10_23_120000_add_custom_body_and_activity_fields_to_sms_templates.php @@ -0,0 +1,26 @@ +boolean('allow_custom_body')->default(false)->after('content'); + $table->foreignId('action_id')->nullable()->after('default_sender_id')->constrained('actions')->nullOnDelete(); + $table->foreignId('decision_id')->nullable()->after('action_id')->constrained('decisions')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('sms_templates', function (Blueprint $table) { + $table->dropConstrainedForeignId('decision_id'); + $table->dropConstrainedForeignId('action_id'); + $table->dropColumn('allow_custom_body'); + }); + } +}; diff --git a/resources/js/Components/PersonInfoGrid.vue b/resources/js/Components/PersonInfoGrid.vue index 320060f..af876be 100644 --- a/resources/js/Components/PersonInfoGrid.vue +++ b/resources/js/Components/PersonInfoGrid.vue @@ -3,7 +3,7 @@ import { FwbBadge } from "flowbite-vue"; import { EditIcon, PlusIcon, UserEditIcon, TrashBinIcon } from "@/Utilities/Icons"; import CusTab from "./CusTab.vue"; import CusTabs from "./CusTabs.vue"; -import { provide, ref, watch } from "vue"; +import { provide, ref, watch, computed } from "vue"; import axios from "axios"; import PersonUpdateForm from "./PersonUpdateForm.vue"; import AddressCreateForm from "./AddressCreateForm.vue"; @@ -14,6 +14,8 @@ import EmailUpdateForm from "./EmailUpdateForm.vue"; import TrrCreateForm from "./TrrCreateForm.vue"; import TrrUpdateForm from "./TrrUpdateForm.vue"; import ConfirmDialog from "./ConfirmDialog.vue"; +import DialogModal from "@/Components/DialogModal.vue"; +import { router, usePage } from "@inertiajs/vue3"; const props = defineProps({ person: Object, @@ -32,6 +34,14 @@ const props = defineProps({ phone_types: [], }, }, + // Enable sending SMS buttons (only pass true for ClientCase person context) + enableSms: { type: Boolean, default: false }, + // Required when enableSms=true to scope route correctly + clientCaseUuid: { type: String, default: null }, + // Optional overrides; if not provided, falls back to Inertia page props + smsProfiles: { type: Array, default: () => [] }, + smsSenders: { type: Array, default: () => [] }, + smsTemplates: { type: Array, default: () => [] }, }); const drawerUpdatePerson = ref(false); @@ -187,6 +197,123 @@ const getTRRs = (p) => { if (Array.isArray(p?.bankAccounts)) return p.bankAccounts; return []; }; + +// SMS dialog state and actions (ClientCase person only) +const showSmsDialog = ref(false); +const smsTargetPhone = ref(null); +const smsMessage = ref(""); +const smsSending = ref(false); + +// Page-level props fallback for SMS metadata +const page = usePage(); +// In Inertia Vue 3, page.props is already a reactive object (not a Ref), +// so do NOT access .value here; otherwise, you'll get undefined and empty lists. +const pageProps = computed(() => page?.props ?? {}); +const pageSmsProfiles = computed(() => { + const fromProps = + Array.isArray(props.smsProfiles) && props.smsProfiles.length + ? props.smsProfiles + : null; + return fromProps ?? pageProps.value?.sms_profiles ?? []; +}); +const pageSmsSenders = computed(() => { + const fromProps = + Array.isArray(props.smsSenders) && props.smsSenders.length ? props.smsSenders : null; + return fromProps ?? pageProps.value?.sms_senders ?? []; +}); +const pageSmsTemplates = computed(() => { + const fromProps = + Array.isArray(props.smsTemplates) && props.smsTemplates.length + ? props.smsTemplates + : null; + return fromProps ?? pageProps.value?.sms_templates ?? []; +}); + +// Selections +const selectedProfileId = ref(null); +const selectedSenderId = ref(null); +const deliveryReport = ref(false); +const selectedTemplateId = ref(null); + +const sendersForSelectedProfile = computed(() => { + if (!selectedProfileId.value) return pageSmsSenders.value; + return (pageSmsSenders.value || []).filter( + (s) => s.profile_id === selectedProfileId.value + ); +}); + +watch(selectedProfileId, () => { + // Clear sender selection if it doesn't belong to the chosen profile + if (!selectedSenderId.value) return; + const ok = sendersForSelectedProfile.value.some((s) => s.id === selectedSenderId.value); + if (!ok) selectedSenderId.value = null; +}); + +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; + } +}); + +const openSmsDialog = (phone) => { + if (!props.enableSms || !props.clientCaseUuid) return; + smsTargetPhone.value = phone; + smsMessage.value = ""; + showSmsDialog.value = true; + // Defaults + selectedProfileId.value = + (pageSmsProfiles.value && pageSmsProfiles.value[0]?.id) || null; + // If profile has default sender, try to preselect it + if (selectedProfileId.value) { + const prof = (pageSmsProfiles.value || []).find( + (p) => p.id === selectedProfileId.value + ); + if (prof && prof.default_sender_id) { + selectedSenderId.value = prof.default_sender_id; + } else { + selectedSenderId.value = null; + } + } else { + selectedSenderId.value = null; + } + deliveryReport.value = false; + selectedTemplateId.value = null; +}; +const closeSmsDialog = () => { + showSmsDialog.value = false; + smsTargetPhone.value = null; + smsMessage.value = ""; +}; +const submitSms = () => { + if (!smsTargetPhone.value || !smsMessage.value || !props.clientCaseUuid) { + return; + } + smsSending.value = true; + router.post( + route("clientCase.phone.sms", { + client_case: props.clientCaseUuid, + phone_id: smsTargetPhone.value.id, + }), + { + message: smsMessage.value, + template_id: selectedTemplateId.value, + profile_id: selectedProfileId.value, + sender_id: selectedSenderId.value, + delivery_report: !!deliveryReport.value, + }, + { + preserveScroll: true, + onFinish: () => { + smsSending.value = false; + closeSmsDialog(); + }, + } + ); +}; diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue index 9d42617..b79ff1a 100644 --- a/resources/js/Layouts/AdminLayout.vue +++ b/resources/js/Layouts/AdminLayout.vue @@ -15,6 +15,8 @@ import { faAt, faInbox, faFileLines, + faMessage, + faAddressBook, } from "@fortawesome/free-solid-svg-icons"; import Dropdown from "@/Components/Dropdown.vue"; import DropdownLink from "@/Components/DropdownLink.vue"; @@ -134,6 +136,44 @@ const navGroups = computed(() => [ }, ], }, + { + key: "sms", + label: "SMS", + items: [ + { + key: "admin.sms-templates.index", + label: "SMS predloge", + route: "admin.sms-templates.index", + icon: faFileLines, + active: [ + "admin.sms-templates.index", + "admin.sms-templates.create", + "admin.sms-templates.edit", + ], + }, + { + key: "admin.sms-logs.index", + label: "SMS dnevniki", + route: "admin.sms-logs.index", + icon: faInbox, + active: ["admin.sms-logs.index", "admin.sms-logs.show"], + }, + { + key: "admin.sms-senders.index", + label: "SMS pošiljatelji", + route: "admin.sms-senders.index", + icon: faAddressBook, + active: ["admin.sms-senders.index"], + }, + { + key: "admin.sms-profiles.index", + label: "SMS profili", + route: "admin.sms-profiles.index", + icon: faGears, + active: ["admin.sms-profiles.index"], + }, + ], + }, ]); function isActive(patterns) { diff --git a/resources/js/Pages/Admin/Index.vue b/resources/js/Pages/Admin/Index.vue index 34558ff..f9f4d5c 100644 --- a/resources/js/Pages/Admin/Index.vue +++ b/resources/js/Pages/Admin/Index.vue @@ -2,7 +2,7 @@ import AdminLayout from '@/Layouts/AdminLayout.vue' import { Link } from '@inertiajs/vue3' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' -import { faUserGroup, faKey, faGears, faFileWord, faEnvelopeOpenText, faInbox, faAt } from '@fortawesome/free-solid-svg-icons' +import { faUserGroup, faKey, faGears, faFileWord, faEnvelopeOpenText, faInbox, faAt, faAddressBook, faFileLines } from '@fortawesome/free-solid-svg-icons' const cards = [ { @@ -62,6 +62,35 @@ const cards = [ }, ], }, + { + category: 'Komunikacije', + items: [ + { + title: 'SMS profili', + description: 'Nastavitve SMS profilov, testno pošiljanje in stanje kreditov', + route: 'admin.sms-profiles.index', + icon: faGears, + }, + { + title: 'SMS pošiljatelji', + description: 'Upravljanje nazivov pošiljateljev (Sender ID) za SMS profile', + route: 'admin.sms-senders.index', + icon: faAddressBook, + }, + { + title: 'SMS predloge', + description: 'Tekstovne predloge za SMS obvestila in opomnike', + route: 'admin.sms-templates.index', + icon: faFileLines, + }, + { + title: 'SMS dnevniki', + description: 'Pregled poslanih SMSov in statusov', + route: 'admin.sms-logs.index', + icon: faInbox, + }, + ], + }, ] diff --git a/resources/js/Pages/Admin/SmsLogs/Index.vue b/resources/js/Pages/Admin/SmsLogs/Index.vue new file mode 100644 index 0000000..9880fe0 --- /dev/null +++ b/resources/js/Pages/Admin/SmsLogs/Index.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/resources/js/Pages/Admin/SmsLogs/Show.vue b/resources/js/Pages/Admin/SmsLogs/Show.vue new file mode 100644 index 0000000..1179d03 --- /dev/null +++ b/resources/js/Pages/Admin/SmsLogs/Show.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/resources/js/Pages/Admin/SmsProfiles/Index.vue b/resources/js/Pages/Admin/SmsProfiles/Index.vue new file mode 100644 index 0000000..fcd6f0a --- /dev/null +++ b/resources/js/Pages/Admin/SmsProfiles/Index.vue @@ -0,0 +1,292 @@ + + + + + diff --git a/resources/js/Pages/Admin/SmsSenders/Index.vue b/resources/js/Pages/Admin/SmsSenders/Index.vue new file mode 100644 index 0000000..fe5af4b --- /dev/null +++ b/resources/js/Pages/Admin/SmsSenders/Index.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/resources/js/Pages/Admin/SmsTemplates/Edit.vue b/resources/js/Pages/Admin/SmsTemplates/Edit.vue new file mode 100644 index 0000000..231bf7c --- /dev/null +++ b/resources/js/Pages/Admin/SmsTemplates/Edit.vue @@ -0,0 +1,263 @@ + + + + + diff --git a/resources/js/Pages/Admin/SmsTemplates/Index.vue b/resources/js/Pages/Admin/SmsTemplates/Index.vue new file mode 100644 index 0000000..8fa29f3 --- /dev/null +++ b/resources/js/Pages/Admin/SmsTemplates/Index.vue @@ -0,0 +1,332 @@ + + + + + diff --git a/resources/js/Pages/Cases/Show.vue b/resources/js/Pages/Cases/Show.vue index 5126a8d..28eea8b 100644 --- a/resources/js/Pages/Cases/Show.vue +++ b/resources/js/Pages/Cases/Show.vue @@ -274,6 +274,8 @@ const submitAttachSegment = () => { :types="types" tab-color="red-600" :person="client_case.person" + :enable-sms="true" + :client-case-uuid="client_case.uuid" /> diff --git a/resources/js/Pages/Dashboard.vue b/resources/js/Pages/Dashboard.vue index 8413c85..b0e68d4 100644 --- a/resources/js/Pages/Dashboard.vue +++ b/resources/js/Pages/Dashboard.vue @@ -22,6 +22,7 @@ const props = defineProps({ fieldJobsAssignedToday: Array, importsInProgress: Array, activeTemplates: Array, + smsStats: Array, }); const kpiDefs = [ @@ -318,6 +319,63 @@ function safeCaseHref(uuid, segment = null) {
+ +
+

+ SMS stanje +

+
+ + + + + + + + + + + + + + + + + + + + + +
ProfilBilancaDanes (skupaj)SentDeliveredFailed
+ {{ + p.name + }} + {{ p.active ? "Aktiven" : "Neaktiven" }} + + {{ p.balance ?? "—" }} + {{ p.today?.total ?? 0 }}{{ p.today?.sent ?? 0 }} + {{ p.today?.delivered ?? 0 }} + {{ p.today?.failed ?? 0 }}
+
+
+ Ni podatkov o SMS. +
+
+
{ ? props.clients : (Array.isArray(props.activities?.data) ? props.activities.data : []) .map((row) => { - const cc = row.contract?.client_case || row.client_case; - return cc ? { value: cc.uuid, label: cc.person?.full_name || "(neznana stranka)" } : null; + const client = row.contract?.client_case?.client || row.client_case?.client; + if (!client?.uuid) return null; + return { value: client.uuid, label: client.person?.full_name || "(neznana stranka)" }; }) .filter(Boolean) .reduce((acc, cur) => { @@ -174,8 +175,8 @@ async function markRead(id) {