SMS service
This commit is contained in:
parent
3a2eed7dda
commit
930ac83604
82
app/Http/Controllers/Admin/SmsLogController.php
Normal file
82
app/Http/Controllers/Admin/SmsLogController.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SmsLog;
|
||||
use App\Models\SmsProfile;
|
||||
use App\Models\SmsTemplate;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class SmsLogController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = SmsLog::query()->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
108
app/Http/Controllers/Admin/SmsProfileController.php
Normal file
108
app/Http/Controllers/Admin/SmsProfileController.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreSmsProfileRequest;
|
||||
use App\Http\Requests\TestSendSmsRequest;
|
||||
use App\Jobs\SendSmsJob;
|
||||
use App\Models\SmsProfile;
|
||||
use App\Models\SmsSender;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class SmsProfileController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$profiles = SmsProfile::query()->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]);
|
||||
}
|
||||
}
|
||||
88
app/Http/Controllers/Admin/SmsSenderController.php
Normal file
88
app/Http/Controllers/Admin/SmsSenderController.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreSmsSenderRequest;
|
||||
use App\Http\Requests\UpdateSmsSenderRequest;
|
||||
use App\Models\SmsProfile;
|
||||
use App\Models\SmsSender;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class SmsSenderController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$senders = SmsSender::query()
|
||||
->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.');
|
||||
}
|
||||
}
|
||||
199
app/Http/Controllers/Admin/SmsTemplateController.php
Normal file
199
app/Http/Controllers/Admin/SmsTemplateController.php
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreSmsTemplateRequest;
|
||||
use App\Http\Requests\TestSendSmsTemplateRequest;
|
||||
use App\Http\Requests\UpdateSmsTemplateRequest;
|
||||
use App\Models\SmsProfile;
|
||||
use App\Models\SmsSender;
|
||||
use App\Models\SmsTemplate;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class SmsTemplateController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$templates = SmsTemplate::query()
|
||||
->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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
85
app/Http/Controllers/SmsWebhookController.php
Normal file
85
app/Http/Controllers/SmsWebhookController.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\SmsLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SmsWebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle smsapi.si delivery reports (GET) and replies (POST).
|
||||
* This endpoint accepts both methods as the provider may use either.
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
// Delivery report via GET: id (int) and status (string)
|
||||
if ($request->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
23
app/Http/Requests/StoreSmsProfileRequest.php
Normal file
23
app/Http/Requests/StoreSmsProfileRequest.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreSmsProfileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/StoreSmsSenderRequest.php
Normal file
30
app/Http/Requests/StoreSmsSenderRequest.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreSmsSenderRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/StoreSmsTemplateRequest.php
Normal file
31
app/Http/Requests/StoreSmsTemplateRequest.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreSmsTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/TestSendSmsRequest.php
Normal file
24
app/Http/Requests/TestSendSmsRequest.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TestSendSmsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/TestSendSmsTemplateRequest.php
Normal file
26
app/Http/Requests/TestSendSmsTemplateRequest.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TestSendSmsTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
33
app/Http/Requests/UpdateSmsSenderRequest.php
Normal file
33
app/Http/Requests/UpdateSmsSenderRequest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateSmsSenderRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
33
app/Http/Requests/UpdateSmsTemplateRequest.php
Normal file
33
app/Http/Requests/UpdateSmsTemplateRequest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateSmsTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
100
app/Jobs/SendSmsJob.php
Normal file
100
app/Jobs/SendSmsJob.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\SmsProfile;
|
||||
use App\Models\SmsSender;
|
||||
use App\Models\SmsTemplate;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SendSmsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $profileId,
|
||||
public string $to,
|
||||
public string $content,
|
||||
public ?int $senderId = null,
|
||||
public ?string $countryCode = null,
|
||||
public bool $deliveryReport = false,
|
||||
public ?string $clientReference = null,
|
||||
// For optional activity creation (case UI only)
|
||||
public ?int $templateId = null,
|
||||
public ?int $clientCaseId = null,
|
||||
public ?int $userId = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(SmsService $sms): void
|
||||
{
|
||||
// Resolve models
|
||||
/** @var SmsProfile|null $profile */
|
||||
$profile = SmsProfile::find($this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
52
app/Models/SmsLog.php
Normal file
52
app/Models/SmsLog.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SmsLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'sms_logs';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'profile_id',
|
||||
'template_id',
|
||||
'to_number',
|
||||
'sender',
|
||||
'message',
|
||||
'status',
|
||||
'provider_message_id',
|
||||
'error_code',
|
||||
'error_message',
|
||||
'cost',
|
||||
'currency',
|
||||
'meta',
|
||||
'queued_at',
|
||||
'sent_at',
|
||||
'delivered_at',
|
||||
'failed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'meta' => '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');
|
||||
}
|
||||
}
|
||||
56
app/Models/SmsProfile.php
Normal file
56
app/Models/SmsProfile.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SmsProfile extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'sms_profiles';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'active',
|
||||
'api_username',
|
||||
'default_sender_id',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => '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');
|
||||
}
|
||||
}
|
||||
30
app/Models/SmsSender.php
Normal file
30
app/Models/SmsSender.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SmsSender extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'sms_senders';
|
||||
|
||||
protected $fillable = [
|
||||
'profile_id',
|
||||
'sname',
|
||||
'phone_number',
|
||||
'description',
|
||||
'active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
];
|
||||
|
||||
public function profile()
|
||||
{
|
||||
return $this->belongsTo(SmsProfile::class, 'profile_id');
|
||||
}
|
||||
}
|
||||
53
app/Models/SmsTemplate.php
Normal file
53
app/Models/SmsTemplate.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SmsTemplate extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'sms_templates';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'slug',
|
||||
'content',
|
||||
'variables_json',
|
||||
'is_active',
|
||||
'default_profile_id',
|
||||
'default_sender_id',
|
||||
'allow_custom_body',
|
||||
'action_id',
|
||||
'decision_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => '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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
202
app/Services/Sms/SmsApiSiClient.php
Normal file
202
app/Services/Sms/SmsApiSiClient.php
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Sms;
|
||||
|
||||
use App\Models\SmsProfile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SmsApiSiClient implements SmsClient
|
||||
{
|
||||
public function send(SmsProfile $profile, SmsMessage $message): SmsResult
|
||||
{
|
||||
$baseUrl = config('services.sms.providers.smsapi_si.base_url');
|
||||
$endpoint = config('services.sms.providers.smsapi_si.send_endpoint', '/poslji-sms');
|
||||
$timeout = (int) config('services.sms.providers.smsapi_si.timeout', 10);
|
||||
|
||||
$url = rtrim($baseUrl, '/').$endpoint;
|
||||
|
||||
$payload = [
|
||||
'un' => $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;
|
||||
}
|
||||
}
|
||||
23
app/Services/Sms/SmsClient.php
Normal file
23
app/Services/Sms/SmsClient.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Sms;
|
||||
|
||||
use App\Models\SmsProfile;
|
||||
|
||||
interface SmsClient
|
||||
{
|
||||
/**
|
||||
* Sends an SMS message using the given profile credentials.
|
||||
*/
|
||||
public function send(SmsProfile $profile, SmsMessage $message): SmsResult;
|
||||
|
||||
/**
|
||||
* Returns current credit balance as an integer (normalized provider value).
|
||||
*/
|
||||
public function getCreditBalance(SmsProfile $profile): int;
|
||||
|
||||
/**
|
||||
* Returns price quote(s) as an array of strings (provider can return multiple separated by ##).
|
||||
*/
|
||||
public function getPriceQuotes(SmsProfile $profile): array;
|
||||
}
|
||||
16
app/Services/Sms/SmsMessage.php
Normal file
16
app/Services/Sms/SmsMessage.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Sms;
|
||||
|
||||
class SmsMessage
|
||||
{
|
||||
public function __construct(
|
||||
public string $to,
|
||||
public string $content,
|
||||
public ?string $sender = null, // provider sname
|
||||
public ?string $senderPhone = null, // phone number for 'from'
|
||||
public ?string $countryCode = null, // cc param, optional
|
||||
public bool $deliveryReport = false, // dr=1 when true
|
||||
public ?string $clientReference = null,
|
||||
) {}
|
||||
}
|
||||
14
app/Services/Sms/SmsResult.php
Normal file
14
app/Services/Sms/SmsResult.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Sms;
|
||||
|
||||
class SmsResult
|
||||
{
|
||||
public function __construct(
|
||||
public string $status, // sent|failed
|
||||
public ?string $providerMessageId = null,
|
||||
public ?float $cost = null,
|
||||
public ?string $currency = null,
|
||||
public array $meta = [],
|
||||
) {}
|
||||
}
|
||||
108
app/Services/Sms/SmsService.php
Normal file
108
app/Services/Sms/SmsService.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Sms;
|
||||
|
||||
use App\Models\SmsLog;
|
||||
use App\Models\SmsProfile;
|
||||
use App\Models\SmsSender;
|
||||
use App\Models\SmsTemplate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SmsService
|
||||
{
|
||||
public function __construct(
|
||||
protected SmsClient $client,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send a raw text message.
|
||||
*/
|
||||
public function sendRaw(SmsProfile $profile, string $to, string $content, ?SmsSender $sender = null, ?string $countryCode = null, bool $deliveryReport = false, ?string $clientReference = null): SmsLog
|
||||
{
|
||||
return DB::transaction(function () use ($profile, $to, $content, $sender, $countryCode, $deliveryReport, $clientReference): SmsLog {
|
||||
$log = new SmsLog([
|
||||
'uuid' => (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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
28
database/factories/SmsProfileFactory.php
Normal file
28
database/factories/SmsProfileFactory.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SmsProfile;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<SmsProfile>
|
||||
*/
|
||||
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' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
database/factories/SmsSenderFactory.php
Normal file
25
database/factories/SmsSenderFactory.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SmsProfile;
|
||||
use App\Models\SmsSender;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<SmsSender>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sms_profiles', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sms_senders', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sms_templates', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sms_logs', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('sms_senders', function (Blueprint $table): void {
|
||||
$table->string('phone_number', 30)->nullable()->after('sname');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('sms_senders', function (Blueprint $table): void {
|
||||
$table->dropColumn('phone_number');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('sms_senders', function (Blueprint $table): void {
|
||||
// Make Sender ID nullable
|
||||
$table->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('sms_templates', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -303,9 +430,20 @@ const getTRRs = (p) => {
|
|||
<div class="text-sm leading-5 md:text-sm text-gray-500 flex justify-between">
|
||||
<div class="flex">
|
||||
<FwbBadge title type="yellow">+{{ phone.country_code }}</FwbBadge>
|
||||
<FwbBadge>{{ phone.type.name }}</FwbBadge>
|
||||
<FwbBadge>{{
|
||||
phone && phone.type && phone.type.name ? phone.type.name : "—"
|
||||
}}</FwbBadge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Send SMS only in ClientCase person context -->
|
||||
<button
|
||||
v-if="enableSms && clientCaseUuid"
|
||||
@click="openSmsDialog(phone)"
|
||||
title="Pošlji SMS"
|
||||
class="text-indigo-600 hover:text-indigo-800 text-xs border border-indigo-200 px-2 py-0.5 rounded"
|
||||
>
|
||||
SMS
|
||||
</button>
|
||||
<button>
|
||||
<EditIcon
|
||||
@click="operDrawerAddPhone(true, phone.id)"
|
||||
|
|
@ -510,4 +648,89 @@ const getTRRs = (p) => {
|
|||
@close="closeConfirm"
|
||||
@confirm="onConfirmDelete"
|
||||
/>
|
||||
|
||||
<!-- Send SMS dialog -->
|
||||
<DialogModal :show="showSmsDialog" @close="closeSmsDialog">
|
||||
<template #title>Pošlji SMS</template>
|
||||
<template #content>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-gray-600">
|
||||
Prejemnik: <span class="font-mono">{{ smsTargetPhone?.nu }}</span>
|
||||
<span v-if="smsTargetPhone?.country_code" class="ml-2 text-xs text-gray-500"
|
||||
>CC +{{ smsTargetPhone.country_code }}</span
|
||||
>
|
||||
</p>
|
||||
<!-- Profile & Sender selectors -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Profil</label>
|
||||
<select
|
||||
v-model="selectedProfileId"
|
||||
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
|
||||
>
|
||||
<option :value="null">—</option>
|
||||
<option v-for="p in pageSmsProfiles" :key="p.id" :value="p.id">
|
||||
{{ p.name || "Profil #" + p.id }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Pošiljatelj</label>
|
||||
<select
|
||||
v-model="selectedSenderId"
|
||||
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
|
||||
>
|
||||
<option :value="null">—</option>
|
||||
<option v-for="s in sendersForSelectedProfile" :key="s.id" :value="s.id">
|
||||
{{ s.name || s.phone || "Sender #" + s.id }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template selector -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Predloga</label>
|
||||
<select
|
||||
v-model="selectedTemplateId"
|
||||
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
|
||||
>
|
||||
<option :value="null">—</option>
|
||||
<option v-for="t in pageSmsTemplates" :key="t.id" :value="t.id">
|
||||
{{ t.name || "Predloga #" + t.id }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="block text-sm font-medium text-gray-700">Vsebina sporočila</label>
|
||||
<textarea
|
||||
v-model="smsMessage"
|
||||
rows="4"
|
||||
class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
|
||||
placeholder="Vpišite SMS vsebino..."
|
||||
></textarea>
|
||||
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 mt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="deliveryReport"
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
Zahtevaj poročilo o dostavi
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button class="px-3 py-1 rounded border mr-2" @click="closeSmsDialog">
|
||||
Prekliči
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1 rounded bg-indigo-600 text-white disabled:opacity-50"
|
||||
:disabled="smsSending || !smsMessage"
|
||||
@click="submitSms"
|
||||
>
|
||||
Pošlji
|
||||
</button>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
|
|
|
|||
164
resources/js/Pages/Admin/SmsLogs/Index.vue
Normal file
164
resources/js/Pages/Admin/SmsLogs/Index.vue
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Head, Link, router } from "@inertiajs/vue3";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
logs: { type: Object, required: true },
|
||||
profiles: { type: Array, default: () => [] },
|
||||
templates: { type: Array, default: () => [] },
|
||||
filters: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const f = ref({
|
||||
status: props.filters.status ?? "",
|
||||
profile_id: props.filters.profile_id ?? "",
|
||||
template_id: props.filters.template_id ?? "",
|
||||
search: props.filters.search ?? "",
|
||||
from: props.filters.from ?? "",
|
||||
to: props.filters.to ?? "",
|
||||
});
|
||||
|
||||
function reload() {
|
||||
const query = Object.fromEntries(
|
||||
Object.entries(f.value).filter(([_, v]) => v !== null && v !== undefined && v !== "")
|
||||
);
|
||||
router.get(route("admin.sms-logs.index"), query, { preserveScroll: true, preserveState: true });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [f.value.status, f.value.profile_id, f.value.template_id, f.value.from, f.value.to],
|
||||
() => reload()
|
||||
);
|
||||
|
||||
function clearFilters() {
|
||||
f.value = { status: "", profile_id: "", template_id: "", search: "", from: "", to: "" };
|
||||
reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout title="SMS dnevniki">
|
||||
<Head title="SMS dnevniki" />
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-semibold text-gray-800">SMS dnevniki</h1>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border bg-white p-4 shadow-sm mb-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||
<div>
|
||||
<label class="label">Status</label>
|
||||
<select v-model="f.status" class="input">
|
||||
<option value="">Vsi</option>
|
||||
<option value="queued">queued</option>
|
||||
<option value="sent">sent</option>
|
||||
<option value="delivered">delivered</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Profil</label>
|
||||
<select v-model="f.profile_id" class="input">
|
||||
<option value="">Vsi</option>
|
||||
<option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Predloga</label>
|
||||
<select v-model="f.template_id" class="input">
|
||||
<option value="">Vse</option>
|
||||
<option v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Od</label>
|
||||
<input type="date" v-model="f.from" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Do</label>
|
||||
<input type="date" v-model="f.to" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Iskanje</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="f.search"
|
||||
class="input"
|
||||
placeholder="to, sender, provider id, message"
|
||||
@keyup.enter="reload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button type="button" class="px-3 py-1.5 rounded border text-sm bg-gray-50 hover:bg-gray-100" @click="reload">Filtriraj</button>
|
||||
<button type="button" class="px-3 py-1.5 rounded border text-sm bg-white hover:bg-gray-50" @click="clearFilters">Počisti</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border bg-white overflow-hidden shadow-sm">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Čas</th>
|
||||
<th class="px-3 py-2 text-left">Prejemnik</th>
|
||||
<th class="px-3 py-2 text-left">Sender</th>
|
||||
<th class="px-3 py-2 text-left">Profil</th>
|
||||
<th class="px-3 py-2 text-left">Predloga</th>
|
||||
<th class="px-3 py-2 text-left">Status</th>
|
||||
<th class="px-3 py-2 text-left">Cena</th>
|
||||
<th class="px-3 py-2 text-left">Provider ID</th>
|
||||
<th class="px-3 py-2"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in logs.data" :key="log.id" class="border-t last:border-b hover:bg-gray-50">
|
||||
<td class="px-3 py-2">{{ new Date(log.created_at).toLocaleString() }}</td>
|
||||
<td class="px-3 py-2">{{ log.to_number }}</td>
|
||||
<td class="px-3 py-2">{{ log.sender || '—' }}</td>
|
||||
<td class="px-3 py-2">{{ log.profile?.name || '—' }}</td>
|
||||
<td class="px-3 py-2">{{ log.template?.slug || log.template?.name || '—' }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<span :class="{
|
||||
'text-amber-600': log.status === 'queued',
|
||||
'text-sky-700': log.status === 'sent',
|
||||
'text-emerald-700': log.status === 'delivered',
|
||||
'text-rose-700': log.status === 'failed',
|
||||
}">{{ log.status }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2">{{ log.cost != null ? (Number(log.cost).toFixed(2) + ' ' + (log.currency || '')) : '—' }}</td>
|
||||
<td class="px-3 py-2">{{ log.provider_message_id || '—' }}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<Link :href="route('admin.sms-logs.show', log.id)" class="text-xs px-2 py-1 rounded border text-gray-700 bg-gray-50 hover:bg-gray-100">Ogled</Link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!logs.data || logs.data.length === 0">
|
||||
<td colspan="9" class="px-3 py-6 text-center text-sm text-gray-500">Ni vnosov.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="px-3 py-2 border-t flex items-center justify-between text-xs text-gray-600">
|
||||
<div>
|
||||
Prikaz {{ logs.from || 0 }}–{{ logs.to || 0 }} od {{ logs.total || 0 }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Link
|
||||
v-for="l in logs.links"
|
||||
:key="l.label + l.url"
|
||||
:href="l.url || '#'"
|
||||
v-html="l.label"
|
||||
class="px-2 py-1 rounded border"
|
||||
:class="[l.active ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white hover:bg-gray-50']"
|
||||
preserve-scroll
|
||||
preserve-state
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input { width: 100%; border-radius: 0.375rem; border: 1px solid #d1d5db; padding: 0.5rem 0.75rem; font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.input:focus { outline: 2px solid transparent; outline-offset: 2px; border-color: #6366f1; box-shadow: 0 0 0 1px #6366f1; }
|
||||
.label { display: block; font-size: 0.65rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: #6b7280; margin-bottom: 0.25rem; }
|
||||
</style>
|
||||
80
resources/js/Pages/Admin/SmsLogs/Show.vue
Normal file
80
resources/js/Pages/Admin/SmsLogs/Show.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Head, Link } from "@inertiajs/vue3";
|
||||
|
||||
const props = defineProps({ log: { type: Object, required: true } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout title="SMS log">
|
||||
<Head title="SMS log" />
|
||||
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Link :href="route('admin.sms-logs.index')" class="text-sm text-indigo-600 hover:underline">← Nazaj na dnevnike</Link>
|
||||
<div class="text-gray-700 text-sm">#{{ log.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<div class="rounded-lg border bg-white p-4 shadow-sm">
|
||||
<div class="font-semibold text-gray-800 mb-2">Sporočilo</div>
|
||||
<pre class="text-sm whitespace-pre-wrap">{{ log.message }}</pre>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-white p-4 shadow-sm">
|
||||
<div class="font-semibold text-gray-800 mb-2">Meta</div>
|
||||
<pre class="text-xs whitespace-pre-wrap">{{ JSON.stringify(log.meta || {}, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border bg-white p-4 shadow-sm">
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<div class="text-gray-500">Prejemnik</div>
|
||||
<div class="text-gray-800">{{ log.to_number }}</div>
|
||||
|
||||
<div class="text-gray-500">Sender</div>
|
||||
<div class="text-gray-800">{{ log.sender || '—' }}</div>
|
||||
|
||||
<div class="text-gray-500">Profil</div>
|
||||
<div class="text-gray-800">{{ log.profile?.name || '—' }}</div>
|
||||
|
||||
<div class="text-gray-500">Predloga</div>
|
||||
<div class="text-gray-800">{{ log.template?.slug || log.template?.name || '—' }}</div>
|
||||
|
||||
<div class="text-gray-500">Status</div>
|
||||
<div class="text-gray-800">{{ log.status }}</div>
|
||||
|
||||
<div class="text-gray-500">Cena</div>
|
||||
<div class="text-gray-800">{{ log.cost != null ? (Number(log.cost).toFixed(2) + ' ' + (log.currency || '')) : '—' }}</div>
|
||||
|
||||
<div class="text-gray-500">Provider ID</div>
|
||||
<div class="text-gray-800">{{ log.provider_message_id || '—' }}</div>
|
||||
|
||||
<div class="text-gray-500">Čas</div>
|
||||
<div class="text-gray-800">{{ new Date(log.created_at).toLocaleString() }}</div>
|
||||
|
||||
<div class="text-gray-500">Sent</div>
|
||||
<div class="text-gray-800">{{ log.sent_at ? new Date(log.sent_at).toLocaleString() : '—' }}</div>
|
||||
|
||||
<div class="text-gray-500">Delivered</div>
|
||||
<div class="text-gray-800">{{ log.delivered_at ? new Date(log.delivered_at).toLocaleString() : '—' }}</div>
|
||||
|
||||
<div class="text-gray-500">Failed</div>
|
||||
<div class="text-gray-800">{{ log.failed_at ? new Date(log.failed_at).toLocaleString() : '—' }}</div>
|
||||
|
||||
<div class="text-gray-500">Napaka (koda)</div>
|
||||
<div class="text-gray-800">{{ log.error_code || '—' }}</div>
|
||||
|
||||
<div class="text-gray-500">Napaka (opis)</div>
|
||||
<div class="text-gray-800">{{ log.error_message || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.label { display: block; font-size: 0.65rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: #6b7280; margin-bottom: 0.25rem; }
|
||||
</style>
|
||||
292
resources/js/Pages/Admin/SmsProfiles/Index.vue
Normal file
292
resources/js/Pages/Admin/SmsProfiles/Index.vue
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import { Head, useForm, router } from "@inertiajs/vue3";
|
||||
import { ref, watch } from "vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faPlus, faPaperPlane, faCoins, faTags, faFlask } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const props = defineProps({
|
||||
initialProfiles: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const profiles = ref(props.initialProfiles || []);
|
||||
|
||||
// Keep local ref in sync with Inertia prop changes (after router navigations)
|
||||
watch(
|
||||
() => props.initialProfiles,
|
||||
(val) => {
|
||||
profiles.value = val || [];
|
||||
}
|
||||
);
|
||||
|
||||
// Create profile modal
|
||||
const createOpen = ref(false);
|
||||
const createForm = useForm({
|
||||
name: "",
|
||||
active: true,
|
||||
api_username: "",
|
||||
api_password: "",
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
createForm.reset();
|
||||
createForm.active = true;
|
||||
createOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
try {
|
||||
createForm.processing = true;
|
||||
const payload = {
|
||||
name: createForm.name,
|
||||
active: !!createForm.active,
|
||||
api_username: createForm.api_username,
|
||||
api_password: createForm.api_password,
|
||||
};
|
||||
await router.post(route("admin.sms-profiles.store"), payload, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
createOpen.value = false;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
createForm.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Test send modal
|
||||
const testOpen = ref(false);
|
||||
const testTarget = ref(null);
|
||||
const testResult = ref(null);
|
||||
const testForm = useForm({
|
||||
to: "",
|
||||
message: "",
|
||||
sender_id: null,
|
||||
delivery_report: true,
|
||||
country_code: null,
|
||||
});
|
||||
|
||||
function openTest(p) {
|
||||
testForm.reset();
|
||||
testTarget.value = p;
|
||||
testResult.value = null;
|
||||
testOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
if (!testTarget.value) return;
|
||||
try {
|
||||
testForm.processing = true;
|
||||
const payload = {
|
||||
to: testForm.to,
|
||||
message: testForm.message,
|
||||
sender_id: testForm.sender_id,
|
||||
delivery_report: !!testForm.delivery_report,
|
||||
country_code: testForm.country_code,
|
||||
};
|
||||
await router.post(route("admin.sms-profiles.test-send", testTarget.value.id), payload, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
testResult.value = null;
|
||||
testOpen.value = false;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
testForm.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Balance & Price
|
||||
const balances = ref({});
|
||||
const quotes = ref({});
|
||||
|
||||
function fetchBalance(p) {
|
||||
window.axios.post(route("admin.sms-profiles.balance", p.id)).then((r) => {
|
||||
balances.value[p.id] = r.data.balance;
|
||||
});
|
||||
}
|
||||
|
||||
function fetchPrice(p) {
|
||||
window.axios.post(route("admin.sms-profiles.price", p.id)).then((r) => {
|
||||
quotes.value[p.id] = r.data.quotes || [];
|
||||
});
|
||||
}
|
||||
|
||||
const formatDateTime = (s) => (s ? new Date(s).toLocaleString() : "—");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout title="SMS profili">
|
||||
<Head title="SMS profili" />
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-semibold text-gray-800 flex items-center gap-3">
|
||||
SMS profili
|
||||
<span class="text-xs font-medium text-gray-400">({{ profiles.length }})</span>
|
||||
</h1>
|
||||
<button
|
||||
@click="openCreate"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-500 shadow"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPlus" class="w-4 h-4" /> Nov profil
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border bg-white overflow-hidden shadow-sm">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Ime</th>
|
||||
<th class="px-3 py-2 text-left">Uporabnik</th>
|
||||
<th class="px-3 py-2">Aktiven</th>
|
||||
<th class="px-3 py-2">Pošiljatelji</th>
|
||||
<th class="px-3 py-2">Bilanca</th>
|
||||
<th class="px-3 py-2">Cena</th>
|
||||
<th class="px-3 py-2">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in profiles" :key="p.id" class="border-t last:border-b hover:bg-gray-50">
|
||||
<td class="px-3 py-2 font-medium text-gray-800">{{ p.name }}</td>
|
||||
<td class="px-3 py-2">{{ p.api_username }}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<span :class="p.active ? 'text-emerald-600' : 'text-rose-600'">{{ p.active ? 'Da' : 'Ne' }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-600">
|
||||
<span v-if="(p.senders||[]).length === 0">—</span>
|
||||
<span v-else>
|
||||
{{ p.senders.map(s => s.sname).join(', ') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="fetchBalance(p)" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-amber-700 border-amber-300 bg-amber-50 hover:bg-amber-100">
|
||||
<FontAwesomeIcon :icon="faCoins" class="w-3.5 h-3.5" /> Pridobi
|
||||
</button>
|
||||
<span class="text-xs text-gray-600">{{ balances[p.id] ?? '—' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="fetchPrice(p)" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-indigo-700 border-indigo-300 bg-indigo-50 hover:bg-indigo-100">
|
||||
<FontAwesomeIcon :icon="faTags" class="w-3.5 h-3.5" /> Cene
|
||||
</button>
|
||||
<span class="text-xs text-gray-600 truncate max-w-[200px]" :title="(quotes[p.id]||[]).join(', ')">
|
||||
{{ (quotes[p.id] || []).join(', ') || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 flex items-center gap-2">
|
||||
<button
|
||||
@click="openTest(p)"
|
||||
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-emerald-700 border-emerald-300 bg-emerald-50 hover:bg-emerald-100"
|
||||
title="Pošlji test SMS"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPaperPlane" class="w-3.5 h-3.5" /> Test SMS
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create Profile Modal -->
|
||||
<DialogModal :show="createOpen" max-width="2xl" @close="() => (createOpen = false)">
|
||||
<template #title> Nov SMS profil </template>
|
||||
<template #content>
|
||||
<form @submit.prevent="submitCreate" id="create-sms-profile" class="space-y-5">
|
||||
<div class="grid gap-4 grid-cols-2">
|
||||
<div>
|
||||
<label class="label">Ime</label>
|
||||
<input v-model="createForm.name" type="text" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Aktivno</label>
|
||||
<select v-model="createForm.active" class="input">
|
||||
<option :value="true">Da</option>
|
||||
<option :value="false">Ne</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">API uporabnik</label>
|
||||
<input v-model="createForm.api_username" type="text" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">API geslo</label>
|
||||
<input v-model="createForm.api_password" type="password" class="input" autocomplete="new-password" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button type="button" @click="() => (createOpen = false)" class="px-4 py-2 text-sm rounded-md border bg-white hover:bg-gray-50">Prekliči</button>
|
||||
<button form="create-sms-profile" type="submit" :disabled="createForm.processing" class="px-4 py-2 text-sm rounded-md bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50">Shrani</button>
|
||||
</template>
|
||||
</DialogModal>
|
||||
|
||||
<!-- Test Send Modal -->
|
||||
<DialogModal :show="testOpen" max-width="2xl" @close="() => (testOpen = false)">
|
||||
<template #title> Testni SMS </template>
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 grid-cols-2">
|
||||
<div>
|
||||
<label class="label">Prejemnik (E.164)</label>
|
||||
<input v-model="testForm.to" type="text" class="input" placeholder="+386..." />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Državna koda (opcijsko)</label>
|
||||
<input v-model="testForm.country_code" type="text" class="input" placeholder="SI" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Sporočilo</label>
|
||||
<textarea v-model="testForm.message" class="input" rows="4"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Dostavna poročila</label>
|
||||
<select v-model="testForm.delivery_report" class="input">
|
||||
<option :value="true">Da</option>
|
||||
<option :value="false">Ne</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Result details removed; rely on flash message after redirect -->
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button type="button" @click="() => (testOpen = false)" class="px-4 py-2 text-sm rounded-md border bg-white hover:bg-gray-50">Zapri</button>
|
||||
<button type="button" @click="submitTest" :disabled="testForm.processing || !testTarget" class="px-4 py-2 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50">
|
||||
<FontAwesomeIcon :icon="faPaperPlane" class="w-3.5 h-3.5 mr-1" /> Pošlji test
|
||||
</button>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</AdminLayout>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--tw-color-gray-300, #d1d5db);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
.input:focus {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
--tw-ring-color: #6366f1;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 1px #6366f1;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
199
resources/js/Pages/Admin/SmsSenders/Index.vue
Normal file
199
resources/js/Pages/Admin/SmsSenders/Index.vue
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import { Head, useForm, router } from "@inertiajs/vue3";
|
||||
import { ref, computed } from "vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faPlus, faPen, faTrash, faToggleOn, faToggleOff } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const props = defineProps({
|
||||
initialSenders: { type: Array, default: () => [] },
|
||||
profiles: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
// Use props directly so Inertia navigations refresh the list automatically
|
||||
const senders = computed(() => props.initialSenders || []);
|
||||
const profileById = computed(() => Object.fromEntries((props.profiles || []).map(p => [p.id, p])));
|
||||
|
||||
|
||||
// Create/Edit modal
|
||||
const editOpen = ref(false);
|
||||
const editing = ref(null);
|
||||
const form = useForm({
|
||||
profile_id: null,
|
||||
sname: "",
|
||||
phone_number: "",
|
||||
description: "",
|
||||
active: true,
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
form.reset();
|
||||
form.active = true;
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(s) {
|
||||
editing.value = s;
|
||||
form.reset();
|
||||
form.profile_id = s.profile_id;
|
||||
form.sname = s.sname;
|
||||
form.phone_number = s.phone_number || "";
|
||||
form.description = s.description;
|
||||
form.active = !!s.active;
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
try {
|
||||
form.processing = true;
|
||||
const payload = {
|
||||
profile_id: form.profile_id,
|
||||
sname: (form.sname || "").trim() || null,
|
||||
phone_number: form.phone_number || null,
|
||||
description: form.description || null,
|
||||
active: !!form.active,
|
||||
};
|
||||
if (editing.value) {
|
||||
await router.put(route("admin.sms-senders.update", editing.value.id), payload, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
} else {
|
||||
await router.post(route("admin.sms-senders.store"), payload, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
editOpen.value = false;
|
||||
} finally {
|
||||
form.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(s) {
|
||||
await router.post(route("admin.sms-senders.toggle", s.id), {}, { preserveScroll: true });
|
||||
}
|
||||
|
||||
async function destroySender(s) {
|
||||
if (!confirm(`Izbrišem pošiljatelja "${s.sname}"?`)) return;
|
||||
await router.delete(route("admin.sms-senders.destroy", s.id), { preserveScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout title="SMS pošiljatelji">
|
||||
<Head title="SMS pošiljatelji" />
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-semibold text-gray-800">SMS pošiljatelji</h1>
|
||||
<button @click="openCreate" class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-500 shadow">
|
||||
<FontAwesomeIcon :icon="faPlus" class="w-4 h-4" /> Nov pošiljatelj
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border bg-white overflow-hidden shadow-sm">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Pošiljatelj</th>
|
||||
<th class="px-3 py-2 text-left">Številka</th>
|
||||
<th class="px-3 py-2 text-left">Profil</th>
|
||||
<th class="px-3 py-2">Aktiven</th>
|
||||
<th class="px-3 py-2 text-left">Opis</th>
|
||||
<th class="px-3 py-2">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in senders" :key="s.id" class="border-t last:border-b hover:bg-gray-50">
|
||||
<td class="px-3 py-2 font-medium text-gray-800">{{ s.sname }}</td>
|
||||
<td class="px-3 py-2 text-gray-700">{{ s.phone_number || '—' }}</td>
|
||||
<td class="px-3 py-2">{{ profileById[s.profile_id]?.name || '—' }}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<span :class="s.active ? 'text-emerald-600' : 'text-rose-600'">{{ s.active ? 'Da' : 'Ne' }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-600">{{ s.description || '—' }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="openEdit(s)" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-indigo-700 border-indigo-300 bg-indigo-50 hover:bg-indigo-100">
|
||||
<FontAwesomeIcon :icon="faPen" class="w-3.5 h-3.5" /> Uredi
|
||||
</button>
|
||||
<button @click="toggleActive(s)" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-amber-700 border-amber-300 bg-amber-50 hover:bg-amber-100">
|
||||
<FontAwesomeIcon :icon="s.active ? faToggleOn : faToggleOff" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click="destroySender(s)" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-rose-700 border-rose-300 bg-rose-50 hover:bg-rose-100">
|
||||
<FontAwesomeIcon :icon="faTrash" class="w-3.5 h-3.5" /> Izbriši
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<DialogModal :show="editOpen" max-width="2xl" @close="() => (editOpen = false)">
|
||||
<template #title> {{ editing ? 'Uredi pošiljatelja' : 'Nov pošiljatelj' }} </template>
|
||||
<template #content>
|
||||
<form @submit.prevent="submitEdit" id="edit-sms-sender" class="space-y-5">
|
||||
<div class="grid gap-4 grid-cols-2">
|
||||
<div>
|
||||
<label class="label">Profil</label>
|
||||
<select v-model="form.profile_id" class="input">
|
||||
<option :value="null" disabled>Izberi profil…</option>
|
||||
<option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Pošiljatelj (Sender ID) — opcijsko</label>
|
||||
<input v-model="form.sname" type="text" class="input" placeholder="npr. TEREN" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Številka pošiljatelja (opcijsko)</label>
|
||||
<input v-model="form.phone_number" type="text" class="input" placeholder="npr. +38640123456" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Opis (opcijsko)</label>
|
||||
<input v-model="form.description" type="text" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Aktivno</label>
|
||||
<select v-model="form.active" class="input">
|
||||
<option :value="true">Da</option>
|
||||
<option :value="false">Ne</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button type="button" @click="() => (editOpen = false)" class="px-4 py-2 text-sm rounded-md border bg-white hover:bg-gray-50">Prekliči</button>
|
||||
<button form="edit-sms-sender" type="submit" :disabled="form.processing" class="px-4 py-2 text-sm rounded-md bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50">Shrani</button>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--tw-color-gray-300, #d1d5db);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
.input:focus {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
--tw-ring-color: #6366f1;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 1px #6366f1;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
263
resources/js/Pages/Admin/SmsTemplates/Edit.vue
Normal file
263
resources/js/Pages/Admin/SmsTemplates/Edit.vue
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Head, Link, useForm, router } from "@inertiajs/vue3";
|
||||
import { ref, computed, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
template: { type: Object, default: null },
|
||||
profiles: { type: Array, default: () => [] },
|
||||
senders: { type: Array, default: () => [] },
|
||||
actions: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
name: props.template?.name ?? "",
|
||||
slug: props.template?.slug ?? "",
|
||||
content: props.template?.content ?? "",
|
||||
variables_json: props.template?.variables_json ?? {},
|
||||
is_active: props.template?.is_active ?? true,
|
||||
default_profile_id: props.template?.default_profile_id ?? null,
|
||||
default_sender_id: props.template?.default_sender_id ?? null,
|
||||
allow_custom_body: props.template?.allow_custom_body ?? false,
|
||||
action_id: props.template?.action_id ?? null,
|
||||
decision_id: props.template?.decision_id ?? null,
|
||||
});
|
||||
|
||||
const sendersByProfile = computed(() => {
|
||||
const map = {};
|
||||
(props.senders || []).forEach((s) => {
|
||||
if (!map[s.profile_id]) map[s.profile_id] = [];
|
||||
map[s.profile_id].push(s);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
function currentSendersForProfile(pid) {
|
||||
if (!pid) return [];
|
||||
return (sendersByProfile.value[pid] || []).filter((s) => s.active);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (props.template?.id) {
|
||||
form.put(route("admin.sms-templates.update", props.template.id));
|
||||
} else {
|
||||
form.post(route("admin.sms-templates.store"), {
|
||||
onSuccess: (page) => {
|
||||
// If backend redirected, nothing to do. If returned JSON, navigate to edit.
|
||||
try {
|
||||
const id = page?.props?.template?.id; // in case of redirect props
|
||||
if (id) {
|
||||
router.visit(route("admin.sms-templates.edit", id));
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
onFinish: () => {},
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Test send panel
|
||||
const testForm = useForm({
|
||||
to: "",
|
||||
variables: {},
|
||||
profile_id: props.template?.default_profile_id ?? null,
|
||||
sender_id: props.template?.default_sender_id ?? null,
|
||||
country_code: null,
|
||||
delivery_report: true,
|
||||
custom_content: "",
|
||||
});
|
||||
const testResult = ref(null);
|
||||
|
||||
watch(
|
||||
() => testForm.profile_id,
|
||||
() => {
|
||||
// reset sender when profile changes
|
||||
testForm.sender_id = null;
|
||||
}
|
||||
);
|
||||
|
||||
async function submitTest() {
|
||||
if (!props.template?.id) {
|
||||
alert("Najprej shranite predlogo.");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
to: testForm.to,
|
||||
variables: testForm.variables || {},
|
||||
profile_id: testForm.profile_id || null,
|
||||
sender_id: testForm.sender_id || null,
|
||||
country_code: testForm.country_code || null,
|
||||
delivery_report: !!testForm.delivery_report,
|
||||
custom_content: testForm.custom_content || null,
|
||||
};
|
||||
await router.post(route("admin.sms-templates.send-test", props.template.id), payload, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
testResult.value = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout :title="props.template ? 'Uredi SMS predlogo' : 'Nova SMS predloga'">
|
||||
<Head :title="props.template ? 'Uredi SMS predlogo' : 'Nova SMS predloga'" />
|
||||
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Link :href="route('admin.sms-templates.index')" class="text-sm text-indigo-600 hover:underline">← Nazaj na seznam</Link>
|
||||
<div class="text-gray-700 text-sm" v-if="props.template">#{{ props.template.id }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="submit"
|
||||
:disabled="form.processing"
|
||||
class="px-4 py-2 text-sm rounded-md bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
Shrani
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Form -->
|
||||
<div class="lg:col-span-2 rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-5 space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">Ime</label>
|
||||
<input v-model="form.name" type="text" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Slug</label>
|
||||
<input v-model="form.slug" type="text" class="input" placeholder="npr. payment_reminder" />
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="label">Vsebina</label>
|
||||
<textarea v-model="form.content" rows="8" class="input" placeholder="Pozdravljen {{ person.first_name }}, ..."></textarea>
|
||||
<div class="text-[11px] text-gray-500 mt-1">
|
||||
Uporabite placeholderje npr. <code>{first_name}</code> ali
|
||||
<code v-pre>{{ person.first_name }}</code> – ob pošiljanju se vrednosti nadomestijo.
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="label">Dovoli lastno besedilo</label>
|
||||
<select v-model="form.allow_custom_body" class="input">
|
||||
<option :value="true">Da</option>
|
||||
<option :value="false">Ne</option>
|
||||
</select>
|
||||
<div class="text-[11px] text-gray-500 mt-1">Če je omogočeno, lahko pošiljatelj namesto vsebine predloge napiše poljubno besedilo.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Privzet profil</label>
|
||||
<select v-model="form.default_profile_id" class="input">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="p in props.profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Privzet sender</label>
|
||||
<select v-model="form.default_sender_id" class="input" :disabled="!form.default_profile_id">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="s in currentSendersForProfile(form.default_profile_id)" :key="s.id" :value="s.id">
|
||||
{{ s.sname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Aktivno</label>
|
||||
<select v-model="form.is_active" class="input">
|
||||
<option :value="true">Da</option>
|
||||
<option :value="false">Ne</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">Akcija po pošiljanju</label>
|
||||
<select v-model="form.action_id" class="input">
|
||||
<option :value="null">(brez)</option>
|
||||
<option v-for="a in props.actions" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Odločitev</label>
|
||||
<select v-model="form.decision_id" class="input" :disabled="!form.action_id">
|
||||
<option :value="null">(brez)</option>
|
||||
<option v-for="d in (props.actions.find(x => x.id === form.action_id)?.decisions || [])" :key="d.id" :value="d.id">{{ d.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test send -->
|
||||
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-5 space-y-4">
|
||||
<div class="font-semibold text-gray-800">Testno pošiljanje</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">Prejemnik (E.164)</label>
|
||||
<input v-model="testForm.to" type="text" class="input" placeholder="+386..." />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Državna koda</label>
|
||||
<input v-model="testForm.country_code" type="text" class="input" placeholder="SI" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Profil</label>
|
||||
<select v-model="testForm.profile_id" class="input">
|
||||
<option :value="null">(privzeti)</option>
|
||||
<option v-for="p in props.profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Sender</label>
|
||||
<select v-model="testForm.sender_id" class="input" :disabled="!testForm.profile_id">
|
||||
<option :value="null">(privzeti)</option>
|
||||
<option v-for="s in currentSendersForProfile(testForm.profile_id)" :key="s.id" :value="s.id">{{ s.sname }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Dostavna poročila</label>
|
||||
<select v-model="testForm.delivery_report" class="input">
|
||||
<option :value="true">Da</option>
|
||||
<option :value="false">Ne</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="form.allow_custom_body" class="md:col-span-2">
|
||||
<label class="label">Lastno besedilo (opcijsko)</label>
|
||||
<textarea v-model="testForm.custom_content" rows="4" class="input" placeholder="Če je izpolnjeno, bo namesto predloge poslano to besedilo."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button type="button" @click="submitTest" :disabled="testForm.processing || !props.template" class="px-3 py-2 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50">Pošlji test</button>
|
||||
</div>
|
||||
<!-- Result details removed in favor of flash messages after redirect -->
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
.input:focus {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 1px #6366f1;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
332
resources/js/Pages/Admin/SmsTemplates/Index.vue
Normal file
332
resources/js/Pages/Admin/SmsTemplates/Index.vue
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import { Head, useForm, Link, router } from "@inertiajs/vue3";
|
||||
import { computed, ref } from "vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faPlus, faPen, faTrash, faPaperPlane } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const props = defineProps({
|
||||
initialTemplates: { type: Array, default: () => [] },
|
||||
profiles: { type: Array, default: () => [] },
|
||||
senders: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const templates = computed(() => props.initialTemplates || []);
|
||||
const profilesById = computed(() =>
|
||||
Object.fromEntries((props.profiles || []).map((p) => [p.id, p]))
|
||||
);
|
||||
const sendersById = computed(() =>
|
||||
Object.fromEntries((props.senders || []).map((s) => [s.id, s]))
|
||||
);
|
||||
const sendersByProfile = computed(() => {
|
||||
const map = {};
|
||||
(props.senders || []).forEach((s) => {
|
||||
if (!map[s.profile_id]) map[s.profile_id] = [];
|
||||
map[s.profile_id].push(s);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
// No manual reload; Inertia visits will refresh props
|
||||
|
||||
// Create/Edit modal
|
||||
const editOpen = ref(false);
|
||||
const editing = ref(null);
|
||||
const form = useForm({
|
||||
name: "",
|
||||
slug: "",
|
||||
content: "",
|
||||
variables_json: {},
|
||||
is_active: true,
|
||||
default_profile_id: null,
|
||||
default_sender_id: null,
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
form.reset();
|
||||
form.is_active = true;
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(t) {
|
||||
editing.value = t;
|
||||
form.reset();
|
||||
form.name = t.name;
|
||||
form.slug = t.slug;
|
||||
form.content = t.content;
|
||||
form.variables_json = t.variables_json || {};
|
||||
form.is_active = !!t.is_active;
|
||||
form.default_profile_id = t.default_profile_id || null;
|
||||
form.default_sender_id = t.default_sender_id || null;
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
try {
|
||||
form.processing = true;
|
||||
const payload = {
|
||||
name: form.name,
|
||||
slug: form.slug,
|
||||
content: form.content,
|
||||
variables_json: form.variables_json || {},
|
||||
is_active: !!form.is_active,
|
||||
default_profile_id: form.default_profile_id || null,
|
||||
default_sender_id: form.default_sender_id || null,
|
||||
};
|
||||
if (editing.value) {
|
||||
await router.put(route("admin.sms-templates.update", editing.value.id), payload, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
} else {
|
||||
await router.post(route("admin.sms-templates.store"), payload, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
editOpen.value = false;
|
||||
} finally {
|
||||
form.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function destroyTemplate(t) {
|
||||
if (!confirm(`Izbrišem SMS predlogo "${t.name}"?`)) return;
|
||||
await router.delete(route("admin.sms-templates.destroy", t.id), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Test send modal
|
||||
const testOpen = ref(false);
|
||||
const testTarget = ref(null);
|
||||
const testForm = useForm({
|
||||
to: "",
|
||||
variables: {},
|
||||
profile_id: null,
|
||||
sender_id: null,
|
||||
country_code: null,
|
||||
delivery_report: true,
|
||||
});
|
||||
const testResult = ref(null);
|
||||
|
||||
function openTest(t) {
|
||||
testTarget.value = t;
|
||||
testForm.reset();
|
||||
testForm.delivery_report = true;
|
||||
testForm.profile_id = t.default_profile_id || null;
|
||||
testForm.sender_id = t.default_sender_id || null;
|
||||
testResult.value = null;
|
||||
testOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
if (!testTarget.value) return;
|
||||
const payload = {
|
||||
to: testForm.to,
|
||||
variables: testForm.variables || {},
|
||||
profile_id: testForm.profile_id || null,
|
||||
sender_id: testForm.sender_id || null,
|
||||
country_code: testForm.country_code || null,
|
||||
delivery_report: !!testForm.delivery_report,
|
||||
};
|
||||
await router.post(
|
||||
route("admin.sms-templates.send-test", testTarget.value.id),
|
||||
payload,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
testOpen.value = false;
|
||||
testResult.value = null;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function currentSenders() {
|
||||
const pid = form.default_profile_id;
|
||||
if (!pid) return [];
|
||||
return (sendersByProfile.value[pid] || []).filter((s) => s.active);
|
||||
}
|
||||
|
||||
function currentSendersForTest() {
|
||||
const pid = testForm.profile_id;
|
||||
if (!pid) return [];
|
||||
return (sendersByProfile.value[pid] || []).filter((s) => s.active);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout title="SMS predloge">
|
||||
<Head title="SMS predloge" />
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-semibold text-gray-800">SMS predloge</h1>
|
||||
<Link
|
||||
:href="route('admin.sms-templates.create')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-500 shadow"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPlus" class="w-4 h-4" /> Nova predloga
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border bg-white overflow-hidden shadow-sm">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Ime</th>
|
||||
<th class="px-3 py-2 text-left">Slug</th>
|
||||
<th class="px-3 py-2 text-left">Privzet profil/sender</th>
|
||||
<th class="px-3 py-2">Aktivno</th>
|
||||
<th class="px-3 py-2">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="t in templates"
|
||||
:key="t.id"
|
||||
class="border-t last:border-b hover:bg-gray-50"
|
||||
>
|
||||
<td class="px-3 py-2 font-medium text-gray-800">{{ t.name }}</td>
|
||||
<td class="px-3 py-2 text-gray-600">{{ t.slug }}</td>
|
||||
<td class="px-3 py-2 text-gray-600">
|
||||
<span>{{ profilesById[t.default_profile_id]?.name || "—" }}</span>
|
||||
<span v-if="t.default_sender_id">
|
||||
/ {{ sendersById[t.default_sender_id]?.sname }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<span :class="t.is_active ? 'text-emerald-600' : 'text-rose-600'">{{
|
||||
t.is_active ? "Da" : "Ne"
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 flex items-center gap-2">
|
||||
<Link
|
||||
:href="route('admin.sms-templates.edit', t.id)"
|
||||
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-indigo-700 border-indigo-300 bg-indigo-50 hover:bg-indigo-100"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPen" class="w-3.5 h-3.5" /> Uredi
|
||||
</Link>
|
||||
<button
|
||||
@click="openTest(t)"
|
||||
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-emerald-700 border-emerald-300 bg-emerald-50 hover:bg-emerald-100"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPaperPlane" class="w-3.5 h-3.5" /> Test
|
||||
</button>
|
||||
<button
|
||||
@click="destroyTemplate(t)"
|
||||
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-rose-700 border-rose-300 bg-rose-50 hover:bg-rose-100"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTrash" class="w-3.5 h-3.5" /> Izbriši
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Edit/Create now handled on dedicated page -->
|
||||
|
||||
<!-- Test Send Modal -->
|
||||
<DialogModal :show="testOpen" max-width="2xl" @close="() => (testOpen = false)">
|
||||
<template #title> Testno pošiljanje </template>
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 grid-cols-2">
|
||||
<div>
|
||||
<label class="label">Prejemnik (E.164)</label>
|
||||
<input
|
||||
v-model="testForm.to"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="+386..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Državna koda</label>
|
||||
<input
|
||||
v-model="testForm.country_code"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="SI"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Profil</label>
|
||||
<select v-model="testForm.profile_id" class="input">
|
||||
<option :value="null">(privzeti)</option>
|
||||
<option v-for="p in props.profiles" :key="p.id" :value="p.id">
|
||||
{{ p.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Sender</label>
|
||||
<select
|
||||
v-model="testForm.sender_id"
|
||||
class="input"
|
||||
:disabled="!testForm.profile_id"
|
||||
>
|
||||
<option :value="null">(privzeti)</option>
|
||||
<option v-for="s in currentSendersForTest()" :key="s.id" :value="s.id">
|
||||
{{ s.sname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Dostavna poročila</label>
|
||||
<select v-model="testForm.delivery_report" class="input">
|
||||
<option :value="true">Da</option>
|
||||
<option :value="false">Ne</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Result details removed in favor of flash messages after redirect -->
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button
|
||||
type="button"
|
||||
@click="() => (testOpen = false)"
|
||||
class="px-4 py-2 text-sm rounded-md border bg-white hover:bg-gray-50"
|
||||
>
|
||||
Zapri
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="submitTest"
|
||||
:disabled="testForm.processing || !testTarget"
|
||||
class="px-4 py-2 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPaperPlane" class="w-3.5 h-3.5 mr-1" /> Pošlji test
|
||||
</button>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--tw-color-gray-300, #d1d5db);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
.input:focus {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
--tw-ring-color: #6366f1;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 1px #6366f1;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
|
||||
<!-- Right side panels -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<!-- SMS Overview -->
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
|
||||
>
|
||||
SMS stanje
|
||||
</h3>
|
||||
<div v-if="props.smsStats?.length" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead
|
||||
class="bg-gray-50 dark:bg-gray-900/30 text-gray-600 dark:text-gray-300 text-xs uppercase tracking-wider"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Profil</th>
|
||||
<th class="px-3 py-2 text-left">Bilanca</th>
|
||||
<th class="px-3 py-2 text-left">Danes (skupaj)</th>
|
||||
<th class="px-3 py-2 text-left">Sent</th>
|
||||
<th class="px-3 py-2 text-left">Delivered</th>
|
||||
<th class="px-3 py-2 text-left">Failed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="p in props.smsStats"
|
||||
:key="p.id"
|
||||
class="border-t last:border-b dark:border-gray-700"
|
||||
>
|
||||
<td class="px-3 py-2">
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{
|
||||
p.name
|
||||
}}</span>
|
||||
<span
|
||||
class="ml-2 text-[11px]"
|
||||
:class="p.active ? 'text-emerald-600' : 'text-gray-400'"
|
||||
>{{ p.active ? "Aktiven" : "Neaktiven" }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">
|
||||
{{ p.balance ?? "—" }}
|
||||
</td>
|
||||
<td class="px-3 py-2">{{ p.today?.total ?? 0 }}</td>
|
||||
<td class="px-3 py-2 text-sky-700">{{ p.today?.sent ?? 0 }}</td>
|
||||
<td class="px-3 py-2 text-emerald-700">
|
||||
{{ p.today?.delivered ?? 0 }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-rose-700">{{ p.today?.failed ?? 0 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Ni podatkov o SMS.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health -->
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
|
||||
|
|
|
|||
|
|
@ -44,8 +44,9 @@ const clientOptions = computed(() => {
|
|||
? 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) {
|
|||
<template #cell-partner="{ row }">
|
||||
<div class="truncate">
|
||||
{{
|
||||
(row.contract?.client_case?.person?.full_name) ||
|
||||
(row.client_case?.person?.full_name) ||
|
||||
(row.contract?.client_case?.client?.person?.full_name) ||
|
||||
(row.client_case?.client?.person?.full_name) ||
|
||||
'—'
|
||||
}}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,39 @@
|
|||
|
||||
Route::redirect('/', 'login');
|
||||
|
||||
// SMS provider webhook (delivery reports and replies)
|
||||
Route::match(['GET', 'POST'], 'webhooks/sms/smsapi-si', \App\Http\Controllers\SmsWebhookController::class)
|
||||
->name('webhooks.sms.smsapi-si');
|
||||
|
||||
// Admin SMS endpoints (under manage-settings)
|
||||
Route::middleware([
|
||||
'auth:sanctum',
|
||||
config('jetstream.auth_session'),
|
||||
'verified',
|
||||
'permission:manage-settings',
|
||||
])->prefix('admin')->name('admin.')->group(function () {
|
||||
Route::get('sms-profiles', [\App\Http\Controllers\Admin\SmsProfileController::class, 'index'])->name('sms-profiles.index');
|
||||
Route::post('sms-profiles', [\App\Http\Controllers\Admin\SmsProfileController::class, 'store'])->name('sms-profiles.store');
|
||||
Route::post('sms-profiles/{smsProfile}/test-send', [\App\Http\Controllers\Admin\SmsProfileController::class, 'testSend'])->name('sms-profiles.test-send');
|
||||
Route::post('sms-profiles/{smsProfile}/balance', [\App\Http\Controllers\Admin\SmsProfileController::class, 'balance'])->name('sms-profiles.balance');
|
||||
Route::post('sms-profiles/{smsProfile}/price', [\App\Http\Controllers\Admin\SmsProfileController::class, 'price'])->name('sms-profiles.price');
|
||||
|
||||
// SMS senders management
|
||||
Route::get('sms-senders', [\App\Http\Controllers\Admin\SmsSenderController::class, 'index'])->name('sms-senders.index');
|
||||
Route::post('sms-senders', [\App\Http\Controllers\Admin\SmsSenderController::class, 'store'])->name('sms-senders.store');
|
||||
Route::put('sms-senders/{smsSender}', [\App\Http\Controllers\Admin\SmsSenderController::class, 'update'])->name('sms-senders.update');
|
||||
Route::post('sms-senders/{smsSender}/toggle', [\App\Http\Controllers\Admin\SmsSenderController::class, 'toggle'])->name('sms-senders.toggle');
|
||||
Route::delete('sms-senders/{smsSender}', [\App\Http\Controllers\Admin\SmsSenderController::class, 'destroy'])->name('sms-senders.destroy');
|
||||
|
||||
// SMS templates management
|
||||
Route::get('sms-templates', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'index'])->name('sms-templates.index');
|
||||
Route::post('sms-templates', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'store'])->name('sms-templates.store');
|
||||
Route::put('sms-templates/{smsTemplate}', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'update'])->name('sms-templates.update');
|
||||
Route::post('sms-templates/{smsTemplate}/toggle', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'toggle'])->name('sms-templates.toggle');
|
||||
Route::delete('sms-templates/{smsTemplate}', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'destroy'])->name('sms-templates.destroy');
|
||||
Route::post('sms-templates/{smsTemplate}/send-test', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'sendTest'])->name('sms-templates.send-test');
|
||||
});
|
||||
|
||||
Route::middleware([
|
||||
'auth:sanctum',
|
||||
config('jetstream.auth_session'),
|
||||
|
|
@ -99,6 +132,27 @@
|
|||
// Email logs
|
||||
Route::get('email-logs', [\App\Http\Controllers\Admin\EmailLogController::class, 'index'])->name('email-logs.index');
|
||||
Route::get('email-logs/{emailLog}', [\App\Http\Controllers\Admin\EmailLogController::class, 'show'])->name('email-logs.show');
|
||||
|
||||
// SMS senders
|
||||
Route::get('sms-senders', [\App\Http\Controllers\Admin\SmsSenderController::class, 'index'])->name('sms-senders.index');
|
||||
Route::post('sms-senders', [\App\Http\Controllers\Admin\SmsSenderController::class, 'store'])->name('sms-senders.store');
|
||||
Route::put('sms-senders/{smsSender}', [\App\Http\Controllers\Admin\SmsSenderController::class, 'update'])->name('sms-senders.update');
|
||||
Route::post('sms-senders/{smsSender}/toggle', [\App\Http\Controllers\Admin\SmsSenderController::class, 'toggle'])->name('sms-senders.toggle');
|
||||
Route::delete('sms-senders/{smsSender}', [\App\Http\Controllers\Admin\SmsSenderController::class, 'destroy'])->name('sms-senders.destroy');
|
||||
|
||||
// SMS templates
|
||||
Route::get('sms-templates', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'index'])->name('sms-templates.index');
|
||||
Route::get('sms-templates/create', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'create'])->name('sms-templates.create');
|
||||
Route::post('sms-templates', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'store'])->name('sms-templates.store');
|
||||
Route::get('sms-templates/{smsTemplate}/edit', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'edit'])->name('sms-templates.edit');
|
||||
Route::put('sms-templates/{smsTemplate}', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'update'])->name('sms-templates.update');
|
||||
Route::post('sms-templates/{smsTemplate}/toggle', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'toggle'])->name('sms-templates.toggle');
|
||||
Route::delete('sms-templates/{smsTemplate}', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'destroy'])->name('sms-templates.destroy');
|
||||
Route::post('sms-templates/{smsTemplate}/send-test', [\App\Http\Controllers\Admin\SmsTemplateController::class, 'sendTest'])->name('sms-templates.send-test');
|
||||
|
||||
// SMS logs
|
||||
Route::get('sms-logs', [\App\Http\Controllers\Admin\SmsLogController::class, 'index'])->name('sms-logs.index');
|
||||
Route::get('sms-logs/{smsLog}', [\App\Http\Controllers\Admin\SmsLogController::class, 'show'])->name('sms-logs.show');
|
||||
});
|
||||
|
||||
// Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service
|
||||
|
|
@ -271,6 +325,8 @@
|
|||
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewDocument'])->name('clientCase.document.view');
|
||||
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/download', [ClientCaseContoller::class, 'downloadDocument'])->name('clientCase.document.download');
|
||||
Route::delete('client-cases/{client_case:uuid}/documents/{document:uuid}', [ClientCaseContoller::class, 'deleteDocument'])->name('clientCase.document.delete');
|
||||
// client-case / person phone - send SMS
|
||||
Route::post('client-cases/{client_case:uuid}/phone/{phone_id}/sms', [ClientCaseContoller::class, 'sendSmsToPhone'])->name('clientCase.phone.sms');
|
||||
// contract / documents (direct access by contract)
|
||||
Route::get('contracts/{contract:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewContractDocument'])->name('contract.document.view');
|
||||
Route::get('contracts/{contract:uuid}/documents/{document:uuid}/download', [ClientCaseContoller::class, 'downloadContractDocument'])->name('contract.document.download');
|
||||
|
|
|
|||
120
tests/Feature/SmsProfileTest.php
Normal file
120
tests/Feature/SmsProfileTest.php
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\SendSmsJob;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\SmsLog;
|
||||
use App\Models\SmsProfile;
|
||||
use App\Models\SmsSender;
|
||||
use App\Models\User;
|
||||
use App\Services\Sms\SmsClient;
|
||||
use App\Services\Sms\SmsMessage;
|
||||
use App\Services\Sms\SmsResult;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function adminUserForSms(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
// Ensure admin role & manage-settings permission exist
|
||||
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
|
||||
$permission = Permission::firstOrCreate(['slug' => 'manage-settings'], ['name' => 'Manage Settings']);
|
||||
$user->roles()->syncWithoutDetaching([$role->id]);
|
||||
if (method_exists($user, 'givePermissionTo')) {
|
||||
$user->givePermissionTo('manage-settings');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('creates an sms profile and encrypts password', function () {
|
||||
$user = adminUserForSms();
|
||||
test()->actingAs($user);
|
||||
|
||||
$resp = test()->postJson(route('admin.sms-profiles.store'), [
|
||||
'name' => 'Primary SMS',
|
||||
'active' => true,
|
||||
'api_username' => 'testuser',
|
||||
'api_password' => 'super-secret',
|
||||
]);
|
||||
|
||||
$resp->assertCreated();
|
||||
$resp->assertJsonStructure(['profile' => ['id', 'uuid', 'name', 'active', 'api_username', 'created_at', 'updated_at']]);
|
||||
|
||||
$profile = SmsProfile::first();
|
||||
expect($profile)->not->toBeNull();
|
||||
// Attribute is hidden, but verify decrypt roundtrip via model method
|
||||
expect($profile->decryptApiPassword())->toBe('super-secret');
|
||||
});
|
||||
|
||||
it('queues a job to send sms and logs as sent', function () {
|
||||
$user = adminUserForSms();
|
||||
test()->actingAs($user);
|
||||
|
||||
$profile = SmsProfile::factory()->create([
|
||||
'api_username' => 'apiuser',
|
||||
'api_password' => 'apipass',
|
||||
]);
|
||||
$sender = SmsSender::factory()->create(['profile_id' => $profile->id]);
|
||||
|
||||
// Bind a fake client that always returns sent with provider id
|
||||
app()->instance(SmsClient::class, new class implements SmsClient
|
||||
{
|
||||
public function send(App\Models\SmsProfile $profile, SmsMessage $message): SmsResult
|
||||
{
|
||||
return new SmsResult('sent', '123456');
|
||||
}
|
||||
|
||||
public function getCreditBalance(App\Models\SmsProfile $profile): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function getPriceQuotes(App\Models\SmsProfile $profile): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Run the job synchronously
|
||||
(new SendSmsJob(
|
||||
profileId: $profile->id,
|
||||
to: '+38640111222',
|
||||
content: 'Hello from test',
|
||||
senderId: null,
|
||||
countryCode: null,
|
||||
deliveryReport: true,
|
||||
clientReference: null,
|
||||
))->handle(app(\App\Services\Sms\SmsService::class));
|
||||
|
||||
$log = SmsLog::first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe('sent');
|
||||
expect($log->provider_message_id)->toBe('123456');
|
||||
});
|
||||
|
||||
it('returns balance and price quotes', function () {
|
||||
$user = adminUserForSms();
|
||||
test()->actingAs($user);
|
||||
|
||||
$profile = SmsProfile::factory()->create([
|
||||
'api_username' => 'apiuser',
|
||||
'api_password' => 'apipass',
|
||||
]);
|
||||
|
||||
$base = config('services.sms.providers.smsapi_si.base_url');
|
||||
$creditsUrl = rtrim($base, '/').config('services.sms.providers.smsapi_si.credits_endpoint', '/preveri-stanje-kreditov');
|
||||
$priceUrl = rtrim($base, '/').config('services.sms.providers.smsapi_si.price_endpoint', '/dobi-ceno');
|
||||
|
||||
Http::fake([
|
||||
$creditsUrl => Http::response('42', 200),
|
||||
$priceUrl => Http::response('0.05 EUR##0.07 EUR', 200),
|
||||
]);
|
||||
|
||||
test()->postJson(route('admin.sms-profiles.balance', $profile))
|
||||
->assertSuccessful()
|
||||
->assertJson(['balance' => '42']);
|
||||
|
||||
test()->postJson(route('admin.sms-profiles.price', $profile))
|
||||
->assertSuccessful()
|
||||
->assertJson(['quotes' => ['0.05 EUR', '0.07 EUR']]);
|
||||
});
|
||||
41
tests/Unit/SmsServiceTest.php
Normal file
41
tests/Unit/SmsServiceTest.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use App\Models\SmsProfile;
|
||||
use App\Services\Sms\SmsClient;
|
||||
use App\Services\Sms\SmsMessage;
|
||||
use App\Services\Sms\SmsResult;
|
||||
use App\Services\Sms\SmsService;
|
||||
|
||||
it('sends via service and creates a log', function () {
|
||||
$profile = SmsProfile::factory()->create([
|
||||
'api_username' => 'apiuser',
|
||||
'api_password' => 'apipass',
|
||||
]);
|
||||
|
||||
// Bind a fake client that always returns sent
|
||||
app()->instance(SmsClient::class, new class implements SmsClient
|
||||
{
|
||||
public function send(App\Models\SmsProfile $profile, SmsMessage $message): SmsResult
|
||||
{
|
||||
return new SmsResult(status: 'sent', providerMessageId: '99999');
|
||||
}
|
||||
|
||||
public function getCreditBalance(App\Models\SmsProfile $profile): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function getPriceQuotes(App\Models\SmsProfile $profile): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
$sms = app(SmsService::class);
|
||||
$log = $sms->sendRaw($profile, '+38640111222', 'Hello');
|
||||
|
||||
expect($log->id)->not->toBeNull();
|
||||
expect($log->profile_id)->toBe($profile->id);
|
||||
expect($log->status)->toBe('sent');
|
||||
expect($log->provider_message_id)->toBe('99999');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user