Package and individual mail sender, new report, and other changes
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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,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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ',', '.');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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>{{body_text}}</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>
|
||||
|
||||
@@ -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">{{ body_text }}</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>{{body_text}}</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 +38640123457 +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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user