Package and individual mail sender, new report, and other changes

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Simon Pocrnjič
2026-05-11 21:32:30 +02:00
parent b6bfa17980
commit e3bc5da7e3
49 changed files with 4754 additions and 249 deletions
@@ -6,6 +6,7 @@
use App\Models\EmailLog;
use App\Models\EmailTemplate;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -69,4 +70,15 @@ public function show(EmailLog $emailLog): Response
'log' => $emailLog,
]);
}
public function body(EmailLog $emailLog): JsonResponse
{
$this->authorize('viewAny', EmailTemplate::class);
$emailLog->load('body');
return response()->json([
'html' => $emailLog->body?->body_html ?? '',
]);
}
}
@@ -13,6 +13,7 @@
use App\Models\EmailLog;
use App\Models\EmailLogStatus;
use App\Models\EmailTemplate;
use App\Models\MailProfile;
use App\Services\EmailTemplateRenderer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
@@ -55,8 +56,14 @@ public function create(): Response
{
$this->authorize('create', EmailTemplate::class);
$actions = \App\Models\Action::query()
->with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/EmailTemplates/Edit', [
'template' => null,
'actions' => $actions,
]);
}
@@ -93,7 +100,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
// Context resolution (shared logic with renderFinalHtml)
$ctx = [];
if ($id = $request->integer('activity_id')) {
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
if ($activity) {
$ctx['activity'] = $activity;
// Derive base entities from activity when not explicitly provided
@@ -110,7 +117,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
}
}
if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
if ($contract) {
$ctx['contract'] = $contract;
if ($contract->clientCase) {
@@ -140,6 +147,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes
}
}
$ctx['extra'] = (array) $request->input('extra', []);
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
$rendered = $renderer->render([
'subject' => $subject,
@@ -161,8 +169,14 @@ public function edit(EmailTemplate $emailTemplate): Response
$q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']);
}]);
$actions = \App\Models\Action::query()
->with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/EmailTemplates/Edit', [
'template' => $emailTemplate,
'actions' => $actions,
]);
}
@@ -181,7 +195,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
// Context resolution
$ctx = [];
if ($id = $request->integer('activity_id')) {
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
if ($activity) {
$ctx['activity'] = $activity;
if ($activity->contract && ! isset($ctx['contract'])) {
@@ -197,7 +211,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
}
}
if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
if ($contract) {
$ctx['contract'] = $contract;
if ($contract->clientCase) {
@@ -227,6 +241,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
}
}
$ctx['extra'] = (array) $request->input('extra', []);
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
// Render preview values; we store a minimal snapshot on the log
$rendered = $renderer->render([
@@ -293,7 +308,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
// Context resolution (same as sendTest)
$ctx = [];
if ($id = $request->integer('activity_id')) {
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id);
if ($activity) {
$ctx['activity'] = $activity;
if ($activity->contract && ! isset($ctx['contract'])) {
@@ -309,7 +324,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
}
}
if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
$contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id);
if ($contract) {
$ctx['contract'] = $contract;
if ($contract->clientCase) {
@@ -339,6 +354,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
}
}
$ctx['extra'] = (array) $request->input('extra', []);
$ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first();
$rendered = $renderer->render([
'subject' => $subject,
@@ -26,7 +26,7 @@ public function index(): Response
->orderBy('priority')
->orderBy('id')
->get([
'id', 'name', 'active', 'host', 'port', 'encryption', 'from_address', 'priority', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at',
'id', 'name', 'active', 'host', 'port', 'username', 'from_name', 'encryption', 'from_address', 'priority', 'signature', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at',
]);
return Inertia::render('Admin/MailProfiles/Index', [
@@ -3,13 +3,16 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEmailPackageFromContractsRequest;
use App\Http\Requests\StorePackageFromContractsRequest;
use App\Http\Requests\StorePackageRequest;
use App\Jobs\PackageItemEmailJob;
use App\Jobs\PackageItemSmsJob;
use App\Models\Contract;
use App\Models\Package;
use App\Models\PackageItem;
use App\Models\SmsTemplate;
use App\Services\Contact\EmailSelector;
use App\Services\Contact\PhoneSelector;
use App\Services\Sms\SmsService;
use Illuminate\Http\RedirectResponse;
@@ -21,20 +24,40 @@
class PackageController extends Controller
{
public function index(Request $request): Response
public function landing(): Response
{
return Inertia::render('Packages/Index');
}
public function smsIndex(Request $request): Response
{
$perPage = $request->input('per_page') ?? 25;
$packages = Package::query()
->where('type', Package::TYPE_SMS)
->latest('id')
->paginate($perPage);
return Inertia::render('Packages/Index', [
return Inertia::render('Packages/Sms/Index', [
'packages' => $packages,
]);
}
public function create(Request $request): Response
public function emailIndex(Request $request): Response
{
$perPage = $request->input('per_page') ?? 25;
$packages = Package::query()
->where('type', Package::TYPE_EMAIL)
->latest('id')
->paginate($perPage);
return Inertia::render('Packages/Mail/Index', [
'packages' => $packages,
]);
}
public function smsCreate(Request $request): Response
{
// Minimal lookups for create form (active only)
$profiles = \App\Models\SmsProfile::query()
@@ -69,7 +92,7 @@ public function create(Request $request): Response
})
->values();
return Inertia::render('Packages/Create', [
return Inertia::render('Packages/Sms/Create', [
'profiles' => $profiles,
'senders' => $senders,
'templates' => $templates,
@@ -78,7 +101,53 @@ public function create(Request $request): Response
]);
}
public function show(Package $package, SmsService $sms): Response
public function emailCreate(): Response
{
$emailTemplates = \App\Models\EmailTemplate::query()
->where('active', true)
->where('client', false)
->orderBy('name')
->get(['id', 'name', 'subject_template', 'text_template', 'html_template'])
->map(fn ($t) => [
'id' => $t->id,
'name' => $t->name,
'subject_template' => $t->subject_template,
'text_template' => $t->text_template,
'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $t->html_template),
])->values();
$mailProfiles = \App\Models\MailProfile::query()
->where('active', true)
->orderBy('priority')
->get(['id', 'name']);
$segments = \App\Models\Segment::query()
->where('active', true)
->where('exclude', false)
->orderBy('name')
->get(['id', 'name']);
$clients = \App\Models\Client::query()
->with(['person' => function ($q) {
$q->select('id', 'uuid', 'full_name');
}])
->latest('id')
->get(['id', 'uuid', 'person_id'])
->map(function ($c) {
return [
'id' => $c->id,
'uuid' => $c->uuid,
'name' => $c->person?->full_name ?? ('Client #'.$c->id),
];
})
->values();
return Inertia::render('Packages/Mail/Create', [
'emailTemplates' => $emailTemplates,
'mailProfiles' => $mailProfiles,
'segments' => $segments,
'clients' => $clients,
]);
}
public function smsShow(Package $package, SmsService $sms): Response
{
$items = $package->items()->latest('id')->paginate(25);
@@ -212,13 +281,23 @@ public function show(Package $package, SmsService $sms): Response
}
}
return Inertia::render('Packages/Show', [
return Inertia::render('Packages/Sms/Show', [
'package' => $package,
'items' => $items,
'preview' => $preview,
]);
}
public function emailShow(Package $package): Response
{
$items = $package->items()->latest('id')->paginate(25);
return Inertia::render('Packages/Mail/Show', [
'package' => $package,
'items' => $items,
]);
}
public function store(StorePackageRequest $request): RedirectResponse
{
$data = $request->validated();
@@ -260,7 +339,11 @@ public function dispatch(Package $package): RedirectResponse
return back()->with('error', 'Package not in a dispatchable state.');
}
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) {
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) use ($package) {
if ($package->type === Package::TYPE_EMAIL) {
return new PackageItemEmailJob($item->id);
}
return new PackageItemSmsJob($item->id);
})->all();
@@ -286,7 +369,7 @@ public function dispatch(Package $package): RedirectResponse
$package->save();
}
})
->onQueue('sms')
->onQueue($package->type === Package::TYPE_EMAIL ? 'email' : 'sms')
->dispatch();
return back()->with('success', 'Package dispatched');
@@ -445,6 +528,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
'number' => $phone->nu,
'validated' => $phone->validated,
'type' => $phone->phone_type?->value,
'description' => $phone->description,
] : null,
'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'),
];
@@ -541,6 +625,213 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
return back()->with('success', 'Package created from contracts');
}
/**
* List contracts with selected email per person (for email packages).
*/
public function contractsForEmail(Request $request, EmailSelector $selector): \Illuminate\Http\JsonResponse
{
$request->validate([
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'only_verified' => ['nullable', 'boolean'],
'only_with_email' => ['nullable', 'boolean'],
'start_date_from' => ['nullable', 'date'],
'start_date_to' => ['nullable', 'date'],
'promise_date_from' => ['nullable', 'date'],
'promise_date_to' => ['nullable', 'date'],
]);
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
$query = Contract::query()
->with([
'clientCase.person.emails',
'clientCase.client.person',
'account',
'segments:id,name',
])
->select('contracts.*')
->latest('contracts.id');
if ($segmentId) {
$query->join('contract_segment', function ($j) use ($segmentId) {
$j->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.segment_id', '=', $segmentId)
->where('contract_segment.active', true);
});
} else {
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
->from('contract_segment')
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
->where('contract_segment.active', true)
->where('segments.exclude', false)
->whereColumn('contract_segment.contract_id', 'contracts.id')
);
}
if ($q = trim((string) $request->input('q'))) {
$query->where('contracts.reference', 'ILIKE', "%{$q}%");
}
if ($clientId = $request->integer('client_id')) {
$query->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
->where('client_cases.client_id', $clientId);
}
if ($startDateFrom = $request->input('start_date_from')) {
$query->where('contracts.start_date', '>=', $startDateFrom);
}
if ($startDateTo = $request->input('start_date_to')) {
$query->where('contracts.start_date', '<=', $startDateTo);
}
$promiseDateFrom = $request->input('promise_date_from');
$promiseDateTo = $request->input('promise_date_to');
if ($promiseDateFrom || $promiseDateTo) {
$query->whereHas('account', function ($q) use ($promiseDateFrom, $promiseDateTo) {
if ($promiseDateFrom) {
$q->where('promise_date', '>=', $promiseDateFrom);
}
if ($promiseDateTo) {
$q->where('promise_date', '<=', $promiseDateTo);
}
});
}
if ($request->boolean('only_verified')) {
$query->whereHas('clientCase.person.emails', function ($q) {
$q->where('is_active', true)->whereNotNull('verified_at');
});
}
if ($request->boolean('only_with_email')) {
$query->whereHas('clientCase.person.emails', function ($q) {
$q->where('is_active', true);
});
}
$contracts = $query->limit(500)->get();
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
$person = $contract->clientCase?->person;
$selected = $person ? $selector->selectForPerson($person) : ['email' => null, 'reason' => 'no_person'];
$email = $selected['email'];
$clientPerson = $contract->clientCase?->client?->person;
$segment = collect($contract->segments)->last();
return [
'id' => $contract->id,
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'start_date' => $contract->start_date,
'promise_date' => $contract->account?->promise_date,
'case' => [
'id' => $contract->clientCase?->id,
'uuid' => $contract->clientCase?->uuid,
],
'person' => [
'id' => $person?->id,
'uuid' => $person?->uuid,
'full_name' => $person?->full_name,
],
'segment' => $segment,
'client' => $clientPerson ? [
'id' => $contract->clientCase?->client?->id,
'uuid' => $contract->clientCase?->client?->uuid,
'name' => $clientPerson->full_name,
] : null,
'selected_email' => $email ? [
'id' => $email->id,
'value' => $email->value,
'is_primary' => $email->is_primary,
'verified' => $email->verified_at !== null,
'label' => $email->label,
] : null,
'no_email_reason' => $email ? null : ($selected['reason'] ?? 'unknown'),
];
});
return response()->json(['data' => $data]);
}
/**
* Create an email package from a list of contracts by selecting recipient emails.
*/
public function storeEmailFromContracts(StoreEmailPackageFromContractsRequest $request, EmailSelector $selector): RedirectResponse
{
$data = $request->validated();
$contracts = Contract::query()
->with(['clientCase.person', 'account.type'])
->whereIn('id', $data['contract_ids'])
->get();
$items = [];
$skipped = 0;
foreach ($contracts as $contract) {
$person = $contract->clientCase?->person;
if (! $person) {
$skipped++;
continue;
}
$selected = $selector->selectForPerson($person);
/** @var ?\App\Models\Email $email */
$email = $selected['email'];
if (! $email) {
$skipped++;
continue;
}
$items[] = [
'email' => $email->value,
'email_id' => $email->id,
'payload' => $data['payload'] ?? [],
'contract_id' => $contract->id,
'account_id' => $contract->account?->id,
];
}
if (empty($items)) {
return back()->with('error', 'No recipients found for selected contracts.');
}
$package = Package::query()->create([
'uuid' => (string) Str::uuid(),
'type' => Package::TYPE_EMAIL,
'status' => Package::STATUS_DRAFT,
'name' => $data['name'] ?? null,
'description' => $data['description'] ?? null,
'meta' => array_merge($data['meta'] ?? [], [
'source' => 'contracts',
'skipped' => $skipped,
]),
'created_by' => optional($request->user())->id,
]);
$packageItems = collect($items)->map(function (array $row) {
return new PackageItem([
'status' => 'queued',
'target_json' => [
'email' => $row['email'],
'email_id' => $row['email_id'],
'contract_id' => $row['contract_id'] ?? null,
'account_id' => $row['account_id'] ?? null,
],
'payload_json' => $row['payload'] ?? [],
]);
});
$package->items()->saveMany($packageItems);
$package->total_items = $packageItems->count();
$package->save();
return back()->with('success', 'Email package created from contracts');
}
/**
* Flatten nested meta structure into dot-notation key-value pairs.
* Extracts 'value' from objects with {title, value, type} structure.
@@ -921,6 +921,18 @@ public function show(ClientCase $clientCase)
->select(['id', 'name', 'content', 'allow_custom_body'])
->orderBy('name')
->get(),
'email_templates' => \App\Models\EmailTemplate::query()
->select(['id', 'name', 'subject_template', 'text_template', 'action_id', 'decision_id'])
->where('active', true)
->where('client', false)
->orderBy('name')
->get(),
'mail_profiles' => \App\Models\MailProfile::query()
->select(['id', 'name'])
->where('active', true)
->orderBy('priority')
->orderBy('name')
->get(),
'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']),
]);
}
@@ -1575,6 +1587,161 @@ public function previewSms(ClientCase $clientCase, Request $request, SmsService
* Extracts 'value' from objects with {title, value, type} structure.
* Also creates direct access aliases for nested fields (skipping numeric keys).
*/
/**
* Render an email template preview with context from the client case.
*/
public function previewEmailForEmail(ClientCase $clientCase, Request $request, int $email_id): \Illuminate\Http\JsonResponse
{
$validated = $request->validate([
'template_id' => ['required', 'integer', 'exists:email_templates,id'],
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
'body_text' => ['sometimes', 'nullable', 'string', 'max:10000'],
]);
$email = \App\Models\Email::query()
->where('id', $email_id)
->where('person_id', $clientCase->person_id)
->firstOrFail();
$template = \App\Models\EmailTemplate::findOrFail((int) $validated['template_id']);
$contract = null;
if (! empty($validated['contract_uuid'])) {
$contract = $clientCase->contracts()
->where('uuid', $validated['contract_uuid'])
->first();
}
$ctx = $this->buildCaseEmailContext($clientCase, $contract);
$ctx['body_text'] = (string) ($validated['body_text'] ?? '');
$renderer = app(\App\Services\EmailTemplateRenderer::class);
$rendered = $renderer->render([
'subject' => (string) $template->subject_template,
'html' => (string) $template->html_template,
'text' => (string) $template->text_template,
], $ctx);
return response()->json([
'subject' => $rendered['subject'] ?? '',
'html' => (string) ($rendered['html'] ?? ''),
'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $template->html_template),
]);
}
/**
* Send a (possibly templated) email to a person email address belonging to this case.
*/
public function sendEmailToEmail(ClientCase $clientCase, Request $request, int $email_id)
{
$validated = $request->validate([
'subject' => ['required', 'string', 'max:255'],
'html_body' => ['nullable', 'string'],
'body_text' => ['nullable', 'string', 'max:10000'],
'template_id' => ['sometimes', 'nullable', 'integer', 'exists:email_templates,id'],
'mail_profile_id' => ['sometimes', 'nullable', 'integer', 'exists:mail_profiles,id'],
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
]);
// Ensure the email belongs to the person of this case
$email = \App\Models\Email::query()
->where('id', $email_id)
->where('person_id', $clientCase->person_id)
->firstOrFail();
$to = (string) $email->value;
/** @var \App\Models\MailProfile|null $mailProfile */
$mailProfile = ! empty($validated['mail_profile_id'])
? \App\Models\MailProfile::query()->where('id', $validated['mail_profile_id'])->where('active', true)->first()
: \App\Models\MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first();
if (! $mailProfile) {
return back()->with('error', 'Ni aktivnega e-poštnega profila.');
}
$contract = null;
if (! empty($validated['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
}
$htmlBody = (string) ($validated['html_body'] ?? '');
$bodyText = (string) ($validated['body_text'] ?? '');
// Apply {{body_text}} substitution if the html body contains the placeholder
if ($bodyText !== '' && preg_match('/{{\s*body_text\s*}}/', $htmlBody)) {
$renderer = app(\App\Services\EmailTemplateRenderer::class);
$htmlBody = $renderer->applyBodyText($htmlBody, $bodyText, html: true) ?? $htmlBody;
}
$subject = (string) $validated['subject'];
$log = new \App\Models\EmailLog;
$log->fill([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'template_id' => $validated['template_id'] ?? null,
'mail_profile_id' => $mailProfile->id,
'to_email' => $to,
'to_recipients' => [$to],
'subject' => $subject,
'body_html_hash' => $htmlBody !== '' ? hash('sha256', $htmlBody) : null,
'body_text_preview' => null,
'embed_mode' => 'base64',
'status' => \App\Models\EmailLogStatus::Queued,
'queued_at' => now(),
'client_id' => $clientCase->client_id,
'client_case_id' => $clientCase->id,
'contract_id' => $contract?->id,
'ip' => $request->ip(),
]);
$log->save();
$log->body()->create([
'body_html' => $htmlBody,
'body_text' => $bodyText,
'inline_css' => false,
]);
dispatch(new \App\Jobs\SendEmailTemplateJob($log->id));
// Create activity if template has action/decision
if (! empty($validated['template_id'])) {
$template = \App\Models\EmailTemplate::find((int) $validated['template_id']);
if ($template && ($template->action_id || $template->decision_id)) {
$activity = $clientCase->activities()->create(array_filter([
'contract_id' => $contract?->id,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'note' => 'Poslano: '.$to.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
'user_id' => optional($request->user())->id,
], fn ($v) => ! is_null($v)));
$activity->emailLogs()->attach($log->id);
}
}
return back()->with('success', "E-pošta poslana na {$to}.");
}
/**
* Build a template rendering context from the given client case and optional contract.
*/
private function buildCaseEmailContext(ClientCase $clientCase, ?\App\Models\Contract $contract = null): array
{
$clientCase->loadMissing('client.person');
$ctx = [
'client_case' => $clientCase,
'client' => $clientCase->client,
'person' => optional($clientCase->client)->person,
'mail_profile' => \App\Models\MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first(),
];
if ($contract) {
$contract->loadMissing(['clientCase.client.person', 'account.type']);
$ctx['contract'] = $contract;
}
return $ctx;
}
private function flattenMeta(array $meta, string $prefix = ''): array
{
$result = [];
@@ -136,6 +136,7 @@ public function createEmail(Person $person, Request $request)
'is_primary' => 'boolean',
'is_active' => 'boolean',
'valid' => 'boolean',
'failed' => 'boolean',
'receive_auto_mails' => 'sometimes|boolean',
'verified_at' => 'nullable|date',
'preferences' => 'nullable|array',
@@ -164,6 +165,7 @@ public function updateEmail(Person $person, int $email_id, Request $request)
'is_primary' => 'boolean',
'is_active' => 'boolean',
'valid' => 'boolean',
'failed' => 'boolean',
'receive_auto_mails' => 'sometimes|boolean',
'verified_at' => 'nullable|date',
'preferences' => 'nullable|array',
+43 -6
View File
@@ -43,7 +43,7 @@ public function show(string $slug, Request $request)
$inputs = $this->buildInputsArray($report);
$filters = $this->validateFilters($inputs, $request);
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
$perPage = (int) ($request->integer('per_page') ?: 25);
$query = $this->queryBuilder->build($report, $filters);
$paginator = $query->paginate($perPage);
@@ -279,16 +279,51 @@ public function clients(Request $request)
$clients = \App\Models\Client::query()
->with('person:id,full_name')
->get()
->map(fn($c) => [
->map(fn ($c) => [
'id' => $c->uuid,
'name' => $c->person->full_name ?? 'Unknown'
'name' => $c->person->full_name ?? 'Unknown',
])
->sortBy('name')
->values();
return response()->json($clients);
}
/**
* Lightweight actions lookup for select:action filters.
*/
public function actions(Request $request)
{
$actions = \App\Models\Action::query()
->orderBy('name')
->get(['id', 'name'])
->map(fn ($a) => ['id' => $a->id, 'name' => $a->name])
->values();
return response()->json($actions);
}
/**
* Lightweight decisions lookup for select:decision filters.
* Optionally filtered by action_id (for dependent filter UI).
*/
public function decisions(Request $request)
{
$actionId = $request->integer('action_id', 0) ?: null;
$q = \App\Models\Decision::query()->orderBy('name');
if ($actionId !== null) {
$q->whereHas('actions', fn ($qq) => $qq->where('actions.id', $actionId));
}
$decisions = $q->get(['id', 'name'])
->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])
->values();
return response()->json($decisions);
}
/**
* Build validation rules based on inputs descriptor and validate.
*
@@ -307,6 +342,8 @@ protected function validateFilters(array $inputs, Request $request): array
'integer' => [$nullable, 'integer'],
'select:user' => [$nullable, 'integer', 'exists:users,id'],
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
'select:action' => [$nullable, 'integer', 'exists:actions,id'],
'select:decision' => [$nullable, 'integer', 'exists:decisions,id'],
default => [$nullable, 'string'],
};
}
@@ -319,7 +356,7 @@ protected function validateFilters(array $inputs, Request $request): array
*/
protected function buildInputsArray(Report $report): array
{
return $report->filters->map(fn($filter) => [
return $report->filters->map(fn ($filter) => [
'key' => $filter->key,
'type' => $filter->type,
'label' => $filter->label,
@@ -336,7 +373,7 @@ protected function buildColumnsArray(Report $report): array
{
return $report->columns
->where('visible', true)
->map(fn($col) => [
->map(fn ($col) => [
'key' => $col->key,
'label' => $col->label,
])
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEmailPackageFromContractsRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'type' => ['required', 'in:email'],
'name' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'meta' => ['nullable', 'array'],
// Common payload for all items
'payload' => ['required', 'array'],
'payload.mail_profile_id' => ['nullable', 'integer', 'exists:mail_profiles,id'],
'payload.template_id' => ['nullable', 'integer', 'exists:email_templates,id'],
'payload.subject' => ['nullable', 'string', 'max:255'],
'payload.body_text' => ['nullable', 'string', 'max:10000'],
'payload.variables' => ['nullable', 'array'],
// Source contracts to derive items from
'contract_ids' => ['required', 'array', 'min:1'],
'contract_ids.*' => ['integer', 'exists:contracts,id'],
];
}
}
@@ -23,6 +23,9 @@ public function rules(): array
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
'allow_attachments' => ['sometimes', 'boolean'],
'active' => ['boolean'],
'client' => ['sometimes', 'boolean'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
];
}
}
@@ -26,6 +26,8 @@ public function rules(): array
'reply_to_name' => ['nullable', 'string', 'max:190'],
'priority' => ['nullable', 'integer', 'between:0,65535'],
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
'signature' => ['nullable', 'array'],
'signature.*' => ['nullable', 'string', 'max:1000'],
];
}
}
@@ -25,6 +25,9 @@ public function rules(): array
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
'allow_attachments' => ['sometimes', 'boolean'],
'active' => ['boolean'],
'client' => ['sometimes', 'boolean'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
];
}
}
@@ -27,6 +27,8 @@ public function rules(): array
'priority' => ['nullable', 'integer', 'between:0,65535'],
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
'active' => ['nullable', 'boolean'],
'signature' => ['nullable', 'array'],
'signature.*' => ['nullable', 'string', 'max:1000'],
];
}
}
+296
View File
@@ -0,0 +1,296 @@
<?php
namespace App\Jobs;
use App\Models\Contract;
use App\Models\Email;
use App\Models\EmailLog;
use App\Models\EmailLogStatus;
use App\Models\EmailTemplate;
use App\Models\MailProfile;
use App\Models\Package;
use App\Models\PackageItem;
use App\Services\EmailSender;
use App\Services\EmailTemplateRenderer;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
class PackageItemEmailJob implements ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $packageItemId)
{
$this->onQueue('email');
}
public function handle(EmailTemplateRenderer $renderer, EmailSender $sender): void
{
/** @var PackageItem|null $item */
$item = PackageItem::query()->find($this->packageItemId);
if (! $item) {
return;
}
/** @var Package $package */
$package = $item->package;
if (! $package || $package->status === Package::STATUS_CANCELED) {
return;
}
if (in_array($item->status, ['sent', 'failed', 'canceled', 'skipped'], true)) {
return;
}
if ($item->status === 'queued') {
$item->status = 'processing';
$item->save();
$package->increment('processing_count');
}
$payload = (array) $item->payload_json;
$target = (array) $item->target_json;
$to = $target['email'] ?? null;
if (! is_string($to) || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
$item->status = 'failed';
$item->last_error = 'Missing or invalid recipient email.';
$item->save();
$this->updatePackageCounters($item, $package);
return;
}
$templateId = $payload['template_id'] ?? null;
$mailProfileId = $payload['mail_profile_id'] ?? null;
$variables = (array) ($payload['variables'] ?? []);
$subjectOverride = isset($payload['subject']) ? trim((string) $payload['subject']) : null;
if ($subjectOverride === '') {
$subjectOverride = null;
}
$bodyText = isset($payload['body_text']) ? (string) $payload['body_text'] : '';
// Enrich variables with contract/account context when available
$contract = null;
if (! empty($target['contract_id'])) {
$contract = Contract::query()->with(['clientCase.person', 'account.type'])->find($target['contract_id']);
if ($contract) {
$variables['contract'] = [
'id' => $contract->id,
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'start_date' => (string) ($contract->start_date ?? ''),
'end_date' => (string) ($contract->end_date ?? ''),
];
if (is_array($contract->meta) && ! empty($contract->meta)) {
$variables['contract']['meta'] = $this->flattenMeta($contract->meta);
}
if ($contract->account) {
$initialRaw = (string) $contract->account->initial_amount;
$balanceRaw = (string) $contract->account->balance_amount;
$variables['account'] = [
'id' => $contract->account->id,
'reference' => $contract->account->reference,
'initial_amount' => $this->formatAmountEu($initialRaw),
'balance_amount' => $this->formatAmountEu($balanceRaw),
'initial_amount_raw' => $initialRaw,
'balance_amount_raw' => $balanceRaw,
'type' => $contract->account->type?->name,
];
}
if ($contract->clientCase?->person) {
$person = $contract->clientCase->person;
$variables['person'] = [
'full_name' => $person->full_name,
'first_name' => $person->first_name,
'last_name' => $person->last_name,
];
}
}
}
/** @var EmailTemplate|null $template */
$template = $templateId ? EmailTemplate::with(['action', 'decision'])->find((int) $templateId) : null;
/** @var MailProfile|null $mailProfile */
$mailProfile = $mailProfileId
? MailProfile::find((int) $mailProfileId)
: MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first();
try {
if (! $template && ! $subjectOverride) {
throw new \RuntimeException('No email template or subject provided.');
}
$rendered = $template
? $renderer->render([
'subject' => $subjectOverride ?? (string) $template->subject_template,
'html' => (string) $template->html_template,
'text' => (string) $template->text_template,
], array_filter([
'contract' => $contract,
'person' => $contract?->clientCase?->person,
'client' => $contract?->clientCase?->client,
'client_case' => $contract?->clientCase,
'mail_profile' => $mailProfile,
'extra' => $variables,
'body_text' => $bodyText !== '' ? $bodyText : null,
]))
: [
'subject' => $subjectOverride ?? '',
'html' => null,
'text' => null,
];
$log = new EmailLog;
$log->fill([
'uuid' => (string) Str::uuid(),
'template_id' => $template?->id,
'mail_profile_id' => $mailProfile?->id,
'to_email' => $to,
'to_recipients' => [$to],
'subject' => $rendered['subject'],
'body_html_hash' => isset($rendered['html']) ? hash('sha256', (string) $rendered['html']) : null,
'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null,
'embed_mode' => 'base64',
'status' => EmailLogStatus::Queued,
'queued_at' => now(),
'contract_id' => $contract?->id,
'client_id' => $contract?->clientCase?->client?->id,
'client_case_id' => $contract?->clientCase?->id,
'extra_context' => ['package_id' => $item->package_id, 'package_item_id' => $item->id],
]);
$log->save();
$log->body()->create([
'body_html' => (string) ($rendered['html'] ?? ''),
'body_text' => (string) ($rendered['text'] ?? ''),
'inline_css' => true,
]);
// Send directly (synchronous within job context)
$start = microtime(true);
$log->status = EmailLogStatus::Sending;
$log->started_at = now();
$log->attempt = 1;
$log->save();
$sender->sendFromLog($log);
$log->status = EmailLogStatus::Sent;
$log->sent_at = now();
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
$log->save();
$item->status = 'sent';
$item->result_json = ['email_log_id' => $log->id, 'subject' => $rendered['subject']];
$item->last_error = null;
$item->save();
// Clear failed flag on successful delivery
Email::query()->where('value', $to)->where('failed', true)->update(['failed' => false]);
// Create activity if the template has action/decision configured
if ($template && ($template->action_id || $template->decision_id) && $contract && $contract->client_case_id) {
$activity = \App\Models\Activity::create(array_filter([
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'note' => 'Poslano: '.$to.', Uspešno'.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
]));
$activity->emailLogs()->attach($log->id);
}
} catch (\Throwable $e) {
$item->status = 'failed';
$item->last_error = $e->getMessage();
$item->save();
// Create activity for failed send if the template has action/decision configured
if ($template && ($template->action_id || $template->decision_id) && isset($contract) && $contract && $contract->client_case_id) {
$shortError = mb_strimwidth($e->getMessage(), 0, 120, '…');
$activity = \App\Models\Activity::create(array_filter([
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'note' => 'Poslano: '.$to.', Napaka pri pošiljanju: '.$shortError.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''),
]));
if (isset($log) && $log->exists) {
$activity->emailLogs()->attach($log->id);
}
}
// Mark the email address as failed in the DB.
if (isset($to)) {
Email::query()
->where('value', $to)
->update(['failed' => true]);
}
// Permanent SMTP rejection (550 user unknown, 551 not local, 553 invalid address)
// means the address definitively does not exist — also mark it invalid.
if ($e instanceof \Symfony\Component\Mailer\Exception\TransportExceptionInterface
&& preg_match('/\b55[013]\b/', $e->getMessage())
&& isset($to)) {
Email::query()
->where('value', $to)
->update(['valid' => false]);
}
}
$this->updatePackageCounters($item, $package);
}
private function updatePackageCounters(PackageItem $item, Package $package): void
{
if ($item->status === 'sent') {
$package->increment('sent_count');
} else {
$package->increment('failed_count');
}
$package->decrement('processing_count');
$package->refresh();
$done = $package->sent_count + $package->failed_count;
if ($done >= $package->total_items) {
$package->status = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED;
$package->finished_at = now();
$package->save();
}
}
private function flattenMeta(array $meta, string $prefix = ''): array
{
$result = [];
foreach ($meta as $key => $value) {
$newKey = $prefix === '' ? $key : "{$prefix}.{$key}";
if (is_array($value)) {
if (isset($value['value'])) {
$result[$newKey] = $value['value'];
} else {
$nested = $this->flattenMeta($value, $newKey);
$result = array_merge($result, $nested);
}
} else {
$result[$newKey] = $value;
}
}
return $result;
}
private function formatAmountEu(string $raw): string
{
$numeric = preg_replace('/[^0-9.]/', '', $raw);
$float = (float) $numeric;
return number_format($float, 2, ',', '.');
}
}
+5
View File
@@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Models\Email;
use App\Models\EmailLog;
use App\Models\EmailLogStatus;
use App\Services\EmailSender;
@@ -53,6 +54,10 @@ public function handle(): void
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
$log->save();
if ($log->to_email) {
Email::query()->where('value', $log->to_email)->update(['failed' => true]);
}
throw $e;
}
}
+6
View File
@@ -7,6 +7,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Activity extends Model
@@ -159,4 +160,9 @@ public function callLaters(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(\App\Models\CallLater::class);
}
public function emailLogs(): BelongsToMany
{
return $this->belongsToMany(EmailLog::class, 'activity_email_logs');
}
}
+2
View File
@@ -18,6 +18,7 @@ class Email extends Model
'is_primary',
'is_active',
'valid',
'failed',
'receive_auto_mails',
'verified_at',
'preferences',
@@ -28,6 +29,7 @@ class Email extends Model
'is_primary' => 'boolean',
'is_active' => 'boolean',
'valid' => 'boolean',
'failed' => 'boolean',
'receive_auto_mails' => 'boolean',
'verified_at' => 'datetime',
'preferences' => 'array',
+6
View File
@@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
enum EmailLogStatus: string
@@ -83,4 +84,9 @@ public function body(): HasOne
{
return $this->hasOne(EmailLogBody::class, 'email_log_id');
}
public function activities(): BelongsToMany
{
return $this->belongsToMany(Activity::class, 'activity_email_logs');
}
}
+15
View File
@@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class EmailTemplate extends Model
@@ -19,10 +20,14 @@ class EmailTemplate extends Model
'entity_types',
'allow_attachments',
'active',
'action_id',
'decision_id',
'client',
];
protected $casts = [
'active' => 'boolean',
'client' => 'boolean',
'entity_types' => 'array',
'allow_attachments' => 'boolean',
];
@@ -31,4 +36,14 @@ public function documents(): MorphMany
{
return $this->morphMany(Document::class, 'documentable');
}
public function action(): BelongsTo
{
return $this->belongsTo(Action::class);
}
public function decision(): BelongsTo
{
return $this->belongsTo(Decision::class);
}
}
+2 -1
View File
@@ -11,12 +11,13 @@ class MailProfile extends Model
protected $fillable = [
'name', 'active', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
'reply_to_address', 'reply_to_name', 'priority', 'max_daily_quota', 'emails_sent_today',
'reply_to_address', 'reply_to_name', 'priority', 'signature', 'max_daily_quota', 'emails_sent_today',
'last_success_at', 'last_error_at', 'last_error_message', 'failover_to_id', 'test_status', 'test_checked_at',
];
protected $casts = [
'active' => 'boolean',
'signature' => 'array',
'last_success_at' => 'datetime',
'last_error_at' => 'datetime',
'test_checked_at' => 'datetime',
+2
View File
@@ -34,6 +34,8 @@ public function items()
public const TYPE_SMS = 'sms';
public const TYPE_EMAIL = 'email';
public const STATUS_DRAFT = 'draft';
public const STATUS_QUEUED = 'queued';
+21 -2
View File
@@ -90,7 +90,24 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
// Ensure related names are available without extra queries
$activity->loadMissing(['action', 'decision']);
// Ensure account is available on contract (needed for contract.account.* tokens)
if ($contract && ! $contract->relationLoaded('account')) {
$contract->load('account');
}
// Resolve the sending profile once — used both for signature tokens and as the actual sender.
// Prefer the profile explicitly requested via options, fall back to highest-priority active one.
$mailProfile = isset($options['mail_profile_id'])
? MailProfile::query()->find($options['mail_profile_id'])
: null;
$mailProfile ??= MailProfile::query()
->where('active', true)
->orderBy('priority')
->orderBy('id')
->first();
// Render content
$bodyText = isset($options['body_text']) ? (string) $options['body_text'] : '';
$rendered = $this->renderer->render([
'subject' => (string) $template->subject_template,
'html' => (string) $template->html_template,
@@ -102,6 +119,8 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
'person' => $person,
'activity' => $activity,
'extra' => [],
'mail_profile' => $mailProfile,
'body_text' => $bodyText,
]);
// Create the log and body
@@ -109,7 +128,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
$log->fill([
'uuid' => (string) \Str::uuid(),
'template_id' => $template->id,
'mail_profile_id' => optional(MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first())->id,
'mail_profile_id' => $mailProfile?->id,
'user_id' => auth()->id(),
'to_email' => (string) ($recipients[0] ?? ''),
'to_recipients' => $recipients,
@@ -149,7 +168,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
$log->body()->create([
'body_html' => (string) ($rendered['html'] ?? ''),
'body_text' => (string) ($rendered['text'] ?? ''),
'body_text' => $bodyText !== '' ? $bodyText : (string) ($rendered['text'] ?? ''),
'inline_css' => true,
]);
+1 -2
View File
@@ -40,7 +40,6 @@ public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Co
$query->forSegment($segmentId);
}
return $query->get();
}
@@ -55,7 +54,7 @@ public function getActivities(
int $perPage = 20
): LengthAwarePaginator {
$query = $clientCase->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name', 'emailLogs:id'])
->orderByDesc('created_at');
if (! empty($segmentId)) {
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Services\Contact;
use App\Models\Email;
use App\Models\Person\Person;
class EmailSelector
{
/**
* Select the best email for a person following priority rules.
* Priority:
* 1) verified primary email that is active
* 2) primary email that is active
* 3) any active and valid email
* 4) first active email
*
* Returns an array shape: ['email' => ?Email, 'reason' => ?string]
*/
public function selectForPerson(Person $person): array
{
$emails = Email::query()
->where('person_id', $person->id)
->where('is_active', true)
->orderBy('is_primary', 'desc')
->orderBy('id')
->get();
if ($emails->isEmpty()) {
return ['email' => null, 'reason' => 'no_active_emails'];
}
// 1) verified primary
$email = $emails->first(fn (Email $e) => $e->is_primary && $e->verified_at !== null);
if ($email) {
return ['email' => $email, 'reason' => null];
}
// 2) primary (any verification)
$email = $emails->first(fn (Email $e) => $e->is_primary);
if ($email) {
return ['email' => $email, 'reason' => null];
}
// 3) valid (any)
$email = $emails->first(fn (Email $e) => $e->valid);
if ($email) {
return ['email' => $email, 'reason' => null];
}
// 4) first active
return ['email' => $emails->first(), 'reason' => null];
}
}
+37 -4
View File
@@ -30,17 +30,41 @@ public function render(array $template, array $ctx): array
return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($m) use ($map) {
$key = $m[1];
// body_text is handled separately by applyBodyText(); preserve as literal
if ($key === 'body_text') {
return $m[0];
}
return (string) data_get($map, $key, '');
}, $input);
};
$bodyText = isset($ctx['body_text']) ? (string) $ctx['body_text'] : '';
return [
'subject' => $replacer($template['subject']) ?? '',
'html' => $replacer($template['html'] ?? null) ?? null,
'text' => $replacer($template['text'] ?? null) ?? null,
'html' => $this->applyBodyText($replacer($template['html'] ?? null) ?? null, $bodyText, html: true),
'text' => $this->applyBodyText($replacer($template['text'] ?? null) ?? null, $bodyText, html: false),
];
}
/**
* Substitute the literal {{body_text}} placeholder with the user-supplied body text.
* In HTML context the text is HTML-escaped and newlines are converted to <br>.
* In plain-text context the raw value is used.
*/
public function applyBodyText(?string $content, string $bodyText, bool $html = true): ?string
{
if ($content === null) {
return null;
}
$replacement = $html
? nl2br(htmlspecialchars($bodyText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'))
: $bodyText;
return preg_replace('/{{\s*body_text\s*}}/', $replacement, $content);
}
/**
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
*/
@@ -145,8 +169,11 @@ protected function buildMap(array $ctx): array
'id' => data_get($co, 'id'),
'uuid' => data_get($co, 'uuid'),
'reference' => data_get($co, 'reference'),
// Format amounts in EU style for emails
'amount' => $formatMoneyEu(data_get($co, 'amount')),
// Account amounts — sourced from the related Account model
'account' => [
'balance_amount' => $formatMoneyEu(data_get($co, 'account.balance_amount')),
'initial_amount' => $formatMoneyEu(data_get($co, 'account.initial_amount')),
],
];
$meta = data_get($co, 'meta');
if (is_array($meta)) {
@@ -172,6 +199,12 @@ protected function buildMap(array $ctx): array
if (! empty($ctx['extra']) && is_array($ctx['extra'])) {
$out['extra'] = $ctx['extra'];
}
if (isset($ctx['mail_profile'])) {
$mp = $ctx['mail_profile'];
$out['profile'] = [
'signature' => is_array($mp->signature) ? $mp->signature : [],
];
}
return $out;
}
@@ -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('mail_profiles', function (Blueprint $table) {
$table->jsonb('signature')->nullable()->after('priority');
});
}
public function down(): void
{
Schema::table('mail_profiles', function (Blueprint $table) {
$table->dropColumn('signature');
});
}
};
@@ -0,0 +1,25 @@
<?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('email_templates', function (Blueprint $table): void {
$table->foreignId('action_id')->nullable()->after('active')->constrained('actions')->nullOnDelete();
$table->foreignId('decision_id')->nullable()->after('action_id')->constrained('decisions')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('email_templates', function (Blueprint $table): void {
$table->dropForeign(['action_id']);
$table->dropForeign(['decision_id']);
$table->dropColumn(['action_id', 'decision_id']);
});
}
};
@@ -0,0 +1,25 @@
<?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('activity_email_logs', function (Blueprint $table): void {
$table->id();
$table->foreignId('activity_id')->constrained('activities')->cascadeOnDelete();
$table->foreignId('email_log_id')->constrained('email_logs')->cascadeOnDelete();
$table->timestamps();
$table->unique(['activity_id', 'email_log_id']);
});
}
public function down(): void
{
Schema::dropIfExists('activity_email_logs');
}
};
@@ -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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('emails', function (Blueprint $table) {
$table->boolean('failed')->default(false)->after('valid');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('emails', function (Blueprint $table) {
$table->dropColumn('failed');
});
}
};
@@ -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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('email_templates', function (Blueprint $table): void {
$table->boolean('client')->default(false)->after('active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('email_templates', function (Blueprint $table): void {
$table->dropColumn('client');
});
}
};
+262
View File
@@ -21,6 +21,7 @@ public function run(): void
$this->seedSegmentActivityCountsReport();
$this->seedActionsDecisionsCountReport();
$this->seedActivitiesPerPeriodReport();
$this->seedActivitiesDetailReport();
}
protected function seedActiveContractsReport(): void
@@ -783,4 +784,265 @@ protected function seedActivitiesPerPeriodReport(): void
'order' => 0,
]);
}
protected function seedActivitiesDetailReport(): void
{
$report = Report::create([
'slug' => 'activities-detail',
'name' => 'Aktivnosti pregled',
'description' => 'Podroben pregled aktivnosti z možnostjo filtriranja po stranki, datumu, akciji in odločitvi.',
'category' => 'activities',
'enabled' => true,
'order' => 7,
]);
// Entities (joins)
$report->entities()->create([
'model_class' => 'App\\Models\\Activity',
'join_type' => 'base',
'order' => 0,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Action',
'join_type' => 'leftJoin',
'join_first' => 'activities.action_id',
'join_operator' => '=',
'join_second' => 'actions.id',
'order' => 1,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Decision',
'join_type' => 'leftJoin',
'join_first' => 'activities.decision_id',
'join_operator' => '=',
'join_second' => 'decisions.id',
'order' => 2,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Contract',
'join_type' => 'leftJoin',
'join_first' => 'activities.contract_id',
'join_operator' => '=',
'join_second' => 'contracts.id',
'order' => 3,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\ClientCase',
'join_type' => 'leftJoin',
'join_first' => 'activities.client_case_id',
'join_operator' => '=',
'join_second' => 'client_cases.id',
'order' => 4,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Client',
'join_type' => 'leftJoin',
'join_first' => 'client_cases.client_id',
'join_operator' => '=',
'join_second' => 'clients.id',
'order' => 5,
]);
$report->entities()->createMany([
[
'model_class' => 'App\\Models\\Person\\Person',
'alias' => 'client_people',
'join_type' => 'leftJoin',
'join_first' => 'clients.person_id',
'join_operator' => '=',
'join_second' => 'client_people.id',
'order' => 6,
],
[
'model_class' => 'App\\Models\\Person\\Person',
'alias' => 'subject_people',
'join_type' => 'leftJoin',
'join_first' => 'client_cases.person_id',
'join_operator' => '=',
'join_second' => 'subject_people.id',
'order' => 7,
],
]);
// Columns
$report->columns()->createMany([
[
'key' => 'contract_reference',
'label' => 'Pogodba',
'type' => 'string',
'expression' => 'contracts.reference',
'sortable' => true,
'visible' => true,
'order' => 0,
],
[
'key' => 'naziv',
'label' => 'Naziv',
'type' => 'string',
'expression' => 'subject_people.full_name',
'sortable' => true,
'visible' => true,
'order' => 1,
],
[
'key' => 'stranka',
'label' => 'Stranka',
'type' => 'string',
'expression' => 'client_people.full_name',
'sortable' => true,
'visible' => true,
'order' => 2,
],
[
'key' => 'aktivnost',
'label' => 'Aktivnost',
'type' => 'string',
'expression' => "CONCAT(COALESCE(actions.name, ''), ' / ', COALESCE(decisions.name, ''))",
'sortable' => false,
'visible' => true,
'order' => 3,
],
[
'key' => 'datum',
'label' => 'Datum',
'type' => 'date',
'expression' => 'DATE(activities.created_at)',
'sortable' => true,
'visible' => true,
'order' => 4,
],
[
'key' => 'opomba',
'label' => 'Opomba',
'type' => 'string',
'expression' => 'activities.note',
'sortable' => false,
'visible' => true,
'order' => 5,
],
[
'key' => 'zapadlost',
'label' => 'Zapadlost',
'type' => 'date',
'expression' => 'activities.due_date',
'sortable' => true,
'visible' => true,
'order' => 6,
],
[
'key' => 'znesek',
'label' => 'Znesek',
'type' => 'currency',
'expression' => 'activities.amount',
'sortable' => true,
'visible' => true,
'order' => 7,
],
]);
// Filters
$report->filters()->createMany([
[
'key' => 'client_uuid',
'label' => 'Stranka',
'type' => 'select:client',
'nullable' => true,
'order' => 0,
],
[
'key' => 'from',
'label' => 'Datum od',
'type' => 'date',
'nullable' => true,
'order' => 1,
],
[
'key' => 'to',
'label' => 'Datum do',
'type' => 'date',
'nullable' => true,
'order' => 2,
],
[
'key' => 'action_id',
'label' => 'Akcija',
'type' => 'select:action',
'nullable' => true,
'order' => 3,
],
[
'key' => 'decision_id',
'label' => 'Odločitev',
'type' => 'select:decision',
'nullable' => true,
'order' => 4,
],
]);
// Conditions (all filter-based, skipped when null)
$report->conditions()->createMany([
[
'column' => 'activities.created_at',
'operator' => '>=',
'value_type' => 'filter',
'filter_key' => 'from',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.created_at',
'operator' => '<=',
'value_type' => 'filter',
'filter_key' => 'to',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 1,
'enabled' => true,
],
[
'column' => 'clients.uuid',
'operator' => '=',
'value_type' => 'filter',
'filter_key' => 'client_uuid',
'logical_operator' => 'AND',
'group_id' => 2,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.action_id',
'operator' => '=',
'value_type' => 'filter',
'filter_key' => 'action_id',
'logical_operator' => 'AND',
'group_id' => 3,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.decision_id',
'operator' => '=',
'value_type' => 'filter',
'filter_key' => 'decision_id',
'logical_operator' => 'AND',
'group_id' => 4,
'order' => 0,
'enabled' => true,
],
]);
// Order
$report->orders()->create([
'column' => 'activities.created_at',
'direction' => 'DESC',
'order' => 0,
]);
}
}
@@ -43,6 +43,8 @@ const formSchema = toTypedSchema(
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
label: z.string().optional(),
receive_auto_mails: z.boolean().optional(),
valid: z.boolean().default(true),
failed: z.boolean().default(false),
decision_ids: z.array(z.string()).optional().default([]),
})
);
@@ -54,6 +56,8 @@ const form = useForm({
value: "",
label: "",
receive_auto_mails: false,
valid: true,
failed: false,
decision_ids: [],
},
});
@@ -78,6 +82,8 @@ const resetForm = () => {
value: "",
label: "",
receive_auto_mails: false,
valid: true,
failed: false,
decision_ids: [],
},
});
@@ -182,6 +188,8 @@ watch(
value: email.value ?? email.email ?? email.address ?? "",
label: email.label ?? "",
receive_auto_mails: !!email.receive_auto_mails,
valid: email.valid !== undefined ? !!email.valid : true,
failed: !!email.failed,
decision_ids: existingDecisionIds,
});
} else {
@@ -272,6 +280,28 @@ const onConfirm = () => {
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="valid">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Veljavna</FormLabel>
</div>
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="failed">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Switch :model-value="value" @update:model-value="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="cursor-pointer">Neuspešna dostava</FormLabel>
</div>
</FormItem>
</FormField>
<!-- Limit to specific decisions only shown when receive_auto_mails is on and decisions exist -->
<template v-if="(props.person?.client || isClientContext) && form.values.receive_auto_mails && decisionOptions.length > 0">
<div class="flex flex-row items-start space-x-3 space-y-0">
@@ -0,0 +1,483 @@
<script setup>
import { ref, watch, computed, nextTick } from "vue";
import axios from "axios";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { router, usePage } from "@inertiajs/vue3";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Button } from "@/Components/ui/button";
import { ScrollArea } from "@/Components/ui/scroll-area";
const props = defineProps({
show: { type: Boolean, default: false },
email: { type: Object, default: null },
clientCaseUuid: { type: String, default: null },
emailTemplates: { type: Array, default: () => [] },
mailProfiles: { type: Array, default: () => [] },
});
const emit = defineEmits(["close"]);
const page = usePage();
const pageProps = computed(() => page?.props ?? {});
const pageEmailTemplates = computed(() => {
const fromProps =
Array.isArray(props.emailTemplates) && props.emailTemplates.length
? props.emailTemplates
: null;
return fromProps ?? pageProps.value?.email_templates ?? [];
});
const pageMailProfiles = computed(() => {
const fromProps =
Array.isArray(props.mailProfiles) && props.mailProfiles.length
? props.mailProfiles
: null;
return fromProps ?? pageProps.value?.mail_profiles ?? [];
});
// Form schema
const formSchema = toTypedSchema(
z.object({
subject: z.string().min(1, "Zadeva je obvezna.").max(255),
html_body: z.string().nullable().optional(),
body_text: z.string().max(10000).nullable().optional(),
template_id: z.number().nullable().optional(),
mail_profile_id: z.number().nullable().optional(),
contract_uuid: z.string().nullable().optional(),
})
);
const form = useForm({
validationSchema: formSchema,
initialValues: {
subject: "",
html_body: "",
body_text: "",
template_id: null,
mail_profile_id: null,
contract_uuid: null,
},
});
const processing = ref(false);
const contractsForCase = ref([]);
const hasBodyText = ref(false); // whether selected template uses {{body_text}}
// WYSIWYG iframe
const iframeRef = ref(null);
let iframeSyncing = false;
function ensureFullDoc(html) {
if (!html) {
return '<!doctype html><html><head><meta charset="utf-8" /></head><body></body></html>';
}
if (/<html[\s\S]*<\/html>/i.test(html)) return html;
return `<!doctype html><html><head><meta charset="utf-8" /></head><body>${html}</body></html>`;
}
function writeIframeDocument(html) {
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
const full = ensureFullDoc(html ?? form.values.html_body ?? "");
doc.open();
doc.write(full);
doc.close();
try {
doc.body.setAttribute("spellcheck", "false");
} catch {}
}
function initIframeEditor(html) {
writeIframeDocument(html);
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
try {
doc.designMode = "on";
} catch {}
const syncHandler = () => {
if (iframeSyncing) return;
try {
iframeSyncing = true;
const full = doc.documentElement.outerHTML;
form.setFieldValue("html_body", full);
} finally {
iframeSyncing = false;
}
};
doc.removeEventListener("input", syncHandler);
doc.removeEventListener("keyup", syncHandler);
doc.addEventListener("input", syncHandler);
doc.addEventListener("keyup", syncHandler);
}
function iframeExec(command) {
const iframe = iframeRef.value;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
try {
doc.body.focus();
} catch {}
try {
doc.execCommand(command, false, null);
} catch (e) {
console.warn("execCommand failed", command, e);
}
}
// Load template preview from server
const loadingPreview = ref(false);
const updateFromTemplate = async () => {
if (!form.values.template_id || !props.clientCaseUuid) return;
loadingPreview.value = true;
try {
const url = route("clientCase.email.preview", {
client_case: props.clientCaseUuid,
email_id: props.email?.id,
});
const { data } = await axios.post(url, {
template_id: form.values.template_id,
contract_uuid: form.values.contract_uuid || null,
body_text: form.values.body_text || "",
});
const hadBodyText = hasBodyText.value;
hasBodyText.value = !!data?.has_body_text;
// Pre-fill body_text from text_template when the placeholder is present and field is empty
if (data?.has_body_text && !hadBodyText) {
const tpl = pageEmailTemplates.value.find((t) => t.id === form.values.template_id);
if (tpl?.text_template && !form.values.body_text) {
form.setFieldValue("body_text", tpl.text_template);
}
}
if (data?.subject) {
form.setFieldValue("subject", data.subject);
}
const html = data?.html ?? "";
form.setFieldValue("html_body", html);
await nextTick();
initIframeEditor(html);
} catch (e) {
// ignore
} finally {
loadingPreview.value = false;
}
};
watch(
() => form.values.template_id,
() => {
updateFromTemplate();
}
);
watch(
() => form.values.contract_uuid,
() => {
if (form.values.template_id) {
updateFromTemplate();
}
}
);
// Re-preview when body_text changes (debounce-like: only when a template is active)
watch(
() => form.values.body_text,
() => {
if (form.values.template_id && hasBodyText.value) {
updateFromTemplate();
}
}
);
const loadContractsForCase = async () => {
try {
const url = route("clientCase.contracts.list", {
client_case: props.clientCaseUuid,
});
const res = await fetch(url, {
headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin",
});
const json = await res.json();
contractsForCase.value = Array.isArray(json?.data) ? json.data : [];
} catch (e) {
contractsForCase.value = [];
}
};
watch(
() => props.show,
async (newVal) => {
if (newVal) {
form.resetForm({
values: {
subject: "",
html_body: "",
body_text: "",
template_id: null,
mail_profile_id: pageMailProfiles.value?.[0]?.id ?? null,
contract_uuid: null,
},
});
hasBodyText.value = false;
contractsForCase.value = [];
await loadContractsForCase();
// Init empty iframe
await nextTick();
initIframeEditor("");
}
}
);
const closeDialog = () => {
emit("close");
};
const onSubmit = form.handleSubmit((values) => {
if (!props.email || !props.clientCaseUuid) return;
processing.value = true;
router.post(
route("clientCase.email.send", {
client_case: props.clientCaseUuid,
email_id: props.email.id,
}),
values,
{
preserveScroll: true,
onSuccess: () => {
processing.value = false;
closeDialog();
},
onError: () => {
processing.value = false;
},
onFinish: () => {
processing.value = false;
},
}
);
});
const open = computed({
get: () => props.show,
set: (value) => {
if (!value) closeDialog();
},
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Pošlji e-pošto</DialogTitle>
<DialogDescription>
<p class="text-sm text-gray-600">
Prejemnik:
<span class="font-mono">{{ email?.value || email?.email || email?.address }}</span>
</p>
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[70vh] pr-1">
<form @submit.prevent="onSubmit" class="space-y-4 pr-3">
<!-- Mail profile -->
<FormField v-slot="{ value, handleChange }" name="mail_profile_id">
<FormItem>
<FormLabel>E-poštni profil</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="p in pageMailProfiles"
:key="p.id"
:value="p.id"
>
{{ p.name || "Profil #" + p.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<!-- Contract -->
<FormField v-slot="{ value, handleChange }" name="contract_uuid">
<FormItem>
<FormLabel>Pogodba</FormLabel>
<Select :model-value="value" @update:model-value="handleChange">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="c in contractsForCase"
:key="c.uuid"
:value="c.uuid"
>
{{ c.reference || c.uuid }}
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-gray-500">
Izberite pogodbo za zapolnitev spremenljivk v predlogi.
</p>
<FormMessage />
</FormItem>
</FormField>
<!-- Template -->
<FormField v-slot="{ value, handleChange }" name="template_id">
<FormItem>
<FormLabel>Predloga</FormLabel>
<Select
:model-value="value"
@update:model-value="handleChange"
:disabled="loadingPreview"
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem
v-for="t in pageEmailTemplates"
:key="t.id"
:value="t.id"
>
{{ t.name || "Predloga #" + t.id }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<!-- Subject -->
<FormField v-slot="{ componentField }" name="subject">
<FormItem>
<FormLabel>Zadeva</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Zadeva e-poštnega sporočila..."
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- body_text textarea shown only when the template uses {{body_text}} -->
<FormField v-if="hasBodyText" v-slot="{ componentField }" name="body_text">
<FormItem>
<FormLabel>Besedilo sporočila</FormLabel>
<FormControl>
<Textarea
placeholder="Vnesite besedilo, ki se vstavi na mesto {{body_text}} v predlogi..."
class="min-h-[120px] resize-y"
v-bind="componentField"
/>
</FormControl>
<p class="mt-1 text-xs text-gray-500">
Besedilo se vstavi na oznako <code>&#123;&#123;body_text&#125;&#125;</code> v predlogi. Besedilo ne podpira spremenljivk.
</p>
<FormMessage />
</FormItem>
</FormField>
<!-- WYSIWYG body editor -->
<div>
<label class="text-sm font-medium leading-none">Vsebina</label>
<!-- Toolbar -->
<div class="flex gap-1 mt-2 mb-1 border rounded-t-md bg-gray-50 p-1">
<Button
type="button"
size="sm"
variant="ghost"
class="font-bold px-2 py-1 h-7"
title="Krepko (Ctrl+B)"
@click="iframeExec('bold')"
>B</Button>
<Button
type="button"
size="sm"
variant="ghost"
class="italic px-2 py-1 h-7"
title="Poševno (Ctrl+I)"
@click="iframeExec('italic')"
>I</Button>
<Button
type="button"
size="sm"
variant="ghost"
class="underline px-2 py-1 h-7"
title="Podčrtano (Ctrl+U)"
@click="iframeExec('underline')"
>U</Button>
</div>
<iframe
ref="iframeRef"
class="w-full border rounded-b-md bg-white"
style="min-height: 240px; max-height: 360px"
frameborder="0"
sandbox="allow-same-origin allow-scripts"
/>
<p class="mt-1 text-xs text-gray-500">
Kliknite v vsebino in začnite pisati. Izberite predlogo za samodejno zapolnitev.
</p>
</div>
</form>
</ScrollArea>
<DialogFooter>
<Button variant="outline" @click="closeDialog" :disabled="processing">
Prekliči
</Button>
<Button
@click="onSubmit"
:disabled="processing || !form.values.subject"
>
{{ processing ? "Pošiljanje..." : "Pošlji" }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -8,14 +8,16 @@ import {
} from "@/Components/ui/dropdown-menu";
import { Card } from "@/Components/ui/card";
import { Button } from "../ui/button";
import { EllipsisVertical } from "lucide-vue-next";
import { CircleCheckBigIcon, CircleXIcon, EllipsisVertical, MailIcon } from "lucide-vue-next";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
const props = defineProps({
person: Object,
edit: { type: Boolean, default: true },
enableEmail: { type: Boolean, default: false },
});
const emit = defineEmits(["add", "edit", "delete"]);
const emit = defineEmits(["add", "edit", "delete", "email"]);
const getEmails = (p) => (Array.isArray(p?.emails) ? p.emails : []);
@@ -44,7 +46,17 @@ const handleDelete = (id, label) => emit("delete", id, label);
</span>
</div>
<div v-if="edit">
<DropdownMenu>
<div class="flex items-center gap-1">
<Button
v-if="enableEmail"
@click="$emit('email', email)"
title="Pošlji e-pošto"
size="icon"
variant="ghost"
>
<MailIcon :size="18" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" title="Možnosti">
<EllipsisVertical />
@@ -66,11 +78,28 @@ const handleDelete = (id, label) => emit("delete", id, label);
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div class="p-1">
<p class="font-medium text-gray-900 leading-relaxed">
<p class="font-medium text-gray-900 leading-relaxed flex gap-1 items-center">
{{ email?.value || email?.email || email?.address || "-" }}
<TooltipProvider v-if="email?.valid">
<Tooltip>
<TooltipTrigger as-child>
<CircleCheckBigIcon color="#3e9392" :size="18" />
</TooltipTrigger>
<TooltipContent>Veljavna</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider v-if="email?.failed">
<Tooltip>
<TooltipTrigger as-child>
<CircleXIcon color="#dc2626" :size="18" />
</TooltipTrigger>
<TooltipContent>Neuspešna dostava</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
<p
v-if="email?.note"
@@ -30,6 +30,7 @@ import PersonInfoPhonesTab from "./PersonInfoPhonesTab.vue";
import PersonInfoEmailsTab from "./PersonInfoEmailsTab.vue";
import PersonInfoTrrTab from "./PersonInfoTrrTab.vue";
import PersonInfoSmsDialog from "./PersonInfoSmsDialog.vue";
import PersonInfoEmailDialog from "./PersonInfoEmailDialog.vue";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
@@ -58,6 +59,9 @@ const props = defineProps({
smsProfiles: { type: Array, default: () => [] },
smsSenders: { type: Array, default: () => [] },
smsTemplates: { type: Array, default: () => [] },
enableEmail: { type: Boolean, default: false },
emailTemplates: { type: Array, default: () => [] },
mailProfiles: { type: Array, default: () => [] },
});
// Dialog states
@@ -91,6 +95,10 @@ const confirm = ref({
const showSmsDialog = ref(false);
const smsTargetPhone = ref(null);
// Email dialog state
const showEmailDialog = ref(false);
const emailTarget = ref(null);
// Person handlers
const openDrawerUpdateClient = () => {
drawerUpdatePerson.value = true;
@@ -251,6 +259,18 @@ const closeSmsDialog = () => {
smsTargetPhone.value = null;
};
// Email dialog handlers
const openEmailDialog = (email) => {
if (!props.enableEmail || !props.clientCaseUuid) return;
emailTarget.value = email;
showEmailDialog.value = true;
};
const closeEmailDialog = () => {
showEmailDialog.value = false;
emailTarget.value = null;
};
// Tab event handlers
const handlePersonEdit = () => openDrawerUpdateClient();
@@ -266,6 +286,7 @@ const handlePhoneSms = (phone) => openSmsDialog(phone);
const handleEmailAdd = () => openDrawerAddEmail(false, 0);
const handleEmailEdit = (id) => openDrawerAddEmail(true, id);
const handleEmailDelete = (id, label) => openConfirm("email", id, label);
const handleEmailSend = (email) => openEmailDialog(email);
const handleTrrAdd = () => openDrawerAddTrr(false, 0);
const handleTrrEdit = (id) => openDrawerAddTrr(true, id);
@@ -418,9 +439,11 @@ const switchToTab = (tab) => {
<PersonInfoEmailsTab
:person="person"
:edit="edit"
:enable-email="enableEmail && !!clientCaseUuid"
@add="handleEmailAdd"
@edit="handleEmailEdit"
@delete="handleEmailDelete"
@email="handleEmailSend"
/>
</TabsContent>
@@ -534,4 +557,15 @@ const switchToTab = (tab) => {
:sms-templates="smsTemplates"
@close="closeSmsDialog"
/>
<!-- Email Dialog -->
<PersonInfoEmailDialog
v-if="clientCaseUuid"
:show="showEmailDialog"
:email="emailTarget"
:client-case-uuid="clientCaseUuid"
:email-templates="emailTemplates"
:mail-profiles="mailProfiles"
@close="closeEmailDialog"
/>
</template>
+1 -1
View File
@@ -225,7 +225,7 @@ const rawMenuGroups = [
{
key: "packages",
icon: PackageIcon,
title: "SMS paketi",
title: "Paketno pošiljanje",
routeName: "packages.index",
active: ["packages.index", "packages.show", "packages.create"],
},
@@ -64,6 +64,7 @@ import "quill/dist/quill.snow.css";
const props = defineProps({
template: { type: Object, default: null },
actions: { type: Array, default: () => [] },
});
const form = useForm({
@@ -75,6 +76,9 @@ const form = useForm({
entity_types: props.template?.entity_types ?? ["client", "contract"],
allow_attachments: props.template?.allow_attachments ?? false,
active: props.template?.active ?? true,
client: props.template?.client ?? false,
action_id: props.template?.action_id ?? null,
decision_id: props.template?.decision_id ?? null,
});
const preview = ref({ subject: "", html: "", text: "" });
@@ -732,7 +736,8 @@ const placeholderGroups = computed(() => {
"contract.id",
"contract.uuid",
"contract.reference",
"contract.amount",
"contract.account.balance_amount",
"contract.account.initial_amount",
"contract.meta.some_key",
]);
}
@@ -747,6 +752,13 @@ const placeholderGroups = computed(() => {
]);
// Extra is always useful for ad-hoc data
add("extra", "Extra", ["extra.some_key"]);
// Profile signature tokens (resolved from the active mail profile at send time)
add("profile", "Profil / Podpis", [
"profile.signature.ime",
"profile.signature.naziv",
"profile.signature.telefon",
"profile.signature.email",
]);
return groups;
});
@@ -1028,6 +1040,49 @@ watch(
/>
<Label for="active" class="font-normal cursor-pointer">Aktivno</Label>
</div>
<div class="flex items-center gap-2">
<Switch
id="client"
:default-value="form.client"
@update:model-value="(val) => (form.client = val)"
/>
<Label for="client" class="font-normal cursor-pointer">Samo za stranke</Label>
</div>
</div>
<!-- Activity after send: action + decision -->
<div>
<Label class="mb-2 block">Aktivnost po pošiljanju</Label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="action_id">Akcija</Label>
<Select v-model="form.action_id" @update:model-value="(val) => { form.action_id = val; form.decision_id = null; }">
<SelectTrigger id="action_id">
<SelectValue placeholder="Brez" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Brez</SelectItem>
<SelectItem v-for="a in props.actions" :key="a.id" :value="a.id">{{ a.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="decision_id">Odločitev</Label>
<Select v-model="form.decision_id" :disabled="!form.action_id">
<SelectTrigger id="decision_id">
<SelectValue placeholder="Brez" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Brez</SelectItem>
<SelectItem
v-for="d in props.actions?.find((x) => x.id === form.action_id)?.decisions || []"
:key="d.id"
:value="d.id"
>{{ d.name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
@@ -1223,6 +1278,25 @@ watch(
</Button>
</div>
</div>
<!-- Special tokens -->
<div class="space-y-2">
<div class="text-sm font-medium text-muted-foreground">Posebni žetoni</div>
<p class="text-xs text-muted-foreground">
<code class="font-mono">&#123;&#123; body_text &#125;&#125;</code> — pri pošiljanju ga nadomesti besedilo, ki ga vnese pošiljatelj.
</p>
<div class="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
@click="insertPlaceholder('body_text')"
class="font-mono text-xs"
>
<PlusCircleIcon class="h-3 w-3 mr-1" />
body_text
</Button>
</div>
</div>
</div>
</div>
</CardContent>
+112 -6
View File
@@ -9,6 +9,7 @@ import {
PencilIcon,
SendIcon,
MoreVerticalIcon,
Trash2Icon,
} from "lucide-vue-next";
import {
Card,
@@ -62,6 +63,32 @@ const createOpen = ref(false); // create modal
const editOpen = ref(false); // edit modal
const editTarget = ref(null); // profile being edited
// Signature items — array of {key, value} pairs edited in the dialog
const signatureItems = ref([{ key: "", value: "" }]);
function addSignatureItem() {
signatureItems.value.push({ key: "", value: "" });
}
function removeSignatureItem(index) {
signatureItems.value.splice(index, 1);
if (signatureItems.value.length === 0)
signatureItems.value.push({ key: "", value: "" });
}
function signatureToObject() {
const obj = {};
signatureItems.value.forEach(({ key, value }) => {
const k = (key || "").trim();
if (k) obj[k] = value ?? "";
});
return Object.keys(obj).length ? obj : null;
}
function signatureFromObject(sig) {
const entries = Object.entries(sig || {});
return entries.length
? entries.map(([key, value]) => ({ key, value }))
: [{ key: "", value: "" }];
}
const form = useForm({
name: "",
host: "",
@@ -76,6 +103,7 @@ const form = useForm({
function openCreate() {
form.reset();
signatureItems.value = [{ key: "", value: "" }];
createOpen.value = true;
editTarget.value = null;
}
@@ -93,6 +121,7 @@ function openEdit(p) {
form.from_name = p.from_name || "";
form.priority = p.priority ?? 10;
editTarget.value = p;
signatureItems.value = signatureFromObject(p.signature);
editOpen.value = true;
}
@@ -102,12 +131,14 @@ function closeCreate() {
}
function submitCreate() {
form.post(route("admin.mail-profiles.store"), {
preserveScroll: true,
onSuccess: () => {
createOpen.value = false;
},
});
form
.transform((data) => ({ ...data, signature: signatureToObject() }))
.post(route("admin.mail-profiles.store"), {
preserveScroll: true,
onSuccess: () => {
createOpen.value = false;
},
});
}
function closeEdit() {
@@ -128,6 +159,7 @@ function submitEdit() {
from_address: form.from_address,
from_name: form.from_name || null,
priority: form.priority,
signature: signatureToObject(),
};
if (form.password && form.password.trim() !== "") {
payload.password = form.password.trim();
@@ -351,6 +383,43 @@ const statusClass = (p) => {
<Input id="create-priority" v-model.number="form.priority" type="number" />
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Podpis (signature)</Label>
<Button type="button" size="sm" variant="outline" @click="addSignatureItem">
<PlusIcon class="h-3 w-3 mr-1" />
Dodaj vrstico
</Button>
</div>
<p class="text-xs text-muted-foreground">
Vrednosti so dostopne v predlogah kot
<code class="font-mono" v-pre>{{ profile.signature.ključ }}</code
>.
</p>
<div class="space-y-2">
<div
v-for="(item, i) in signatureItems"
:key="i"
class="flex gap-2 items-start"
>
<Input
v-model="item.key"
placeholder="Ključ (npr. ime)"
class="w-36 shrink-0 font-mono text-xs"
/>
<Input v-model="item.value" placeholder="Vrednost" class="flex-1" />
<Button
type="button"
variant="ghost"
size="sm"
@click="removeSignatureItem(i)"
>
<Trash2Icon class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
</form>
<DialogFooter>
<Button variant="outline" @click="closeCreate" :disabled="form.processing"
@@ -419,6 +488,43 @@ const statusClass = (p) => {
<Input id="edit-priority" v-model.number="form.priority" type="number" />
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Podpis (signature)</Label>
<Button type="button" size="sm" variant="outline" @click="addSignatureItem">
<PlusIcon class="h-3 w-3 mr-1" />
Dodaj vrstico
</Button>
</div>
<p class="text-xs text-muted-foreground">
Vrednosti so dostopne v predlogah kot
<code class="font-mono" v-pre>{{ profile.signature.ključ }}</code
>.
</p>
<div class="space-y-2">
<div
v-for="(item, i) in signatureItems"
:key="i"
class="flex gap-2 items-start"
>
<Input
v-model="item.key"
placeholder="Ključ (npr. ime)"
class="w-36 shrink-0 font-mono text-xs"
/>
<Input v-model="item.value" placeholder="Vrednost" class="flex-1" />
<Button
type="button"
variant="ghost"
size="sm"
@click="removeSignatureItem(i)"
>
<Trash2Icon class="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
<p class="text-sm text-muted-foreground">
Pusti geslo prazno, če želiš obdržati obstoječe.
</p>
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, useSlots, watch, onMounted } from "vue";
import { router } from "@inertiajs/vue3";
import axios from "axios";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import DeleteDialog from "@/Components/Dialogs/DeleteDialog.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
@@ -20,7 +21,13 @@ import {
} from "@/Components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
import { RangeCalendar } from "@/Components/ui/range-calendar";
import { CalendarIcon, X, Filter, Check, ChevronsUpDown } from "lucide-vue-next";
import { CalendarIcon, X, Filter, Check, ChevronsUpDown, MailIcon } from "lucide-vue-next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { cn } from "@/lib/utils";
import { DateFormatter, getLocalTimeZone, parseDate } from "@internationalized/date";
@@ -242,6 +249,7 @@ const columns = [
{ key: "note", label: "Opomba", sortable: false },
{ key: "promise", label: "Obljuba", sortable: false },
{ key: "user", label: "Dodal", sortable: false },
{ key: "email_action", label: "Akcija", sortable: false, align: "center" },
{ key: "actions", label: "", sortable: false, hideable: false, align: "center" },
];
@@ -301,6 +309,27 @@ const deleteActivity = (row) => {
const confirmDelete = ref(false);
const toDeleteRow = ref(null);
// Email body dialog
const emailBodyDialogOpen = ref(false);
const emailBodyHtml = ref("");
const emailBodyLoading = ref(false);
const emailBodyError = ref(null);
const openEmailBody = async (emailLogId) => {
emailBodyHtml.value = "";
emailBodyError.value = null;
emailBodyLoading.value = true;
emailBodyDialogOpen.value = true;
try {
const res = await axios.get(route("admin.email-logs.body", emailLogId));
emailBodyHtml.value = res.data.html ?? "";
} catch (err) {
emailBodyError.value = "Napaka pri nalaganju vsebine e-pošte.";
} finally {
emailBodyLoading.value = false;
}
};
const openDelete = (row) => {
toDeleteRow.value = row;
confirmDelete.value = true;
@@ -771,6 +800,21 @@ const copyToClipboard = async (text) => {
</div>
</template>
<template #cell-email_action="{ row }">
<div class="flex justify-center">
<Button
v-if="row.email_logs?.length"
variant="ghost"
size="icon"
class="h-7 w-7 text-blue-600 hover:text-blue-800"
:title="'Prikaži poslano e-pošto'"
@click="openEmailBody(row.email_logs[0].id)"
>
<MailIcon class="h-4 w-4" />
</Button>
</div>
</template>
<template #cell-actions="{ row }" v-if="edit">
<TableActions align="right">
<template #default>
@@ -794,4 +838,27 @@ const copyToClipboard = async (text) => {
@close="cancelDelete"
@confirm="confirmDeleteAction"
/>
<Dialog v-model:open="emailBodyDialogOpen">
<DialogContent class="max-w-4xl w-full p-0 overflow-hidden">
<DialogHeader class="px-6 pt-6 pb-0">
<DialogTitle>Vsebina poslane e-pošte</DialogTitle>
</DialogHeader>
<div class="px-6 pb-6 pt-4">
<div v-if="emailBodyLoading" class="flex items-center justify-center h-64 text-muted-foreground">
Nalaganje…
</div>
<div v-else-if="emailBodyError" class="text-destructive py-8 text-center">
{{ emailBodyError }}
</div>
<iframe
v-else
:srcdoc="emailBodyHtml"
sandbox="allow-same-origin"
class="w-full border rounded"
style="height: 600px;"
/>
</div>
</DialogContent>
</Dialog>
</template>
+1
View File
@@ -324,6 +324,7 @@ const submitAttachSegment = () => {
:person="client_case.person"
:person-edit="hasPerm('person-edit')"
:enable-sms="true"
:enable-email="true"
:client-case-uuid="client_case.uuid"
/>
</div>
+24 -28
View File
@@ -38,6 +38,8 @@ import {
CheckCircle2Icon,
XCircleIcon,
BadgeCheckIcon,
CircleCheckIcon,
BadgeXIcon,
} from "lucide-vue-next";
import { fmtDateDMY } from "@/Utilities/functions";
import { upperFirst } from "lodash";
@@ -519,10 +521,7 @@ const numbersCount = computed(() => {
</div>
</div>
<div class="flex justify-end gap-2">
<Button
@click="router.visit(route('packages.index'))"
variant="outline"
>
<Button @click="router.visit(route('packages.index'))" variant="outline">
Prekliči
</Button>
<Button
@@ -702,10 +701,7 @@ const numbersCount = computed(() => {
<CheckCircle2Icon class="h-3 w-3" />
Izbrano: {{ selectedContractIds.size }}
</Badge>
<Button
@click="router.visit(route('packages.index'))"
variant="outline"
>
<Button @click="router.visit(route('packages.index'))" variant="outline">
Prekliči
</Button>
<Button
@@ -769,26 +765,26 @@ const numbersCount = computed(() => {
</template>
<template #cell-selected_phone="{ row }">
<div v-if="row.selected_phone" class="space-y-1">
<div class="flex flex-col items-center gap-1">
<span>{{ row.selected_phone.number }}</span>
<span
><Badge
v-if="row.selected_phone.validated"
variant="secondary"
class="text-xs"
>
<BadgeCheckIcon />
Potrjena
</Badge>
<Badge
v-else
variant="destructive"
class="h-5 min-w-5 rounded-full px-1 font-mono tabular-nums text-accent"
>
Nepotrjena
</Badge></span
>
<div v-if="row.selected_phone" class="space-y-1 flex flex-col gap-1">
<div class="flex flex-row gap-1 items-center">
<span>
{{ row.selected_phone.number }}
</span>
<BadgeCheckIcon
size="18"
color="green"
v-if="row.selected_phone.validated"
/>
<BadgeXIcon
size="18"
color="red"
v-if="!row.selected_phone.validated"
/>
</div>
<div v-if="row.selected_phone.description">
<Badge variant="secondary" class="break-all">
{{ row.selected_phone.description }}
</Badge>
</div>
</div>
<span v-else class="text-xs text-destructive">Ni telefonske št.</span>
+36 -167
View File
@@ -1,176 +1,45 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import { PackageIcon, PlusIcon, Trash2Icon, EyeIcon } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { fmtDateTime } from "@/Utilities/functions";
const props = defineProps({
packages: { type: Object, required: true },
});
const deletingId = ref(null);
const packageToDelete = ref(null);
const showDeleteDialog = ref(false);
const columns = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "name", header: "Ime" },
{ accessorKey: "type", header: "Tip" },
{ accessorKey: "status", header: "Status" },
{ accessorKey: "total_items", header: "Skupaj" },
{ accessorKey: "sent_count", header: "Poslano" },
{ accessorKey: "failed_count", header: "Neuspešno" },
{ accessorKey: "finished_at", header: "Zaključeno" },
{ accessorKey: "actions", header: "", enableSorting: false },
];
function getStatusVariant(status) {
if (["queued", "running"].includes(status)) return "secondary";
if (status === "completed") return "default";
if (status === "failed") return "destructive";
return "outline";
}
function goShow(id) {
router.visit(route("packages.show", id));
}
function openDeleteDialog(pkg) {
if (!pkg || pkg.status !== "draft") return;
packageToDelete.value = pkg;
showDeleteDialog.value = true;
}
function confirmDelete() {
if (!packageToDelete.value) return;
deletingId.value = packageToDelete.value.id;
router.delete(route("packages.destroy", packageToDelete.value.id), {
onSuccess: () => {
router.reload({ only: ["packages"] });
},
onFinish: () => {
deletingId.value = null;
showDeleteDialog.value = false;
packageToDelete.value = null;
},
});
}
import { Link } from "@inertiajs/vue3";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/Components/ui/card";
import { MessageSquareIcon, MailIcon } from "lucide-vue-next";
</script>
<template>
<AppLayout title="SMS paketi">
<Card class="mb-4">
<CardHeader>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<PackageIcon class="h-5 w-5 text-muted-foreground" />
<AppLayout title="Paketi">
<div class="mb-6">
<h1 class="text-2xl font-bold tracking-tight">Paketi</h1>
<p class="text-sm text-muted-foreground">Izberite vrsto paketa za pošiljanje</p>
</div>
<div class="grid gap-4 sm:grid-cols-2 max-w-2xl">
<Link :href="route('packages.sms.index')">
<Card class="cursor-pointer hover:border-primary transition-colors h-full">
<CardHeader>
<div class="flex items-center gap-3 mb-2">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<MessageSquareIcon class="h-5 w-5 text-primary" />
</div>
</div>
<CardTitle>SMS paketi</CardTitle>
</div>
<Link :href="route('packages.create')">
<Button>
<PlusIcon class="h-4 w-4" />
Nov paket
</Button>
</Link>
</div>
</CardHeader>
</Card>
<CardDescription>Pošlji SMS sporočila v paketu prejemnikom iz pogodb ali ročno vnesenih številk</CardDescription>
</CardHeader>
</Card>
</Link>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<PackageIcon size="18" />
<CardTitle class="uppercase">Paketi</CardTitle>
</div>
</template>
<DataTableNew2
:columns="columns"
:data="packages.data"
:meta="packages"
route-name="packages.index"
>
<template #cell-name="{ row }">
<span class="text-sm">{{ row.name ?? "—" }}</span>
</template>
<template #cell-type="{ row }">
<Badge variant="outline" class="uppercase">{{ row.type }}</Badge>
</template>
<template #cell-status="{ row }">
<Badge :variant="getStatusVariant(row.status)">{{ row.status }}</Badge>
</template>
<template #cell-finished_at="{ row }">
<span class="text-xs text-muted-foreground">{{
fmtDateTime(row.finished_at) ?? "—"
}}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<Button @click="goShow(row.id)" variant="ghost" size="sm">
<EyeIcon class="h-4 w-4" />
</Button>
<Button
v-if="row.status === 'draft'"
@click="openDeleteDialog(row)"
:disabled="deletingId === row.id"
variant="ghost"
size="sm"
>
<Trash2Icon class="h-4 w-4" />
</Button>
</div>
</template>
</DataTableNew2>
</AppCard>
<!-- Delete Confirmation Dialog -->
<AlertDialog v-model:open="showDeleteDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Izbriši paket?</AlertDialogTitle>
<AlertDialogDescription>
Ali ste prepričani, da želite izbrisati paket
<strong v-if="packageToDelete"
>#{{ packageToDelete.id }} -
{{ packageToDelete.name || "Brez imena" }}</strong
>? Tega dejanja ni mogoče razveljaviti.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Prekliči</AlertDialogCancel>
<AlertDialogAction
@click="confirmDelete"
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Izbriši
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Link :href="route('packages.email.index')">
<Card class="cursor-pointer hover:border-primary transition-colors h-full">
<CardHeader>
<div class="flex items-center gap-3 mb-2">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<MailIcon class="h-5 w-5 text-primary" />
</div>
</div>
<CardTitle>E-mail paketi</CardTitle>
<CardDescription>Pošlji e-mail sporočila v paketu prejemnikom iz pogodb z e-mail predlogami</CardDescription>
</CardHeader>
</Card>
</Link>
</div>
</AppLayout>
</template>
+592
View File
@@ -0,0 +1,592 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router, useForm } from "@inertiajs/vue3";
import { ref, computed, nextTick } from "vue";
import axios from "axios";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import { Label } from "@/Components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
import { Badge } from "@/Components/ui/badge";
import { Separator } from "@/Components/ui/separator";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import {
MailIcon,
UsersIcon,
SearchIcon,
SaveIcon,
ArrowLeftIcon,
FilterIcon,
CalendarIcon,
CheckCircle2Icon,
XCircleIcon,
BadgeCheckIcon,
} from "lucide-vue-next";
import { fmtDateDMY } from "@/Utilities/functions";
import { upperFirst } from "lodash";
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
import AppRangeDatePicker from "@/Components/app/ui/AppRangeDatePicker.vue";
const props = defineProps({
emailTemplates: { type: Array, default: () => [] },
mailProfiles: { type: Array, default: () => [] },
segments: { type: Array, default: () => [] },
clients: { type: Array, default: () => [] },
});
const creatingFromContracts = ref(false);
const hasBodyText = ref(false);
const form = useForm({
name: "",
description: "",
mail_profile_id: null,
template_id: null,
subject: "",
body_text: "",
});
function onTemplateChange(newTemplateId) {
const template = props.emailTemplates.find((t) => t.id === newTemplateId);
if (template?.subject_template && !form.subject) {
form.subject = template.subject_template;
}
hasBodyText.value = !!template?.has_body_text;
if (template?.has_body_text && template?.text_template) {
form.body_text = template.text_template;
} else {
form.body_text = "";
}
}
// Contracts mode state & actions
const contracts = ref({
data: [],
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
});
const segmentId = ref(null);
const search = ref("");
const clientId = ref(null);
const startDateRange = ref({ start: null, end: null });
const promiseDateRange = ref({ start: null, end: null });
const onlyVerified = ref(false);
const onlyWithEmail = ref(false);
const loadingContracts = ref(false);
const clientItems = computed(() =>
props.clients.map((c) => ({
value: c.id,
label: c.name,
}))
);
const selectedContractIds = ref(new Set());
const perPage = ref(25);
const contractColumns = [
{ accessorKey: "reference", header: "Pogodba" },
{
id: "person",
accessorFn: (row) => row.person?.full_name || "—",
header: "Primer",
},
{
id: "client",
accessorFn: (row) => row.client?.name || "—",
header: "Stranka",
},
{ accessorKey: "start_date", header: "Datum začetka" },
{ accessorKey: "promise_date", header: "Zadnja obljuba" },
{
id: "selected_email",
accessorFn: (row) => row.selected_email?.value || "—",
header: "Izbrani e-mail",
},
{
id: "segment",
accessorFn: (row) => upperFirst(row.segment?.name) || "—",
header: "Segment",
},
{ accessorKey: "no_email_reason", header: "Opomba" },
];
function onSelectionChange(selectedKeys) {
const newSelection = new Set();
selectedKeys.forEach((key) => {
const index = parseInt(key);
if (contracts.value.data[index]) {
newSelection.add(contracts.value.data[index].id);
}
});
selectedContractIds.value = newSelection;
}
async function loadContracts(url = null) {
loadingContracts.value = true;
try {
const params = new URLSearchParams();
if (segmentId.value) params.append("segment_id", segmentId.value);
if (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value);
if (startDateRange.value?.start)
params.append("start_date_from", startDateRange.value.start);
if (startDateRange.value?.end)
params.append("start_date_to", startDateRange.value.end);
if (promiseDateRange.value?.start)
params.append("promise_date_from", promiseDateRange.value.start);
if (promiseDateRange.value?.end)
params.append("promise_date_to", promiseDateRange.value.end);
if (onlyVerified.value) params.append("only_verified", "1");
if (onlyWithEmail.value) params.append("only_with_email", "1");
params.append("per_page", perPage.value);
const target = url || `${route("packages.email.contracts")}?${params.toString()}`;
const { data: json } = await axios.get(target, {
headers: { "X-Requested-With": "XMLHttpRequest" },
});
await nextTick();
contracts.value = {
data: json.data || [],
meta: json.meta || { current_page: 1, last_page: 1, per_page: 25, total: 0 },
};
} finally {
loadingContracts.value = false;
}
}
const rowSelection = computed(() => {
const selection = {};
contracts.value.data.forEach((contract, index) => {
if (selectedContractIds.value.has(contract.id)) {
selection[index.toString()] = true;
}
});
return selection;
});
const tableKey = computed(() => {
return `contracts-${contracts.value.meta.current_page}-${contracts.value.data.length}`;
});
function resetFilters() {
segmentId.value = null;
clientId.value = null;
search.value = "";
startDateRange.value = { start: null, end: null };
promiseDateRange.value = { start: null, end: null };
onlyVerified.value = false;
onlyWithEmail.value = false;
contracts.value = {
data: [],
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
};
}
function submitCreateFromContracts() {
const ids = Array.from(selectedContractIds.value);
if (!ids.length) return;
const visibleById = new Map((contracts.value.data || []).map((c) => [c.id, c]));
const selectedVisible = ids.map((id) => visibleById.get(id)).filter(Boolean);
if (selectedVisible.length && selectedVisible.every((c) => !c?.selected_email)) {
alert("Za izbrane pogodbe ni mogoče najti prejemnikov (e-mail naslovov).");
return;
}
const payload = {
type: "email",
name: form.name || `E-mail paket (segment) ${new Date().toLocaleString()}`,
description: form.description || "",
payload: {
mail_profile_id: form.mail_profile_id,
template_id: form.template_id,
subject: form.subject && form.subject.trim() ? form.subject.trim() : null,
body_text: form.body_text && form.body_text.trim() ? form.body_text.trim() : null,
},
contract_ids: ids,
};
creatingFromContracts.value = true;
router.post(route("packages.email.store-from-contracts"), payload, {
onSuccess: () => {
router.visit(route("packages.email.index"));
},
onError: (errors) => {
const first = errors && Object.values(errors)[0];
if (first) {
alert(String(first));
}
},
onFinish: () => {
creatingFromContracts.value = false;
},
});
}
</script>
<template>
<AppLayout title="Ustvari e-mail paket">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<Link :href="route('packages.email.index')">
<Button variant="ghost" size="sm">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Nazaj
</Button>
</Link>
</div>
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<MailIcon class="h-6 w-6 text-primary" />
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight">Ustvari e-mail paket</h1>
<p class="text-sm text-muted-foreground">Pošlji e-mail sporočila v paketu</p>
</div>
</div>
</div>
<!-- Package Details Card -->
<Card class="mb-6">
<CardHeader>
<CardTitle>Podatki o paketu</CardTitle>
<CardDescription>Osnovne informacije in e-mail nastavitve</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<!-- Basic Info -->
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="name">Ime paketa</Label>
<Input
id="name"
v-model="form.name"
placeholder="Npr. E-mail kampanja december 2024"
/>
</div>
<div class="space-y-2">
<Label for="description">Opis</Label>
<Input
id="description"
v-model="form.description"
placeholder="Neobvezen opis paketa"
/>
</div>
</div>
<Separator />
<!-- Email Configuration -->
<div>
<h3 class="text-sm font-semibold mb-4">E-mail nastavitve</h3>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label>E-mail profil</Label>
<Select v-model="form.mail_profile_id">
<SelectTrigger>
<SelectValue placeholder="Izberi profil" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="p in mailProfiles" :key="p.id" :value="p.id">
{{ p.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Predloga</Label>
<Select v-model="form.template_id" @update:model-value="onTemplateChange">
<SelectTrigger>
<SelectValue placeholder="Izberi predlogo" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="t in emailTemplates" :key="t.id" :value="t.id">
{{ t.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="mt-4 space-y-2">
<Label for="subject">Zadeva (neobvezno prepiše zadevo iz predloge)</Label>
<Input
id="subject"
v-model="form.subject"
placeholder="Npr. Vaša pogodba ..."
/>
</div>
<div v-if="hasBodyText" class="mt-4 space-y-2">
<Label for="body_text">Besedilo sporočila (neobvezno vstavi se na mesto <code>&#123;&#123;body_text&#125;&#125;</code>)</Label>
<Textarea
id="body_text"
v-model="form.body_text"
placeholder="Vnesite besedilo sporočila..."
class="min-h-[120px] resize-y"
/>
</div>
</div>
</CardContent>
</Card>
<!-- Contracts Filter Card -->
<Card class="mb-6">
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>Filtri za pogodbe</CardTitle>
<CardDescription>Najdi prejemnike glede na pogodbe in segmente</CardDescription>
</div>
<Badge variant="outline" class="text-xs">
<FilterIcon class="h-3 w-3 mr-1" />
Napredno iskanje
</Badge>
</div>
</CardHeader>
<CardContent class="space-y-6">
<!-- Basic filters -->
<div class="grid gap-4 md:grid-cols-3">
<div class="space-y-2">
<Label>Segment</Label>
<Select v-model="segmentId" @update:model-value="loadContracts()">
<SelectTrigger>
<SelectValue placeholder="Vsi segmenti" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Vsi segmenti</SelectItem>
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
{{ s.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Stranka</Label>
<AppCombobox
v-model="clientId"
:items="clientItems"
placeholder="Vse stranke"
search-placeholder="Išči stranko..."
empty-text="Stranka ni najdena."
button-class="w-full"
@update:model-value="loadContracts()"
/>
</div>
<div class="space-y-2">
<Label>Iskanje po referenci</Label>
<Input
v-model="search"
@keyup.enter="loadContracts()"
placeholder="Vnesi referenco..."
/>
</div>
</div>
<Separator />
<!-- Date filters -->
<div>
<h4 class="text-sm font-semibold mb-3 flex items-center gap-2">
<CalendarIcon class="h-4 w-4" />
Datumski filtri
</h4>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-3">
<p class="text-sm text-muted-foreground">Datum začetka pogodbe</p>
<AppRangeDatePicker
v-model="startDateRange"
placeholder="Izberi obdobje"
button-class="w-full"
:number-of-months="1"
/>
</div>
<div class="space-y-3">
<p class="text-sm text-muted-foreground">Datum obljube plačila</p>
<AppRangeDatePicker
v-model="promiseDateRange"
placeholder="Izberi obdobje"
button-class="w-full"
:number-of-months="1"
/>
</div>
</div>
</div>
<Separator />
<!-- Email filters -->
<div>
<h4 class="text-sm font-semibold mb-3">E-mail filtri</h4>
<div class="flex flex-wrap gap-4">
<div class="flex items-center gap-2">
<Checkbox
:model-value="onlyWithEmail"
@update:model-value="(val) => { onlyWithEmail = val; }"
id="only-with-email"
/>
<Label for="only-with-email" class="cursor-pointer text-sm">
Samo pogodbe z e-mail naslovom
</Label>
</div>
<div class="flex items-center gap-2">
<Checkbox
:model-value="onlyVerified"
@update:model-value="(val) => { onlyVerified = val; }"
id="only-verified"
/>
<Label for="only-verified" class="cursor-pointer text-sm">
Samo potrjeni e-mail naslovi
</Label>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<Button @click="loadContracts()">
<SearchIcon class="h-4 w-4" />
Išči pogodbe
</Button>
<Button @click="resetFilters" variant="outline">
<XCircleIcon class="h-4 w-4" />
Počisti filtre
</Button>
</div>
</CardContent>
</Card>
<!-- Results -->
<Card v-if="contracts.data.length > 0 || loadingContracts">
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>Rezultati iskanja (do 500 zapisov)</CardTitle>
<CardDescription v-if="contracts.meta.total > 0">
Najdeno {{ contracts.meta.total }}
{{
contracts.meta.total === 1
? "pogodba"
: contracts.meta.total < 5
? "pogodbe"
: "pogodb"
}}
</CardDescription>
</div>
<!-- Create Button -->
<div class="flex justify-end gap-2" v-if="selectedContractIds.size > 0">
<Badge variant="secondary" class="text-sm">
<CheckCircle2Icon class="h-3 w-3" />
Izbrano: {{ selectedContractIds.size }}
</Badge>
<Button @click="router.visit(route('packages.email.index'))" variant="outline">
Prekliči
</Button>
<Button
@click="submitCreateFromContracts"
:disabled="selectedContractIds.size === 0 || creatingFromContracts"
>
<SaveIcon class="h-4 w-4" />
Ustvari paket ({{ selectedContractIds.size }}
{{
selectedContractIds.size === 1
? "pogodba"
: selectedContractIds.size < 5
? "pogodbe"
: "pogodb"
}})
</Button>
</div>
</div>
</CardHeader>
<CardContent class="p-0">
<DataTableNew2
v-if="!loadingContracts"
:key="tableKey"
:columns="contractColumns"
:data="contracts.data"
:enableRowSelection="true"
:rowSelection="rowSelection"
:showPagination="true"
:page-size="50"
:page-size-options="[10, 15, 25, 50, 100]"
:showToolbar="false"
@selection:change="onSelectionChange"
>
<template #cell-reference="{ row }">
<div v-if="row.original" class="space-y-1">
<p class="font-medium">{{ row.original.reference || "" }}</p>
<p class="text-xs text-muted-foreground font-mono">
#{{ row.original.id }}
</p>
</div>
</template>
<template #cell-person="{ row }">
<span v-if="row.original" class="text-xs">{{
row.original.person?.full_name || ""
}}</span>
</template>
<template #cell-client="{ row }">
<span v-if="row.original" class="text-xs">{{
row.original.client?.name || ""
}}</span>
</template>
<template #cell-start_date="{ row }">
{{ fmtDateDMY(row.start_date) || "" }}
</template>
<template #cell-promise_date="{ row }">
{{ fmtDateDMY(row.promise_date) || "" }}
</template>
<template #cell-selected_email="{ row }">
<div v-if="row.selected_email" class="space-y-1 flex flex-col gap-1">
<div class="flex flex-row gap-1 items-center">
<span class="text-xs">{{ row.selected_email.value }}</span>
<BadgeCheckIcon
size="18"
color="green"
v-if="row.selected_email.verified"
/>
</div>
<div v-if="row.selected_email.label">
<Badge variant="secondary" class="break-all text-xs">
{{ row.selected_email.label }}
</Badge>
</div>
</div>
<span v-else class="text-xs text-destructive">Ni e-mail naslova.</span>
</template>
<template #cell-no_email_reason="{ row }">
<span v-if="row.original" class="text-xs text-muted-foreground">{{
row.original.no_email_reason || ""
}}</span>
</template>
</DataTableNew2>
<div v-else class="text-center text-muted-foreground py-24">Nalaganje...</div>
</CardContent>
</Card>
</AppLayout>
</template>
+177
View File
@@ -0,0 +1,177 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import { MailIcon, PlusIcon, Trash2Icon, EyeIcon, ArrowLeftIcon } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { fmtDateTime } from "@/Utilities/functions";
const props = defineProps({
packages: { type: Object, required: true },
});
const deletingId = ref(null);
const packageToDelete = ref(null);
const showDeleteDialog = ref(false);
const columns = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "name", header: "Ime" },
{ accessorKey: "status", header: "Status" },
{ accessorKey: "total_items", header: "Skupaj" },
{ accessorKey: "sent_count", header: "Poslano" },
{ accessorKey: "failed_count", header: "Neuspešno" },
{ accessorKey: "finished_at", header: "Zaključeno" },
{ accessorKey: "actions", header: "", enableSorting: false },
];
function getStatusVariant(status) {
if (["queued", "running"].includes(status)) return "secondary";
if (status === "completed") return "default";
if (status === "failed") return "destructive";
return "outline";
}
function goShow(id) {
router.visit(route("packages.email.show", id));
}
function openDeleteDialog(pkg) {
if (!pkg || pkg.status !== "draft") return;
packageToDelete.value = pkg;
showDeleteDialog.value = true;
}
function confirmDelete() {
if (!packageToDelete.value) return;
deletingId.value = packageToDelete.value.id;
router.delete(route("packages.email.destroy", packageToDelete.value.id), {
onSuccess: () => {
router.reload({ only: ["packages"] });
},
onFinish: () => {
deletingId.value = null;
showDeleteDialog.value = false;
packageToDelete.value = null;
},
});
}
</script>
<template>
<AppLayout title="E-mail paketi">
<Card class="mb-4">
<CardHeader>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" as-child>
<Link :href="route('packages.index')">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Paketi
</Link>
</Button>
<MailIcon class="h-5 w-5 text-muted-foreground" />
<CardTitle>E-mail paketi</CardTitle>
</div>
<Link :href="route('packages.email.create')">
<Button>
<PlusIcon class="h-4 w-4" />
Nov paket
</Button>
</Link>
</div>
</CardHeader>
</Card>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<MailIcon size="18" />
<CardTitle class="uppercase">E-mail Paketi</CardTitle>
</div>
</template>
<DataTableNew2
:columns="columns"
:data="packages.data"
:meta="packages"
route-name="packages.email.index"
>
<template #cell-name="{ row }">
<span class="text-sm">{{ row.name ?? "—" }}</span>
</template>
<template #cell-status="{ row }">
<Badge :variant="getStatusVariant(row.status)">{{ row.status }}</Badge>
</template>
<template #cell-finished_at="{ row }">
<span class="text-xs text-muted-foreground">{{
fmtDateTime(row.finished_at) ?? "—"
}}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<Button @click="goShow(row.id)" variant="ghost" size="sm">
<EyeIcon class="h-4 w-4" />
</Button>
<Button
v-if="row.status === 'draft'"
@click="openDeleteDialog(row)"
:disabled="deletingId === row.id"
variant="ghost"
size="sm"
>
<Trash2Icon class="h-4 w-4" />
</Button>
</div>
</template>
</DataTableNew2>
</AppCard>
<!-- Delete Confirmation Dialog -->
<AlertDialog v-model:open="showDeleteDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Izbriši paket?</AlertDialogTitle>
<AlertDialogDescription>
Ali ste prepričani, da želite izbrisati paket
<strong v-if="packageToDelete"
>#{{ packageToDelete.id }} -
{{ packageToDelete.name || "Brez imena" }}</strong
>? Tega dejanja ni mogoče razveljaviti.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Prekliči</AlertDialogCancel>
<AlertDialogAction
@click="confirmDelete"
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Izbriši
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AppLayout>
</template>
+242
View File
@@ -0,0 +1,242 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { onMounted, onUnmounted, ref, computed } from "vue";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import {
MailIcon,
ArrowLeftIcon,
PlayIcon,
XCircleIcon,
RefreshCwIcon,
} from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
const props = defineProps({
package: { type: Object, required: true },
items: { type: Object, required: true },
});
const columns = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "target", header: "Prejemnik" },
{ accessorKey: "subject", header: "Zadeva" },
{ accessorKey: "status", header: "Status" },
{ accessorKey: "last_error", header: "Napaka" },
];
function getStatusVariant(status) {
if (["queued", "processing"].includes(status)) return "secondary";
if (status === "sent") return "default";
if (status === "failed") return "destructive";
return "outline";
}
const refreshing = ref(false);
let timer = null;
const isRunning = computed(() => ["queued", "running"].includes(props.package.status));
const firstItem = computed(() =>
props.items?.data && props.items.data.length ? props.items.data[0] : null
);
const firstPayload = computed(() =>
firstItem.value ? firstItem.value.payload_json || {} : {}
);
const payloadSummary = computed(() => ({
mail_profile_id: firstPayload.value?.mail_profile_id ?? null,
template_id: firstPayload.value?.template_id ?? null,
subject: firstPayload.value?.subject ?? null,
}));
function reload() {
refreshing.value = true;
router.reload({
only: ["package", "items"],
onFinish: () => (refreshing.value = false),
preserveScroll: true,
preserveState: true,
});
}
function dispatchPkg() {
router.post(
route("packages.email.dispatch", props.package.id),
{},
{ onSuccess: reload }
);
}
function cancelPkg() {
router.post(
route("packages.email.cancel", props.package.id),
{},
{ onSuccess: reload }
);
}
onMounted(() => {
if (isRunning.value) {
timer = setInterval(reload, 3000);
}
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
</script>
<template>
<AppLayout :title="`E-mail paket #${package.id}`">
<Card class="mb-4">
<CardHeader>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<MailIcon class="h-5 w-5 text-muted-foreground" />
<div>
<CardTitle>E-mail paket #{{ package.id }}</CardTitle>
<CardDescription class="font-mono"
>UUID: {{ package.uuid }}</CardDescription
>
</div>
</div>
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" as-child>
<Link :href="route('packages.email.index')">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Nazaj
</Link>
</Button>
<Button
v-if="['draft', 'failed'].includes(package.status)"
@click="dispatchPkg"
size="sm"
>
<PlayIcon class="h-4 w-4 mr-2" />
Zaženi
</Button>
<Button v-if="isRunning" @click="cancelPkg" variant="destructive" size="sm">
<XCircleIcon class="h-4 w-4 mr-2" />
Prekliči
</Button>
<Button v-if="!isRunning" @click="reload" variant="outline" size="sm">
<RefreshCwIcon class="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
</Card>
<div class="grid sm:grid-cols-4 gap-3 mb-4">
<Card>
<CardHeader class="pb-2">
<CardDescription>Status</CardDescription>
<CardTitle class="text-xl uppercase">{{ package.status }}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>Skupaj</CardDescription>
<CardTitle class="text-xl">{{ package.total_items }}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>Poslano</CardDescription>
<CardTitle class="text-xl text-emerald-700">{{ package.sent_count }}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>Neuspešno</CardDescription>
<CardTitle class="text-xl text-rose-700">{{ package.failed_count }}</CardTitle>
</CardHeader>
</Card>
</div>
<!-- E-mail settings summary -->
<Card class="mb-4">
<CardHeader>
<CardTitle class="text-base">Nastavitve pošiljanja</CardTitle>
</CardHeader>
<CardContent>
<dl class="text-sm grid grid-cols-3 gap-y-2">
<dt class="col-span-1 text-muted-foreground">E-mail profil</dt>
<dd class="col-span-2">{{ payloadSummary.mail_profile_id ?? "—" }}</dd>
<dt class="col-span-1 text-muted-foreground">Predloga</dt>
<dd class="col-span-2">{{ payloadSummary.template_id ?? "—" }}</dd>
<dt class="col-span-1 text-muted-foreground">Zadeva</dt>
<dd class="col-span-2">{{ payloadSummary.subject ?? "—" }}</dd>
</dl>
<div
v-if="
package.meta && (package.meta.source || package.meta.skipped !== undefined)
"
class="mt-3 pt-3 border-t text-xs text-muted-foreground"
>
<span v-if="package.meta.source" class="mr-3"
>Vir: {{ package.meta.source }}</span
>
<span v-if="package.meta.skipped !== undefined"
>Preskočeno: {{ package.meta.skipped }}</span
>
</div>
</CardContent>
</Card>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<MailIcon size="18" />
<CardTitle class="uppercase">Uvozi</CardTitle>
</div>
</template>
<DataTableNew2
:columns="columns"
:data="items.data"
:meta="items"
route-name="packages.email.show"
:route-params="{ id: package.id }"
>
<template #cell-target="{ row }">
<span class="text-sm">{{
(row.target_json && row.target_json.email) || "—"
}}</span>
</template>
<template #cell-subject="{ row }">
<span class="text-xs text-muted-foreground">{{
(row.result_json && row.result_json.subject) ||
(row.payload_json && row.payload_json.subject) ||
"—"
}}</span>
</template>
<template #cell-status="{ row }">
<Badge :variant="getStatusVariant(row.status)">{{ row.status }}</Badge>
</template>
<template #cell-last_error="{ row }">
<span class="text-xs text-rose-700">{{ row.last_error ?? "—" }}</span>
</template>
</DataTableNew2>
</AppCard>
<div v-if="refreshing" class="mt-2 text-xs text-muted-foreground">
Osveževanje ...
</div>
</AppLayout>
</template>
+791
View File
@@ -0,0 +1,791 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router, useForm } from "@inertiajs/vue3";
import { ref, computed, nextTick } from "vue";
import axios from "axios";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Textarea } from "@/Components/ui/textarea";
import { Checkbox } from "@/Components/ui/checkbox";
import { Badge } from "@/Components/ui/badge";
import { Separator } from "@/Components/ui/separator";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
import {
PackageIcon,
PhoneIcon,
UsersIcon,
SearchIcon,
SaveIcon,
ArrowLeftIcon,
FilterIcon,
CalendarIcon,
CheckCircle2Icon,
XCircleIcon,
BadgeCheckIcon,
BadgeXIcon,
} from "lucide-vue-next";
import { fmtDateDMY } from "@/Utilities/functions";
import { upperFirst } from "lodash";
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
import AppRangeDatePicker from "@/Components/app/ui/AppRangeDatePicker.vue";
const props = defineProps({
profiles: { type: Array, default: () => [] },
senders: { type: Array, default: () => [] },
templates: { type: Array, default: () => [] },
segments: { type: Array, default: () => [] },
clients: { type: Array, default: () => [] },
});
const creatingFromContracts = ref(false);
const createMode = ref("numbers"); // 'numbers' | 'contracts'
const form = useForm({
type: "sms",
name: "",
description: "",
profile_id: null,
sender_id: null,
template_id: null,
delivery_report: false,
body: "",
numbers: "", // one per line
});
const filteredSenders = computed(() => {
if (!form.profile_id) return props.senders;
return props.senders.filter((s) => s.profile_id === form.profile_id);
});
function onTemplateChange() {
const template = props.templates.find((t) => t.id === form.template_id);
if (template?.content) {
form.body = template.content;
} else {
form.body = "";
}
}
function submitCreate() {
const lines = (form.numbers || "")
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
if (!lines.length) return;
if (!form.profile_id && !form.template_id) {
alert("Izberi SMS profil ali predlogo.");
return;
}
if (!form.template_id && !form.body) {
alert("Vnesi vsebino sporočila ali izberi predlogo.");
return;
}
const payload = {
type: "sms",
name: form.name || `SMS paket ${new Date().toLocaleString()}`,
description: form.description || "",
items: lines.map((number) => ({
number,
payload: {
profile_id: form.profile_id,
sender_id: form.sender_id,
template_id: form.template_id,
delivery_report: !!form.delivery_report,
body: form.body && form.body.trim() ? form.body.trim() : null,
},
})),
};
router.post(route("packages.sms.store"), payload, {
onSuccess: () => {
router.visit(route("packages.sms.index"));
},
});
}
// Contracts mode state & actions
const contracts = ref({
data: [],
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
});
const segmentId = ref(null);
const search = ref("");
const clientId = ref(null);
const startDateRange = ref({ start: null, end: null });
const promiseDateRange = ref({ start: null, end: null });
const onlyMobile = ref(false);
const onlyValidated = ref(false);
const loadingContracts = ref(false);
// Transform clients for AppCombobox
const clientItems = computed(() =>
props.clients.map((c) => ({
value: c.id,
label: c.name,
}))
);
const selectedContractIds = ref(new Set());
const perPage = ref(25);
// DataTable columns definition
const contractColumns = [
{ accessorKey: "reference", header: "Pogodba" },
{
id: "person",
accessorFn: (row) => row.person?.full_name || "—",
header: "Primer",
},
{
id: "client",
accessorFn: (row) => row.client?.name || "—",
header: "Stranka",
},
{ accessorKey: "start_date", header: "Datum začetka" },
{ accessorKey: "promise_date", header: "Zadnja obljuba" },
{
id: "selected_phone",
accessorFn: (row) => row.selected_phone?.number || "—",
header: "Izbrana številka",
},
{
id: "segment",
accessorFn: (row) => upperFirst(row.segment?.name) || "—",
header: "Segment",
},
{ accessorKey: "no_phone_reason", header: "Opomba" },
];
function onSelectionChange(selectedKeys) {
const newSelection = new Set();
selectedKeys.forEach((key) => {
const index = parseInt(key);
if (contracts.value.data[index]) {
newSelection.add(contracts.value.data[index].id);
}
});
selectedContractIds.value = newSelection;
}
async function loadContracts(url = null) {
loadingContracts.value = true;
try {
const params = new URLSearchParams();
if (segmentId.value) params.append("segment_id", segmentId.value);
if (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value);
if (startDateRange.value?.start)
params.append("start_date_from", startDateRange.value.start);
if (startDateRange.value?.end)
params.append("start_date_to", startDateRange.value.end);
if (promiseDateRange.value?.start)
params.append("promise_date_from", promiseDateRange.value.start);
if (promiseDateRange.value?.end)
params.append("promise_date_to", promiseDateRange.value.end);
if (onlyMobile.value) params.append("only_mobile", "1");
if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value);
const target = url || `${route("packages.sms.contracts")}?${params.toString()}`;
const { data: json } = await axios.get(target, {
headers: { "X-Requested-With": "XMLHttpRequest" },
});
await nextTick();
contracts.value = {
data: json.data || [],
meta: json.meta || { current_page: 1, last_page: 1, per_page: 25, total: 0 },
};
} finally {
loadingContracts.value = false;
}
}
function toggleSelectContract(id) {
const s = selectedContractIds.value;
if (s.has(id)) {
s.delete(id);
} else {
s.add(id);
}
selectedContractIds.value = new Set(Array.from(s));
}
const rowSelection = computed(() => {
const selection = {};
contracts.value.data.forEach((contract, index) => {
if (selectedContractIds.value.has(contract.id)) {
selection[index.toString()] = true;
}
});
return selection;
});
const tableKey = computed(() => {
return `contracts-${contracts.value.meta.current_page}-${contracts.value.data.length}`;
});
function clearSelection() {
selectedContractIds.value = new Set();
}
function goToPage(page) {
if (page < 1 || page > contracts.value.meta.last_page) return;
const params = new URLSearchParams();
if (segmentId.value) params.append("segment_id", segmentId.value);
if (search.value) params.append("q", search.value);
if (clientId.value) params.append("client_id", clientId.value);
if (startDateRange.value?.start)
params.append("start_date_from", startDateRange.value.start);
if (startDateRange.value?.end) params.append("start_date_to", startDateRange.value.end);
if (promiseDateRange.value?.start)
params.append("promise_date_from", promiseDateRange.value.start);
if (promiseDateRange.value?.end)
params.append("promise_date_to", promiseDateRange.value.end);
if (onlyMobile.value) params.append("only_mobile", "1");
if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value);
params.append("page", page);
const url = `${route("packages.sms.contracts")}?${params.toString()}`;
loadContracts(url);
}
function resetFilters() {
segmentId.value = null;
clientId.value = null;
search.value = "";
startDateRange.value = { start: null, end: null };
promiseDateRange.value = { start: null, end: null };
onlyMobile.value = false;
onlyValidated.value = false;
contracts.value = {
data: [],
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
};
}
function submitCreateFromContracts() {
const ids = Array.from(selectedContractIds.value);
if (!ids.length) return;
const visibleById = new Map((contracts.value.data || []).map((c) => [c.id, c]));
const selectedVisible = ids.map((id) => visibleById.get(id)).filter(Boolean);
if (selectedVisible.length && selectedVisible.every((c) => !c?.selected_phone)) {
alert("Za izbrane pogodbe ni mogoče najti prejemnikov (telefonov).");
return;
}
const payload = {
type: "sms",
name: form.name || `SMS paket (segment) ${new Date().toLocaleString()}`,
description: form.description || "",
payload: {
profile_id: form.profile_id,
sender_id: form.sender_id,
template_id: form.template_id,
delivery_report: !!form.delivery_report,
body: form.body && form.body.trim() ? form.body.trim() : null,
},
contract_ids: ids,
};
creatingFromContracts.value = true;
router.post(route("packages.sms.store-from-contracts"), payload, {
onSuccess: () => {
router.visit(route("packages.sms.index"));
},
onError: (errors) => {
const first = errors && Object.values(errors)[0];
if (first) {
alert(String(first));
}
},
onFinish: () => {
creatingFromContracts.value = false;
},
});
}
const numbersCount = computed(() => {
return (form.numbers || "")
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean).length;
});
</script>
<template>
<AppLayout title="Ustvari SMS paket">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<Link :href="route('packages.sms.index')">
<Button variant="ghost" size="sm">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Nazaj
</Button>
</Link>
</div>
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<PackageIcon class="h-6 w-6 text-primary" />
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight">Ustvari SMS paket</h1>
<p class="text-sm text-muted-foreground">Pošlji SMS sporočila v paketu</p>
</div>
</div>
</div>
<!-- Main Content -->
<Tabs v-model="createMode" class="w-full">
<TabsList class="flex flex-row justify-baseline py-4">
<TabsTrigger value="numbers" class="p-3">
<span class="flex gap-2 items-center align-middle justify-center">
<PhoneIcon class="h-5 w-5" />Vnos številk
</span>
</TabsTrigger>
<TabsTrigger value="contracts" class="p-3">
<span class="flex gap-2 items-center align-middle justify-center">
<UsersIcon class="h-5 w-5" />Iz pogodb (segment)
</span>
</TabsTrigger>
</TabsList>
<!-- Package Details Card -->
<Card class="mb-6">
<CardHeader>
<CardTitle>Podatki o paketu</CardTitle>
<CardDescription>Osnovne informacije in SMS nastavitve</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<!-- Basic Info -->
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="name">Ime paketa</Label>
<Input
id="name"
v-model="form.name"
placeholder="Npr. SMS kampanja december 2024"
/>
</div>
<div class="space-y-2">
<Label for="description">Opis</Label>
<Input
id="description"
v-model="form.description"
placeholder="Neobvezen opis paketa"
/>
</div>
</div>
<Separator />
<!-- SMS Configuration -->
<div>
<h3 class="text-sm font-semibold mb-4">SMS nastavitve</h3>
<div class="grid gap-4 md:grid-cols-3">
<div class="space-y-2">
<Label>SMS profil</Label>
<Select v-model="form.profile_id">
<SelectTrigger>
<SelectValue placeholder="Izberi profil" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="p in profiles" :key="p.id" :value="p.id">
{{ p.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Pošiljatelj</Label>
<Select v-model="form.sender_id">
<SelectTrigger>
<SelectValue placeholder="Izberi pošiljatelja" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="s in filteredSenders" :key="s.id" :value="s.id">
{{ s.sname }}
<span v-if="s.phone_number" class="text-muted-foreground">
({{ s.phone_number }})
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Predloga</Label>
<Select v-model="form.template_id" @update:model-value="onTemplateChange">
<SelectTrigger>
<SelectValue placeholder="Izberi predlogo" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null"></SelectItem>
<SelectItem v-for="t in templates" :key="t.id" :value="t.id">
{{ t.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div class="space-y-2">
<Label for="body">Vsebina sporočila</Label>
<Textarea
id="body"
v-model="form.body"
rows="4"
placeholder="Vsebina SMS sporočila..."
class="font-mono text-sm"
/>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Checkbox
:model-value="form.delivery_report"
@update:model-value="(val) => (form.delivery_report = val)"
id="delivery-report"
:disabled="true"
/>
<Label for="delivery-report" class="cursor-pointer text-sm">
Zahtevaj delivery report
</Label>
</div>
<p class="text-xs text-muted-foreground">
{{ form.body?.length || 0 }} znakov
</p>
</div>
</div>
</CardContent>
</Card>
<!-- Numbers Mode -->
<TabsContent value="numbers">
<Card>
<CardHeader>
<CardTitle>Telefonske številke</CardTitle>
<CardDescription
>Vnesi telefonske številke prejemnikov (ena na vrstico)</CardDescription
>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Textarea
v-model="form.numbers"
rows="10"
placeholder="+38640123456&#10;+38640123457&#10;+38641234567"
class="font-mono text-sm"
/>
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">
<strong>{{ numbersCount }}</strong>
{{
numbersCount === 1
? "številka"
: numbersCount < 5
? "številke"
: "številk"
}}
</p>
<Badge v-if="numbersCount > 0" variant="secondary">
<CheckCircle2Icon class="h-3 w-3 mr-1" />
Pripravljeno
</Badge>
</div>
</div>
<div class="flex justify-end gap-2">
<Button @click="router.visit(route('packages.sms.index'))" variant="outline">
Prekliči
</Button>
<Button
@click="submitCreate"
:disabled="numbersCount === 0 || (!form.profile_id && !form.template_id)"
>
<SaveIcon class="h-4 w-4 mr-2" />
Ustvari paket
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<!-- Contracts Mode -->
<TabsContent value="contracts">
<Card class="mb-6">
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>Filtri za pogodbe</CardTitle>
<CardDescription
>Najdi prejemnike glede na pogodbe in segmente</CardDescription
>
</div>
<Badge variant="outline" class="text-xs">
<FilterIcon class="h-3 w-3 mr-1" />
Napredno iskanje
</Badge>
</div>
</CardHeader>
<CardContent class="space-y-6">
<!-- Basic filters -->
<div class="grid gap-4 md:grid-cols-3">
<div class="space-y-2">
<Label>Segment</Label>
<Select v-model="segmentId" @update:model-value="loadContracts()">
<SelectTrigger>
<SelectValue placeholder="Vsi segmenti" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Vsi segmenti</SelectItem>
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
{{ s.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Stranka</Label>
<AppCombobox
v-model="clientId"
:items="clientItems"
placeholder="Vse stranke"
search-placeholder="Išči stranko..."
empty-text="Stranka ni najdena."
button-class="w-full"
@update:model-value="loadContracts()"
/>
</div>
<div class="space-y-2">
<Label>Iskanje po referenci</Label>
<Input
v-model="search"
@keyup.enter="loadContracts()"
placeholder="Vnesi referenco..."
/>
</div>
</div>
<Separator />
<!-- Date filters -->
<div>
<h4 class="text-sm font-semibold mb-3 flex items-center gap-2">
<CalendarIcon class="h-4 w-4" />
Datumski filtri
</h4>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-3">
<p class="text-sm text-muted-foreground">Datum začetka pogodbe</p>
<AppRangeDatePicker
v-model="startDateRange"
placeholder="Izberi obdobje"
button-class="w-full"
:number-of-months="1"
/>
</div>
<div class="space-y-3">
<p class="text-sm text-muted-foreground">Datum obljube plačila</p>
<AppRangeDatePicker
v-model="promiseDateRange"
placeholder="Izberi obdobje"
button-class="w-full"
:number-of-months="1"
/>
</div>
</div>
</div>
<Separator />
<!-- Phone filters -->
<div>
<h4 class="text-sm font-semibold mb-3">Telefonski filtri</h4>
<div class="flex flex-wrap gap-4">
<div class="flex items-center gap-2">
<Checkbox
:model-value="onlyMobile"
@update:model-value="(val) => { onlyMobile = val; }"
id="only-mobile"
/>
<Label for="only-mobile" class="cursor-pointer text-sm">
Samo mobilne številke
</Label>
</div>
<div class="flex items-center gap-2">
<Checkbox
:model-value="onlyValidated"
@update:model-value="(val) => { onlyValidated = val; }"
id="only-validated"
/>
<Label for="only-validated" class="cursor-pointer text-sm">
Samo potrjene številke
</Label>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<Button @click="loadContracts()">
<SearchIcon class="h-4 w-4" />
Išči pogodbe
</Button>
<Button @click="resetFilters" variant="outline">
<XCircleIcon class="h-4 w-4" />
Počisti filtre
</Button>
</div>
</CardContent>
</Card>
<!-- Results -->
<Card v-if="contracts.data.length > 0 || loadingContracts">
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>Rezultati iskanja (do 500 zapisov)</CardTitle>
<CardDescription v-if="contracts.meta.total > 0">
Najdeno {{ contracts.meta.total }}
{{
contracts.meta.total === 1
? "pogodba"
: contracts.meta.total < 5
? "pogodbe"
: "pogodb"
}}
</CardDescription>
</div>
<!-- Create Button -->
<div class="flex justify-end gap-2" v-if="selectedContractIds.size > 0">
<Badge
v-if="selectedContractIds.size > 0"
variant="secondary"
class="text-sm"
>
<CheckCircle2Icon class="h-3 w-3" />
Izbrano: {{ selectedContractIds.size }}
</Badge>
<Button @click="router.visit(route('packages.sms.index'))" variant="outline">
Prekliči
</Button>
<Button
@click="submitCreateFromContracts"
:disabled="selectedContractIds.size === 0 || creatingFromContracts"
>
<SaveIcon class="h-4 w-4" />
Ustvari paket ({{ selectedContractIds.size }}
{{
selectedContractIds.size === 1
? "pogodba"
: selectedContractIds.size < 5
? "pogodbe"
: "pogodb"
}})
</Button>
</div>
</div>
</CardHeader>
<CardContent class="p-0">
<DataTableNew2
v-if="!loadingContracts"
:key="tableKey"
:columns="contractColumns"
:data="contracts.data"
:enableRowSelection="true"
:rowSelection="rowSelection"
:showPagination="true"
:page-size="50"
:page-size-options="[10, 15, 25, 50, 100]"
:showToolbar="false"
@selection:change="onSelectionChange"
>
<template #cell-reference="{ row }">
<div v-if="row.original" class="space-y-1">
<p class="font-medium">{{ row.original.reference || "" }}</p>
<p class="text-xs text-muted-foreground font-mono">
#{{ row.original.id }}
</p>
</div>
</template>
<template #cell-person="{ row }">
<span v-if="row.original" class="text-xs">{{
row.original.person?.full_name || ""
}}</span>
</template>
<template #cell-client="{ row }">
<span v-if="row.original" class="text-xs">{{
row.original.client?.name || ""
}}</span>
</template>
<template #cell-start_date="{ row }">
{{ fmtDateDMY(row.start_date) || "" }}
</template>
<template #cell-promise_date="{ row }">
{{ fmtDateDMY(row.promise_date) || "" }}
</template>
<template #cell-selected_phone="{ row }">
<div v-if="row.selected_phone" class="space-y-1 flex flex-col gap-1">
<div class="flex flex-row gap-1 items-center">
<span>{{ row.selected_phone.number }}</span>
<BadgeCheckIcon
size="18"
color="green"
v-if="row.selected_phone.validated"
/>
<BadgeXIcon
size="18"
color="red"
v-if="!row.selected_phone.validated"
/>
</div>
<div v-if="row.selected_phone.description">
<Badge variant="secondary" class="break-all">
{{ row.selected_phone.description }}
</Badge>
</div>
</div>
<span v-else class="text-xs text-destructive">Ni telefonske št.</span>
</template>
<template #cell-no_phone_reason="{ row }">
<span v-if="row.original" class="text-xs text-muted-foreground">{{
row.original.no_phone_reason || ""
}}</span>
</template>
</DataTableNew2>
<div v-else class="text-center text-muted-foreground py-24">Nalaganje...</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</AppLayout>
</template>
+177
View File
@@ -0,0 +1,177 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import { MessageSquareIcon, PlusIcon, Trash2Icon, EyeIcon, ArrowLeftIcon } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { fmtDateTime } from "@/Utilities/functions";
const props = defineProps({
packages: { type: Object, required: true },
});
const deletingId = ref(null);
const packageToDelete = ref(null);
const showDeleteDialog = ref(false);
const columns = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "name", header: "Ime" },
{ accessorKey: "status", header: "Status" },
{ accessorKey: "total_items", header: "Skupaj" },
{ accessorKey: "sent_count", header: "Poslano" },
{ accessorKey: "failed_count", header: "Neuspešno" },
{ accessorKey: "finished_at", header: "Zaključeno" },
{ accessorKey: "actions", header: "", enableSorting: false },
];
function getStatusVariant(status) {
if (["queued", "running"].includes(status)) return "secondary";
if (status === "completed") return "default";
if (status === "failed") return "destructive";
return "outline";
}
function goShow(id) {
router.visit(route("packages.sms.show", id));
}
function openDeleteDialog(pkg) {
if (!pkg || pkg.status !== "draft") return;
packageToDelete.value = pkg;
showDeleteDialog.value = true;
}
function confirmDelete() {
if (!packageToDelete.value) return;
deletingId.value = packageToDelete.value.id;
router.delete(route("packages.sms.destroy", packageToDelete.value.id), {
onSuccess: () => {
router.reload({ only: ["packages"] });
},
onFinish: () => {
deletingId.value = null;
showDeleteDialog.value = false;
packageToDelete.value = null;
},
});
}
</script>
<template>
<AppLayout title="SMS paketi">
<Card class="mb-4">
<CardHeader>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" as-child>
<Link :href="route('packages.index')">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Paketi
</Link>
</Button>
<MessageSquareIcon class="h-5 w-5 text-muted-foreground" />
<CardTitle>SMS paketi</CardTitle>
</div>
<Link :href="route('packages.sms.create')">
<Button>
<PlusIcon class="h-4 w-4" />
Nov paket
</Button>
</Link>
</div>
</CardHeader>
</Card>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<MessageSquareIcon size="18" />
<CardTitle class="uppercase">SMS Paketi</CardTitle>
</div>
</template>
<DataTableNew2
:columns="columns"
:data="packages.data"
:meta="packages"
route-name="packages.sms.index"
>
<template #cell-name="{ row }">
<span class="text-sm">{{ row.name ?? "—" }}</span>
</template>
<template #cell-status="{ row }">
<Badge :variant="getStatusVariant(row.status)">{{ row.status }}</Badge>
</template>
<template #cell-finished_at="{ row }">
<span class="text-xs text-muted-foreground">{{
fmtDateTime(row.finished_at) ?? "—"
}}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<Button @click="goShow(row.id)" variant="ghost" size="sm">
<EyeIcon class="h-4 w-4" />
</Button>
<Button
v-if="row.status === 'draft'"
@click="openDeleteDialog(row)"
:disabled="deletingId === row.id"
variant="ghost"
size="sm"
>
<Trash2Icon class="h-4 w-4" />
</Button>
</div>
</template>
</DataTableNew2>
</AppCard>
<!-- Delete Confirmation Dialog -->
<AlertDialog v-model:open="showDeleteDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Izbriši paket?</AlertDialogTitle>
<AlertDialogDescription>
Ali ste prepričani, da želite izbrisati paket
<strong v-if="packageToDelete"
>#{{ packageToDelete.id }} -
{{ packageToDelete.name || "Brez imena" }}</strong
>? Tega dejanja ni mogoče razveljaviti.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Prekliči</AlertDialogCancel>
<AlertDialogAction
@click="confirmDelete"
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Izbriši
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AppLayout>
</template>
+333
View File
@@ -0,0 +1,333 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { onMounted, onUnmounted, ref, computed } from "vue";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { Separator } from "@/Components/ui/separator";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import {
PackageIcon,
ArrowLeftIcon,
PlayIcon,
XCircleIcon,
RefreshCwIcon,
CopyIcon,
} from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
const props = defineProps({
package: { type: Object, required: true },
items: { type: Object, required: true },
preview: { type: [Object, null], default: null },
});
const columns = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "target", header: "Prejemnik" },
{ accessorKey: "message", header: "Sporočilo" },
{ accessorKey: "status", header: "Status" },
{ accessorKey: "last_error", header: "Napaka" },
{ accessorKey: "provider_message_id", header: "Provider ID" },
{ accessorKey: "cost", header: "Cena" },
{ accessorKey: "currency", header: "Valuta" },
];
function getStatusVariant(status) {
if (["queued", "processing"].includes(status)) return "secondary";
if (status === "sent") return "default";
if (status === "failed") return "destructive";
return "outline";
}
const refreshing = ref(false);
let timer = null;
const isRunning = computed(() => ["queued", "running"].includes(props.package.status));
const firstItem = computed(() =>
props.items?.data && props.items.data.length ? props.items.data[0] : null
);
const firstPayload = computed(() =>
firstItem.value ? firstItem.value.payload_json || {} : {}
);
const messageBody = computed(() => {
const b = firstPayload.value?.body;
if (typeof b === "string") {
const t = b.trim();
return t.length ? t : null;
}
return null;
});
const payloadSummary = computed(() => ({
profile_id: firstPayload.value?.profile_id ?? null,
sender_id: firstPayload.value?.sender_id ?? null,
template_id: firstPayload.value?.template_id ?? null,
delivery_report: !!firstPayload.value?.delivery_report,
}));
function reload() {
refreshing.value = true;
router.reload({
only: ["package", "items"],
onFinish: () => (refreshing.value = false),
preserveScroll: true,
preserveState: true,
});
}
function dispatchPkg() {
router.post(
route("packages.sms.dispatch", props.package.id),
{},
{ onSuccess: reload }
);
}
function cancelPkg() {
router.post(
route("packages.sms.cancel", props.package.id),
{},
{ onSuccess: reload }
);
}
onMounted(() => {
if (isRunning.value) {
timer = setInterval(reload, 3000);
}
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
async function copyText(text) {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
} catch (e) {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand("copy");
} catch (_) {}
document.body.removeChild(ta);
}
}
</script>
<template>
<AppLayout :title="`SMS paket #${package.id}`">
<Card class="mb-4">
<CardHeader>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<PackageIcon class="h-5 w-5 text-muted-foreground" />
<div>
<CardTitle>SMS paket #{{ package.id }}</CardTitle>
<CardDescription class="font-mono"
>UUID: {{ package.uuid }}</CardDescription
>
</div>
</div>
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" as-child>
<Link :href="route('packages.sms.index')">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Nazaj
</Link>
</Button>
<Button
v-if="['draft', 'failed'].includes(package.status)"
@click="dispatchPkg"
size="sm"
>
<PlayIcon class="h-4 w-4 mr-2" />
Zaženi
</Button>
<Button v-if="isRunning" @click="cancelPkg" variant="destructive" size="sm">
<XCircleIcon class="h-4 w-4 mr-2" />
Prekliči
</Button>
<Button v-if="!isRunning" @click="reload" variant="outline" size="sm">
<RefreshCwIcon class="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
</Card>
<div class="grid sm:grid-cols-4 gap-3 mb-4">
<Card>
<CardHeader class="pb-2">
<CardDescription>Status</CardDescription>
<CardTitle class="text-xl uppercase">{{ package.status }}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>Skupaj</CardDescription>
<CardTitle class="text-xl">{{ package.total_items }}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>Poslano</CardDescription>
<CardTitle class="text-xl text-emerald-700">{{ package.sent_count }}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>Neuspešno</CardDescription>
<CardTitle class="text-xl text-rose-700">{{ package.failed_count }}</CardTitle>
</CardHeader>
</Card>
</div>
<!-- Payload / Message preview -->
<div class="mb-4 grid gap-3 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="text-base">Sporočilo</CardTitle>
</CardHeader>
<CardContent>
<template v-if="preview && preview.content">
<div class="text-sm whitespace-pre-wrap mb-3">{{ preview.content }}</div>
<Button @click="copyText(preview.content)" size="sm" variant="outline">
<CopyIcon class="h-3.5 w-3.5 mr-2" />
Kopiraj
</Button>
<p
v-if="preview.source === 'template' && preview.template"
class="mt-3 text-xs text-muted-foreground"
>
Predloga: {{ preview.template.name }} (#{{ preview.template.id }})
</p>
</template>
<template v-else>
<div v-if="messageBody" class="text-sm whitespace-pre-wrap">
{{ messageBody }}
</div>
<div v-else class="text-sm text-muted-foreground">
<template v-if="payloadSummary.template_id">
Uporabljena bo predloga #{{ payloadSummary.template_id }}.
</template>
<template v-else> Vsebina sporočila ni določena. </template>
</div>
</template>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-base">Meta / Nastavitve pošiljanja</CardTitle>
</CardHeader>
<CardContent>
<dl class="text-sm grid grid-cols-3 gap-y-2">
<dt class="col-span-1 text-muted-foreground">Profil</dt>
<dd class="col-span-2">{{ payloadSummary.profile_id ?? "—" }}</dd>
<dt class="col-span-1 text-muted-foreground">Pošiljatelj</dt>
<dd class="col-span-2">{{ payloadSummary.sender_id ?? "—" }}</dd>
<dt class="col-span-1 text-muted-foreground">Predloga</dt>
<dd class="col-span-2">{{ payloadSummary.template_id ?? "—" }}</dd>
<dt class="col-span-1 text-muted-foreground">Delivery report</dt>
<dd class="col-span-2">{{ payloadSummary.delivery_report ? "da" : "ne" }}</dd>
</dl>
<div
v-if="
package.meta && (package.meta.source || package.meta.skipped !== undefined)
"
class="mt-3 pt-3 border-t text-xs text-muted-foreground"
>
<span v-if="package.meta.source" class="mr-3"
>Vir: {{ package.meta.source }}</span
>
<span v-if="package.meta.skipped !== undefined"
>Preskočeno: {{ package.meta.skipped }}</span
>
</div>
</CardContent>
</Card>
</div>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<PackageIcon size="18" />
<CardTitle class="uppercase">Uvozi</CardTitle>
</div>
</template>
<DataTableNew2
:columns="columns"
:data="items.data"
:meta="items"
route-name="packages.sms.show"
:route-params="{ id: package.id }"
>
<template #cell-target="{ row }">
<span class="text-sm">{{
(row.target_json && row.target_json.number) || "—"
}}</span>
</template>
<template #cell-message="{ row }">
<div class="flex items-start gap-2">
<div class="text-xs max-w-[420px] line-clamp-2 whitespace-pre-wrap">
{{ row.rendered_preview || "—" }}
</div>
<Button
v-if="row.rendered_preview"
@click="copyText(row.rendered_preview)"
size="sm"
variant="ghost"
>
<CopyIcon class="h-3.5 w-3.5" />
</Button>
</div>
</template>
<template #cell-status="{ row }">
<Badge :variant="getStatusVariant(row.status)">{{ row.status }}</Badge>
</template>
<template #cell-last_error="{ row }">
<span class="text-xs text-rose-700">{{ row.last_error ?? "—" }}</span>
</template>
<template #cell-provider_message_id="{ row }">
<span class="font-mono text-xs text-muted-foreground">{{
row.provider_message_id ?? "—"
}}</span>
</template>
<template #cell-cost="{ row }">
<span class="text-sm">{{ row.cost ?? "—" }}</span>
</template>
<template #cell-currency="{ row }">
<span class="text-sm">{{ row.currency ?? "—" }}</span>
</template>
</DataTableNew2>
</AppCard>
<div v-if="refreshing" class="mt-2 text-xs text-muted-foreground">
Osveževanje ...
</div>
</AppLayout>
</template>
+83 -1
View File
@@ -1,5 +1,5 @@
<script setup>
import { reactive, ref, computed, onMounted } from "vue";
import { reactive, ref, computed, onMounted, watch } from "vue";
import { Link, router, usePage } from "@inertiajs/vue3";
import axios from "axios";
import AppLayout from "@/Layouts/AppLayout.vue";
@@ -138,11 +138,61 @@ const hasClientFilter = computed(() =>
(props.inputs || []).some((i) => i.type === "select:client")
);
// Async action options for select:action inputs
const actionOptions = ref([]);
const actionLoading = ref(false);
async function fetchActions() {
actionLoading.value = true;
try {
const res = await axios.get(route("reports.actions"));
actionOptions.value = Array.isArray(res.data) ? res.data : [];
} finally {
actionLoading.value = false;
}
}
const hasActionFilter = computed(() =>
(props.inputs || []).some((i) => i.type === "select:action")
);
// Async decision options for select:decision inputs (filtered by selected action)
const decisionOptions = ref([]);
const decisionLoading = ref(false);
async function fetchDecisions(actionId = null) {
decisionLoading.value = true;
try {
const params = actionId ? { action_id: actionId } : {};
const res = await axios.get(route("reports.decisions"), { params });
decisionOptions.value = Array.isArray(res.data) ? res.data : [];
} finally {
decisionLoading.value = false;
}
}
const hasDecisionFilter = computed(() =>
(props.inputs || []).some((i) => i.type === "select:decision")
);
onMounted(() => {
if (hasUserFilter.value) fetchUsers(true);
if (hasClientFilter.value) fetchClients(true);
if (hasActionFilter.value) fetchActions();
if (hasDecisionFilter.value) {
const actionInput = (props.inputs || []).find((i) => i.type === "select:action");
fetchDecisions(actionInput ? (filters[actionInput.key] ?? null) : null);
}
});
// When action filter changes, reload decisions filtered to that action
const actionKey = (props.inputs || []).find((i) => i.type === "select:action")?.key;
if (hasDecisionFilter.value && actionKey) {
watch(
() => filters[actionKey],
(newActionId) => {
filters.decision_id = null;
fetchDecisions(newActionId ?? null);
}
);
}
// Formatting helpers (EU style)
function formatNumberEU(val) {
if (typeof val !== "number") return String(val ?? "");
@@ -382,6 +432,38 @@ function formatCell(value, key) {
Nalagam
</div>
</template>
<template v-else-if="inp.type === 'select:action'">
<AppCombobox
v-model="filters[inp.key]"
:items="
actionOptions.map((a) => ({ value: a.id, label: a.name }))
"
placeholder="Brez"
search-placeholder="Išči akcijo..."
empty-text="Ni akcij"
:disabled="actionLoading"
button-class="w-full"
/>
<div v-if="actionLoading" class="text-xs text-muted-foreground">
Nalagam
</div>
</template>
<template v-else-if="inp.type === 'select:decision'">
<AppCombobox
v-model="filters[inp.key]"
:items="
decisionOptions.map((d) => ({ value: d.id, label: d.name }))
"
placeholder="Brez"
search-placeholder="Išči odločitev..."
empty-text="Ni odločitev"
:disabled="decisionLoading"
button-class="w-full"
/>
<div v-if="decisionLoading" class="text-xs text-muted-foreground">
Nalagam
</div>
</template>
<template v-else>
<Input
v-model="filters[inp.key]"
+34 -10
View File
@@ -139,6 +139,7 @@
// 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');
Route::get('email-logs/{emailLog}/body', [\App\Http\Controllers\Admin\EmailLogController::class, 'body'])->name('email-logs.body');
// SMS senders
Route::get('sms-senders', [\App\Http\Controllers\Admin\SmsSenderController::class, 'index'])->name('sms-senders.index');
@@ -163,17 +164,35 @@
});
// Packages (SMS batch sender) — accessible to users with manage-settings permission
// Packages — accessible to users with manage-settings permission
Route::middleware(['permission:manage-settings'])->prefix('packages')->name('packages.')->group(function () {
Route::get('/', [\App\Http\Controllers\Admin\PackageController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Admin\PackageController::class, 'create'])->name('create');
Route::get('/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'show'])->name('show');
Route::post('/', [\App\Http\Controllers\Admin\PackageController::class, 'store'])->name('store');
Route::post('/{package}/dispatch', [\App\Http\Controllers\Admin\PackageController::class, 'dispatch'])->name('dispatch');
Route::post('/{package}/cancel', [\App\Http\Controllers\Admin\PackageController::class, 'cancel'])->name('cancel');
Route::delete('/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'destroy'])->name('destroy');
Route::get('/contracts/list', [\App\Http\Controllers\Admin\PackageController::class, 'contracts'])->name('contracts');
Route::post('/from-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'storeFromContracts'])->name('store-from-contracts');
// Landing
Route::get('/', [\App\Http\Controllers\Admin\PackageController::class, 'landing'])->name('index');
// SMS packages
Route::prefix('sms')->name('sms.')->group(function () {
Route::get('/', [\App\Http\Controllers\Admin\PackageController::class, 'smsIndex'])->name('index');
Route::get('/create', [\App\Http\Controllers\Admin\PackageController::class, 'smsCreate'])->name('create');
Route::post('/', [\App\Http\Controllers\Admin\PackageController::class, 'store'])->name('store');
Route::get('/contracts/list', [\App\Http\Controllers\Admin\PackageController::class, 'contracts'])->name('contracts');
Route::post('/from-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'storeFromContracts'])->name('store-from-contracts');
Route::get('/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'smsShow'])->name('show');
Route::post('/{package}/dispatch', [\App\Http\Controllers\Admin\PackageController::class, 'dispatch'])->name('dispatch');
Route::post('/{package}/cancel', [\App\Http\Controllers\Admin\PackageController::class, 'cancel'])->name('cancel');
Route::delete('/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'destroy'])->name('destroy');
});
// Email packages
Route::prefix('email')->name('email.')->group(function () {
Route::get('/', [\App\Http\Controllers\Admin\PackageController::class, 'emailIndex'])->name('index');
Route::get('/create', [\App\Http\Controllers\Admin\PackageController::class, 'emailCreate'])->name('create');
Route::get('/contracts/list', [\App\Http\Controllers\Admin\PackageController::class, 'contractsForEmail'])->name('contracts');
Route::post('/from-contracts', [\App\Http\Controllers\Admin\PackageController::class, 'storeEmailFromContracts'])->name('store-from-contracts');
Route::get('/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'emailShow'])->name('show');
Route::post('/{package}/dispatch', [\App\Http\Controllers\Admin\PackageController::class, 'dispatch'])->name('dispatch');
Route::post('/{package}/cancel', [\App\Http\Controllers\Admin\PackageController::class, 'cancel'])->name('cancel');
Route::delete('/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'destroy'])->name('destroy');
});
});
// Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service
@@ -374,6 +393,9 @@
});
// client-case / person phone - send SMS
Route::post('client-cases/{client_case:uuid}/phone/{phone_id}/sms', [ClientCaseContoller::class, 'sendSmsToPhone'])->name('clientCase.phone.sms');
// client-case / person email - preview & send
Route::post('client-cases/{client_case:uuid}/email/{email_id}/preview', [ClientCaseContoller::class, 'previewEmailForEmail'])->name('clientCase.email.preview');
Route::post('client-cases/{client_case:uuid}/email/{email_id}/send', [ClientCaseContoller::class, 'sendEmailToEmail'])->name('clientCase.email.send');
// client-case / contracts list for SMS dialog
Route::get('client-cases/{client_case:uuid}/contracts/list', [ClientCaseContoller::class, 'listContracts'])->name('clientCase.contracts.list');
// client-case / SMS template preview
@@ -538,6 +560,8 @@
// users/clients lookup must come before {slug} to avoid route conflicts
Route::get('reports/users', [\App\Http\Controllers\ReportController::class, 'users'])->name('reports.users');
Route::get('reports/clients', [\App\Http\Controllers\ReportController::class, 'clients'])->name('reports.clients');
Route::get('reports/actions', [\App\Http\Controllers\ReportController::class, 'actions'])->name('reports.actions');
Route::get('reports/decisions', [\App\Http\Controllers\ReportController::class, 'decisions'])->name('reports.decisions');
Route::get('reports/{slug}', [\App\Http\Controllers\ReportController::class, 'show'])->name('reports.show');
Route::get('reports/{slug}/data', [\App\Http\Controllers\ReportController::class, 'data'])->name('reports.data');
});