Compare commits
82 Commits
ebfecb8a30
...
prerelease
| Author | SHA1 | Date | |
|---|---|---|---|
| 8031501d25 | |||
| adc2a64687 | |||
| 11206fb4f7 | |||
| 39a597f6eb | |||
| 5d4498ac5a | |||
| 622f53e401 | |||
| 96473fd60b | |||
| 5ddca35389 | |||
| 94ad0c0772 | |||
| 2140181a76 | |||
| 06fa443b3e | |||
| 6c45063e47 | |||
| b8c9b51f29 | |||
| a4db37adfa | |||
| 76f76f73b4 | |||
| d69f4dd6f6 | |||
| a596177a68 | |||
| aa40ebed5c | |||
| 79de54eef0 | |||
| 53941c054e | |||
| 1a7d2793b0 | |||
| fa54cf48f3 | |||
| d2287ef963 | |||
| fb7160eb33 | |||
| 44f9f8f9fa | |||
| edbdb64102 | |||
| 8125b4d321 | |||
| 46feba2df7 | |||
| 1395b72ae8 | |||
| ad8e0d5cee | |||
| 5f879c9436 | |||
| 0d9c8c8b30 | |||
| 07b1deda21 | |||
| ed4f67effb | |||
| 7d4d18143d | |||
| bdde610178 | |||
| cb7851f91c | |||
| 20d4907fc5 | |||
| 369af34ad4 | |||
| 266af6595e | |||
| 930ac83604 | |||
| 3a2eed7dda | |||
| 67ebe4b225 | |||
| 872b76b012 | |||
| 90bbf1942c | |||
| 424151497d | |||
| abf1676292 | |||
| ea00852528 | |||
| 322bd66502 | |||
| 8f2e5e282c | |||
| bf09164dbe | |||
| f54f198879 | |||
| 3b1a24287a | |||
| 761799bdbe | |||
| 04f31e62aa | |||
| e782bcca7c | |||
| ed62311ba4 | |||
| ddfc79ffe8 | |||
| 79b3e20b02 | |||
| 0bbed64542 | |||
| ec6456cf23 | |||
| 23f2011e33 | |||
| e0303ece74 | |||
| 3ab1c05fcc | |||
| 1b615163be | |||
| 7c7defb6c5 | |||
| afaefa8a9d | |||
| 0598261cdc | |||
| c8029c9eb0 | |||
| 6108028942 | |||
| 932bbdc294 | |||
| 078b08cbc5 | |||
| 86898eac1a | |||
| c177264b0b | |||
| 1b96b0d821 | |||
| 39dd3d4d8f | |||
| f40c3d0f2e | |||
| ee1af56d03 | |||
| b9ca8244ef | |||
| 175111bed4 | |||
| f976b4d6ef | |||
| 9e47b399ed |
@@ -24,15 +24,14 @@ public function build($options = null)
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
$months = $data->pluck('month')->map(
|
$months = $data->pluck('month')->map(
|
||||||
fn($nu)
|
fn ($nu) => \DateTime::createFromFormat('!m', $nu)->format('F'))->toArray();
|
||||||
=> \DateTime::createFromFormat('!m', $nu)->format('F'))->toArray();
|
|
||||||
|
|
||||||
$newCases = $data->pluck('count')->toArray();
|
$newCases = $data->pluck('count')->toArray();
|
||||||
|
|
||||||
return $this->chart->areaChart()
|
return $this->chart->areaChart()
|
||||||
->setTitle('Novi primeri zadnjih šest mesecev.')
|
->setTitle('Novi primeri zadnjih šest mesecev.')
|
||||||
->addData('Primeri', $newCases)
|
->addData('Primeri', $newCases)
|
||||||
//->addData('Completed', [7, 2, 7, 2, 5, 4])
|
// ->addData('Completed', [7, 2, 7, 2, 5, 4])
|
||||||
->setColors(['#ff6384'])
|
->setColors(['#ff6384'])
|
||||||
->setXAxis($months)
|
->setXAxis($months)
|
||||||
->setToolbar(true)
|
->setToolbar(true)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\DocumentTemplate;
|
||||||
|
use App\Services\Documents\DocumentSettings;
|
||||||
|
use App\Services\Documents\TokenScanner;
|
||||||
|
use App\Services\Documents\TokenValueResolver;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class DocScanCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'doc:scan {contract : Contract UUID} {xml : Path to Word document.xml}';
|
||||||
|
|
||||||
|
protected $description = 'Scan a Word document.xml for tokens and resolve values against a contract UUID';
|
||||||
|
|
||||||
|
public function handle(TokenScanner $scanner, TokenValueResolver $resolver, DocumentSettings $settings): int
|
||||||
|
{
|
||||||
|
$uuid = (string) $this->argument('contract');
|
||||||
|
$xmlPath = (string) $this->argument('xml');
|
||||||
|
|
||||||
|
if (! is_file($xmlPath)) {
|
||||||
|
$this->error("XML file not found: {$xmlPath}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
$xml = file_get_contents($xmlPath);
|
||||||
|
if ($xml === false) {
|
||||||
|
$this->error('Unable to read XML file.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract = Contract::where('uuid', $uuid)->first();
|
||||||
|
if (! $contract) {
|
||||||
|
$this->error("Contract not found for UUID: {$uuid}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize common Word run boundaries so tokens appear contiguous
|
||||||
|
$norm = $this->normalizeRunsForTokens($xml);
|
||||||
|
|
||||||
|
$tokens = $scanner->scan($norm);
|
||||||
|
$this->info('Detected tokens:');
|
||||||
|
foreach ($tokens as $t) {
|
||||||
|
$this->line(" - {$t}");
|
||||||
|
}
|
||||||
|
if (empty($tokens)) {
|
||||||
|
$this->warn('No tokens detected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a minimal in-memory template using global whitelist so we can resolve values
|
||||||
|
$whitelist = $settings->get()->whitelist ?? [];
|
||||||
|
if (! is_array($whitelist)) {
|
||||||
|
$whitelist = [];
|
||||||
|
}
|
||||||
|
$entities = array_keys($whitelist);
|
||||||
|
$template = new DocumentTemplate([
|
||||||
|
'entities' => $entities,
|
||||||
|
'columns' => $whitelist,
|
||||||
|
'fail_on_unresolved' => false,
|
||||||
|
'formatting_options' => [],
|
||||||
|
'meta' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Resolve values using a relaxed policy to avoid exceptions on unknowns
|
||||||
|
$user = auth()->user() ?? (\App\Models\User::query()->first() ?: new \App\Models\User(['name' => 'System']));
|
||||||
|
$resolved = $resolver->resolve($tokens, $template, $contract, $user, policy: 'blank');
|
||||||
|
$values = $resolved['values'] ?? [];
|
||||||
|
$unresolved = $resolved['unresolved'] ?? [];
|
||||||
|
|
||||||
|
$this->info('Resolved values:');
|
||||||
|
foreach ($values as $k => $v) {
|
||||||
|
$short = strlen((string) $v) > 120 ? substr((string) $v, 0, 117).'...' : (string) $v;
|
||||||
|
$this->line(" - {$k} => {$short}");
|
||||||
|
}
|
||||||
|
if (! empty($unresolved)) {
|
||||||
|
$this->warn('Unresolved tokens:');
|
||||||
|
foreach ($unresolved as $u) {
|
||||||
|
$this->line(" - {$u}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeRunsForTokens(string $xml): string
|
||||||
|
{
|
||||||
|
// Remove proofing error spans that may split content
|
||||||
|
$xml = preg_replace('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
|
||||||
|
// Iteratively collapse boundaries between text runs, even if w:rPr is present
|
||||||
|
$patterns = [
|
||||||
|
// </w:t></w:r> [optional proofErr] <w:r ...> [optional rPr] <w:t>
|
||||||
|
'#</w:t>\s*</w:r>\s*(?:<w:proofErr[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#is',
|
||||||
|
];
|
||||||
|
$prev = null;
|
||||||
|
while ($prev !== $xml) {
|
||||||
|
$prev = $xml;
|
||||||
|
foreach ($patterns as $pat) {
|
||||||
|
$xml = preg_replace($pat, '', $xml) ?? $xml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove zero-width and soft hyphen characters
|
||||||
|
$xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml);
|
||||||
|
|
||||||
|
return $xml;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class ImportPosts extends Command
|
class ImportPosts extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'import:posts';
|
protected $signature = 'import:posts';
|
||||||
|
|
||||||
protected $description = 'Import posts into Algolia without clearing the index';
|
protected $description = 'Import posts into Algolia without clearing the index';
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
@@ -22,4 +23,3 @@ public function handle()
|
|||||||
$this->info('Posts have been imported into Algolia.');
|
$this->info('Posts have been imported into Algolia.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,15 @@
|
|||||||
class PruneDocumentPreviews extends Command
|
class PruneDocumentPreviews extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'documents:prune-previews {--days=90 : Delete previews older than this many days} {--dry-run : Show what would be deleted without deleting}';
|
protected $signature = 'documents:prune-previews {--days=90 : Delete previews older than this many days} {--dry-run : Show what would be deleted without deleting}';
|
||||||
|
|
||||||
protected $description = 'Deletes generated document preview files older than N days and clears their metadata.';
|
protected $description = 'Deletes generated document preview files older than N days and clears their metadata.';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$days = (int) $this->option('days');
|
$days = (int) $this->option('days');
|
||||||
if ($days < 1) { $days = 90; }
|
if ($days < 1) {
|
||||||
|
$days = 90;
|
||||||
|
}
|
||||||
$cutoff = Carbon::now()->subDays($days);
|
$cutoff = Carbon::now()->subDays($days);
|
||||||
|
|
||||||
$previewDisk = config('files.preview_disk', 'public');
|
$previewDisk = config('files.preview_disk', 'public');
|
||||||
@@ -27,6 +30,7 @@ public function handle(): int
|
|||||||
$count = $query->count();
|
$count = $query->count();
|
||||||
if ($count === 0) {
|
if ($count === 0) {
|
||||||
$this->info('No stale previews found.');
|
$this->info('No stale previews found.');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +40,12 @@ public function handle(): int
|
|||||||
$query->chunkById(200, function ($docs) use ($previewDisk, $dry) {
|
$query->chunkById(200, function ($docs) use ($previewDisk, $dry) {
|
||||||
foreach ($docs as $doc) {
|
foreach ($docs as $doc) {
|
||||||
$path = $doc->preview_path;
|
$path = $doc->preview_path;
|
||||||
if (!$path) { continue; }
|
if (! $path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if ($dry) {
|
if ($dry) {
|
||||||
$this->line("Would delete: {$previewDisk}://{$path} (document #{$doc->id})");
|
$this->line("Would delete: {$previewDisk}://{$path} (document #{$doc->id})");
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\DocumentTemplate;
|
||||||
|
use App\Services\Documents\TokenScanner;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
class TemplateScanCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'template:scan {slug : Template slug} {--tpl-version= : Specific template version number} {--parts : Show per-part tokens}';
|
||||||
|
|
||||||
|
protected $description = 'Scan a stored DOCX template by slug/version and dump detected tokens directly from storage.';
|
||||||
|
|
||||||
|
public function handle(TokenScanner $scanner): int
|
||||||
|
{
|
||||||
|
$slug = (string) $this->argument('slug');
|
||||||
|
$version = $this->option('tpl-version');
|
||||||
|
|
||||||
|
/** @var DocumentTemplate|null $template */
|
||||||
|
$query = DocumentTemplate::query()->where('slug', $slug);
|
||||||
|
if (! empty($version)) {
|
||||||
|
$query->where('version', (int) $version);
|
||||||
|
} else {
|
||||||
|
$query->orderByDesc('version');
|
||||||
|
}
|
||||||
|
$template = $query->first();
|
||||||
|
if (! $template) {
|
||||||
|
$this->error("Template not found for slug '{$slug}'".($version ? " v{$version}" : ''));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$disk = 'public';
|
||||||
|
$path = $template->file_path;
|
||||||
|
if (! $path || ! Storage::disk($disk)->exists($path)) {
|
||||||
|
$this->error('Template file not found on disk: '.$path);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = Storage::disk($disk)->get($path);
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
|
||||||
|
file_put_contents($tmp, $bytes);
|
||||||
|
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
if ($zip->open($tmp) !== true) {
|
||||||
|
$this->error('Unable to open DOCX (zip).');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect parts: main + headers/footers + notes/comments
|
||||||
|
$parts = [];
|
||||||
|
$doc = $zip->getFromName('word/document.xml');
|
||||||
|
if ($doc !== false) {
|
||||||
|
$parts['word/document.xml'] = $doc;
|
||||||
|
}
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$name = $zip->getNameIndex($i);
|
||||||
|
if (! is_string($name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('#^word/(header\d*|footer\d*|footnotes|endnotes|comments)\.xml$#i', $name)) {
|
||||||
|
$xml = $zip->getFromName($name);
|
||||||
|
if ($xml !== false) {
|
||||||
|
$parts[$name] = $xml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize and scan
|
||||||
|
$all = [];
|
||||||
|
$perPart = [];
|
||||||
|
foreach ($parts as $name => $xml) {
|
||||||
|
$norm = $this->normalizeRunsForTokens($xml);
|
||||||
|
$found = $scanner->scan($norm);
|
||||||
|
$perPart[$name] = $found;
|
||||||
|
if ($found) {
|
||||||
|
$all = array_merge($all, $found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$union = array_values(array_unique($all));
|
||||||
|
|
||||||
|
$this->info("Template: {$template->name} (slug={$template->slug}, v{$template->version})");
|
||||||
|
$this->line('File: '.$path);
|
||||||
|
$this->line('Tokens found (union): '.count($union));
|
||||||
|
foreach ($union as $t) {
|
||||||
|
$this->line(' - '.$t);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('parts')) {
|
||||||
|
$this->line('');
|
||||||
|
$this->info('Per-part details:');
|
||||||
|
foreach ($perPart as $n => $list) {
|
||||||
|
$this->line("[{$n}] (".count($list).')');
|
||||||
|
foreach ($list as $t) {
|
||||||
|
$this->line(' - '.$t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
@unlink($tmp);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeRunsForTokens(string $xml): string
|
||||||
|
{
|
||||||
|
// Remove proofing error markers
|
||||||
|
$xml = preg_replace('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
|
||||||
|
// Collapse boundaries between runs and inside runs (include tabs/line breaks)
|
||||||
|
$patterns = [
|
||||||
|
'#</w:t>\s*</w:r>\s*(?:<(?:w:proofErr|w:tab|w:br)[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#is',
|
||||||
|
'#</w:t>\s*(?:<(?:w:proofErr|w:tab|w:br)[^>]*/>\s*)*<w:t[^>]*>#is',
|
||||||
|
];
|
||||||
|
$prev = null;
|
||||||
|
while ($prev !== $xml) {
|
||||||
|
$prev = $xml;
|
||||||
|
foreach ($patterns as $pat) {
|
||||||
|
$xml = preg_replace($pat, '', $xml) ?? $xml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clean inside {{ ... }}
|
||||||
|
$xml = preg_replace_callback('/\{\{.*?\}\}/s', function (array $m) {
|
||||||
|
$inner = substr($m[0], 2, -2);
|
||||||
|
$inner = preg_replace('/<[^>]+>/', '', $inner) ?? $inner;
|
||||||
|
$inner = preg_replace('/\s+/', '', $inner) ?? $inner;
|
||||||
|
|
||||||
|
return '{{'.$inner.'}}';
|
||||||
|
}, $xml) ?? $xml;
|
||||||
|
// Clean inside { ... } if it looks like a token
|
||||||
|
$xml = preg_replace_callback('/\{[^{}]*\}/s', function (array $m) {
|
||||||
|
$raw = $m[0];
|
||||||
|
$inner = substr($raw, 1, -1);
|
||||||
|
$clean = preg_replace('/<[^>]+>/', '', $inner) ?? $inner;
|
||||||
|
$clean = preg_replace('/\s+/', '', $clean) ?? $clean;
|
||||||
|
if (preg_match('/^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*\.[a-zA-Z0-9_.-]+$/', $clean)) {
|
||||||
|
return '{'.$clean.'}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $raw;
|
||||||
|
}, $xml) ?? $xml;
|
||||||
|
// Remove zero-width and soft hyphen
|
||||||
|
$xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml);
|
||||||
|
|
||||||
|
return $xml;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@ protected function schedule(Schedule $schedule): void
|
|||||||
// Optionally prune old previews daily
|
// Optionally prune old previews daily
|
||||||
if (config('files.enable_preview_prune', true)) {
|
if (config('files.enable_preview_prune', true)) {
|
||||||
$days = (int) config('files.preview_retention_days', 90);
|
$days = (int) config('files.preview_retention_days', 90);
|
||||||
if ($days < 1) { $days = 90; }
|
if ($days < 1) {
|
||||||
|
$days = 90;
|
||||||
|
}
|
||||||
$schedule->command('documents:prune-previews', [
|
$schedule->command('documents:prune-previews', [
|
||||||
'--days' => $days,
|
'--days' => $days,
|
||||||
])->dailyAt('02:00');
|
])->dailyAt('02:00');
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum PersonPhoneType: string
|
||||||
|
{
|
||||||
|
case Mobile = 'mobile';
|
||||||
|
case Landline = 'landline';
|
||||||
|
case Voip = 'voip';
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Activity;
|
||||||
|
|
||||||
|
class ActivityDecisionApplied
|
||||||
|
{
|
||||||
|
public function __construct(public Activity $activity) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Contract;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
class ChangeContractSegment
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Contract $contract,
|
||||||
|
public int $segmentId,
|
||||||
|
public bool $deactivatePrevious = true,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Events;
|
|
||||||
|
|
||||||
use App\Models\ClientCase;
|
|
||||||
use App\Models\Segment;
|
|
||||||
use Illuminate\Broadcasting\Channel;
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
|
||||||
use Illuminate\Broadcasting\PresenceChannel;
|
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class ClientCaseToTerrain implements ShouldBroadcast
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
|
||||||
|
|
||||||
public ClientCase $clientCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new event instance.
|
|
||||||
*/
|
|
||||||
public function __construct(ClientCase $clientCase)
|
|
||||||
{
|
|
||||||
$this->clientCase = $clientCase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the channels the event should broadcast on.
|
|
||||||
*
|
|
||||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
|
||||||
*/
|
|
||||||
public function broadcastOn(): PrivateChannel
|
|
||||||
{
|
|
||||||
return new PrivateChannel('segments'.$this->clientCase->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function broadcastAs(){
|
|
||||||
return 'client_case.terrain.add';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Events;
|
|
||||||
|
|
||||||
use App\Models\Contract;
|
|
||||||
use App\Models\Segment;
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class ContractToTerrain implements ShouldBroadcast
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
|
||||||
|
|
||||||
public Contract $contract;
|
|
||||||
public Segment $segment;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new event instance.
|
|
||||||
*/
|
|
||||||
public function __construct(Contract $contract, Segment $segment)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
$this->contract = $contract;
|
|
||||||
$this->segment = $segment;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the channels the event should broadcast on.
|
|
||||||
*
|
|
||||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
|
||||||
*/
|
|
||||||
public function broadcastOn(): PrivateChannel
|
|
||||||
{
|
|
||||||
return new PrivateChannel('contracts.'.$this->segment->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function broadcastAs(){
|
|
||||||
return 'contract.terrain.add';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Models\Contract;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||||
|
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithCustomValueBinder;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||||
|
|
||||||
|
class SegmentContractsExport extends DefaultValueBinder implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithCustomValueBinder, WithHeadings, WithMapping
|
||||||
|
{
|
||||||
|
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
|
||||||
|
|
||||||
|
public const TEXT_EXCEL_FORMAT = NumberFormat::FORMAT_TEXT;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private array $columnLetterMap = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{label: string}>
|
||||||
|
*/
|
||||||
|
public const COLUMN_METADATA = [
|
||||||
|
'reference' => ['label' => 'Pogodba'],
|
||||||
|
'client_case' => ['label' => 'Primer'],
|
||||||
|
'client' => ['label' => 'Stranka'],
|
||||||
|
'type' => ['label' => 'Vrsta'],
|
||||||
|
'start_date' => ['label' => 'Začetek'],
|
||||||
|
'end_date' => ['label' => 'Konec'],
|
||||||
|
'account' => ['label' => 'Stanje'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $columns
|
||||||
|
*/
|
||||||
|
public function __construct(private Builder $query, private array $columns) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function allowedColumns(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::COLUMN_METADATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function columnLabel(string $column): string
|
||||||
|
{
|
||||||
|
return self::COLUMN_METADATA[$column]['label'] ?? $column;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(): Builder
|
||||||
|
{
|
||||||
|
return $this->query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, mixed>
|
||||||
|
*/
|
||||||
|
public function map($row): array
|
||||||
|
{
|
||||||
|
return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return array_map(fn (string $column) => self::columnLabel($column), $this->columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function columnFormats(): array
|
||||||
|
{
|
||||||
|
$formats = [];
|
||||||
|
|
||||||
|
foreach ($this->getColumnLetterMap() as $letter => $column) {
|
||||||
|
if ($column === 'reference') {
|
||||||
|
$formats[$letter] = self::TEXT_EXCEL_FORMAT;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($column, ['start_date', 'end_date'], true)) {
|
||||||
|
$formats[$letter] = self::DATE_EXCEL_FORMAT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveValue(Contract $contract, string $column): mixed
|
||||||
|
{
|
||||||
|
return match ($column) {
|
||||||
|
'reference' => $contract->reference,
|
||||||
|
'client_case' => optional($contract->clientCase?->person)->full_name,
|
||||||
|
'client' => optional($contract->clientCase?->client?->person)->full_name,
|
||||||
|
'type' => optional($contract->type)->name,
|
||||||
|
'start_date' => $this->formatDate($contract->start_date),
|
||||||
|
'end_date' => $this->formatDate($contract->end_date),
|
||||||
|
'account' => optional($contract->account)->balance_amount,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatDate(mixed $value): ?float
|
||||||
|
{
|
||||||
|
$carbon = Carbon::make($value);
|
||||||
|
|
||||||
|
if (! $carbon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExcelDate::dateTimeToExcel($carbon->copy()->startOfDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function columnLetter(int $index): string
|
||||||
|
{
|
||||||
|
$index++;
|
||||||
|
$letter = '';
|
||||||
|
|
||||||
|
while ($index > 0) {
|
||||||
|
$remainder = ($index - 1) % 26;
|
||||||
|
$letter = chr(65 + $remainder).$letter;
|
||||||
|
$index = intdiv($index - 1, 26);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $letter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bindValue(Cell $cell, $value): bool
|
||||||
|
{
|
||||||
|
$columnKey = $this->getColumnLetterMap()[$cell->getColumn()] ?? null;
|
||||||
|
|
||||||
|
if ($columnKey === 'reference') {
|
||||||
|
$cell->setValueExplicit((string) $value, DataType::TYPE_STRING);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::bindValue($cell, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function getColumnLetterMap(): array
|
||||||
|
{
|
||||||
|
if ($this->columnLetterMap === []) {
|
||||||
|
foreach ($this->columns as $index => $column) {
|
||||||
|
$this->columnLetterMap[$this->columnLetter($index)] = $column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->columnLetterMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Account;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Inertia\Inertia;
|
|
||||||
|
|
||||||
class AccountController extends Controller
|
class AccountController extends Controller
|
||||||
{
|
{
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Activity;
|
||||||
|
use App\Models\ActivityNotificationRead;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ActivityNotificationController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle the incoming request.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'activity_id' => ['sometimes', 'integer', 'exists:activities,id'],
|
||||||
|
'activity_ids' => ['sometimes', 'array', 'min:1'],
|
||||||
|
'activity_ids.*' => ['integer', 'exists:activities,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$userId = optional($request->user())->id;
|
||||||
|
if (! $userId) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
if (!empty($data['activity_id'])) {
|
||||||
|
$ids[] = $data['activity_id'];
|
||||||
|
}
|
||||||
|
if (!empty($data['activity_ids'])) {
|
||||||
|
$ids = array_merge($ids, $data['activity_ids']);
|
||||||
|
}
|
||||||
|
$ids = array_unique($ids);
|
||||||
|
|
||||||
|
$activities = Activity::query()->select(['id', 'due_date'])->whereIn('id', $ids)->get();
|
||||||
|
foreach ($activities as $activity) {
|
||||||
|
$due = optional($activity->due_date) ? date('Y-m-d', strtotime($activity->due_date)) : now()->toDateString();
|
||||||
|
ActivityNotificationRead::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $userId,
|
||||||
|
'activity_id' => $activity->id,
|
||||||
|
'due_date' => $due,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'read_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ public function toggleActive(DocumentTemplate $template)
|
|||||||
public function show(DocumentTemplate $template)
|
public function show(DocumentTemplate $template)
|
||||||
{
|
{
|
||||||
$this->ensurePermission();
|
$this->ensurePermission();
|
||||||
|
|
||||||
return Inertia::render('Admin/DocumentTemplates/Show', [
|
return Inertia::render('Admin/DocumentTemplates/Show', [
|
||||||
'template' => $template,
|
'template' => $template,
|
||||||
]);
|
]);
|
||||||
@@ -121,12 +122,105 @@ public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentT
|
|||||||
if ($dirty) {
|
if ($dirty) {
|
||||||
$template->formatting_options = $fmt;
|
$template->formatting_options = $fmt;
|
||||||
}
|
}
|
||||||
|
// Merge meta, including custom_defaults
|
||||||
|
if ($request->has('meta') && is_array($request->input('meta'))) {
|
||||||
|
$meta = array_filter($request->input('meta'), fn ($v) => $v !== null && $v !== '');
|
||||||
|
$template->meta = array_replace($template->meta ?? [], $meta);
|
||||||
|
}
|
||||||
$template->updated_by = Auth::id();
|
$template->updated_by = Auth::id();
|
||||||
$template->save();
|
$template->save();
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Nastavitve predloge shranjene.');
|
return redirect()->back()->with('success', 'Nastavitve predloge shranjene.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function rescanTokens(DocumentTemplate $template)
|
||||||
|
{
|
||||||
|
$this->ensurePermission();
|
||||||
|
|
||||||
|
// Best-effort: read stored DOCX from disk and re-scan tokens
|
||||||
|
$tokens = [];
|
||||||
|
try {
|
||||||
|
/** @var TokenScanner $scanner */
|
||||||
|
$scanner = app(TokenScanner::class);
|
||||||
|
$zip = new \ZipArchive;
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
|
||||||
|
// Copy file from storage to a temp path
|
||||||
|
$disk = 'public';
|
||||||
|
$stream = \Storage::disk($disk)->get($template->file_path);
|
||||||
|
file_put_contents($tmp, $stream);
|
||||||
|
if ($zip->open($tmp) === true) {
|
||||||
|
// Collect main document and header/footer parts
|
||||||
|
$parts = [];
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$stat = $zip->statIndex($i);
|
||||||
|
$name = $stat['name'] ?? '';
|
||||||
|
if (preg_match('#^word\/(document|header\d+|footer\d+)\.xml$#i', $name)) {
|
||||||
|
$parts[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($parts)) {
|
||||||
|
$parts = ['word/document.xml'];
|
||||||
|
}
|
||||||
|
$found = [];
|
||||||
|
foreach ($parts as $name) {
|
||||||
|
$xml = $zip->getFromName($name);
|
||||||
|
if ($xml === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$norm = self::normalizeDocxXmlTokens($xml);
|
||||||
|
$det = $scanner->scan($norm);
|
||||||
|
if (! empty($det)) {
|
||||||
|
$found = array_merge($found, $det);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$tokens = array_values(array_unique($found));
|
||||||
|
$zip->close();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// swallow scanning errors, keep $tokens as empty
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\Schema::hasColumn('document_templates', 'tokens')) {
|
||||||
|
$template->tokens = $tokens;
|
||||||
|
}
|
||||||
|
// Auto-detect custom.* tokens and ensure meta.custom_default_types has defaults
|
||||||
|
try {
|
||||||
|
$meta = is_array($template->meta) ? $template->meta : [];
|
||||||
|
$types = isset($meta['custom_default_types']) && is_array($meta['custom_default_types']) ? $meta['custom_default_types'] : [];
|
||||||
|
$defaults = isset($meta['custom_defaults']) && is_array($meta['custom_defaults']) ? $meta['custom_defaults'] : [];
|
||||||
|
foreach (($tokens ?? []) as $tok) {
|
||||||
|
if (is_string($tok) && str_starts_with($tok, 'custom.')) {
|
||||||
|
$key = substr($tok, 7);
|
||||||
|
if ($key !== '') {
|
||||||
|
if (! array_key_exists($key, $types)) {
|
||||||
|
$types[$key] = 'string';
|
||||||
|
}
|
||||||
|
if (! array_key_exists($key, $defaults)) {
|
||||||
|
$defaults[$key] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! empty($types)) {
|
||||||
|
$meta['custom_default_types'] = $types;
|
||||||
|
}
|
||||||
|
if (! empty($defaults)) {
|
||||||
|
$meta['custom_defaults'] = $defaults;
|
||||||
|
}
|
||||||
|
if ($meta !== ($template->meta ?? [])) {
|
||||||
|
$template->meta = $meta;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore meta typing/defaults failures
|
||||||
|
}
|
||||||
|
$template->updated_by = Auth::id();
|
||||||
|
$template->save();
|
||||||
|
|
||||||
|
$count = is_array($tokens) ? count($tokens) : 0;
|
||||||
|
|
||||||
|
return back()->with('success', "Tokens posodobljeni ({$count} najdenih).");
|
||||||
|
}
|
||||||
|
|
||||||
public function store(StoreDocumentTemplateRequest $request)
|
public function store(StoreDocumentTemplateRequest $request)
|
||||||
{
|
{
|
||||||
$this->ensurePermission();
|
$this->ensurePermission();
|
||||||
@@ -146,7 +240,7 @@ public function store(StoreDocumentTemplateRequest $request)
|
|||||||
$hash = hash_file('sha256', $file->getRealPath());
|
$hash = hash_file('sha256', $file->getRealPath());
|
||||||
$path = $file->store("document-templates/{$slug}/v{$nextVersion}", 'public');
|
$path = $file->store("document-templates/{$slug}/v{$nextVersion}", 'public');
|
||||||
|
|
||||||
// Scan tokens from uploaded DOCX (best effort)
|
// Scan tokens from uploaded DOCX (best effort) – normalize XML to collapse Word run boundaries
|
||||||
$tokens = [];
|
$tokens = [];
|
||||||
try {
|
try {
|
||||||
/** @var TokenScanner $scanner */
|
/** @var TokenScanner $scanner */
|
||||||
@@ -155,10 +249,31 @@ public function store(StoreDocumentTemplateRequest $request)
|
|||||||
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
|
$tmp = tempnam(sys_get_temp_dir(), 'tmpl');
|
||||||
copy($file->getRealPath(), $tmp);
|
copy($file->getRealPath(), $tmp);
|
||||||
if ($zip->open($tmp) === true) {
|
if ($zip->open($tmp) === true) {
|
||||||
$xml = $zip->getFromName('word/document.xml');
|
// Collect main document and header/footer parts
|
||||||
if ($xml !== false) {
|
$parts = [];
|
||||||
$tokens = $scanner->scan($xml);
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$stat = $zip->statIndex($i);
|
||||||
|
$name = $stat['name'] ?? '';
|
||||||
|
if (preg_match('#^word\/(document|header\d+|footer\d+)\.xml$#i', $name)) {
|
||||||
|
$parts[] = $name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (empty($parts)) {
|
||||||
|
$parts = ['word/document.xml'];
|
||||||
|
}
|
||||||
|
$found = [];
|
||||||
|
foreach ($parts as $name) {
|
||||||
|
$xml = $zip->getFromName($name);
|
||||||
|
if ($xml === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$norm = self::normalizeDocxXmlTokens($xml);
|
||||||
|
$det = $scanner->scan($norm);
|
||||||
|
if (! empty($det)) {
|
||||||
|
$found = array_merge($found, $det);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$tokens = array_values(array_unique($found));
|
||||||
$zip->close();
|
$zip->close();
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -218,6 +333,36 @@ public function store(StoreDocumentTemplateRequest $request)
|
|||||||
if (Schema::hasColumn('document_templates', 'tokens')) {
|
if (Schema::hasColumn('document_templates', 'tokens')) {
|
||||||
$payload['tokens'] = $tokens;
|
$payload['tokens'] = $tokens;
|
||||||
}
|
}
|
||||||
|
// Auto-add default string types for any detected custom.* tokens
|
||||||
|
try {
|
||||||
|
$meta = isset($payload['meta']) && is_array($payload['meta']) ? $payload['meta'] : [];
|
||||||
|
$types = isset($meta['custom_default_types']) && is_array($meta['custom_default_types']) ? $meta['custom_default_types'] : [];
|
||||||
|
$defaults = isset($meta['custom_defaults']) && is_array($meta['custom_defaults']) ? $meta['custom_defaults'] : [];
|
||||||
|
foreach (($tokens ?? []) as $tok) {
|
||||||
|
if (is_string($tok) && str_starts_with($tok, 'custom.')) {
|
||||||
|
$key = substr($tok, 7);
|
||||||
|
if ($key !== '') {
|
||||||
|
if (! array_key_exists($key, $types)) {
|
||||||
|
$types[$key] = 'string';
|
||||||
|
}
|
||||||
|
if (! array_key_exists($key, $defaults)) {
|
||||||
|
$defaults[$key] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! empty($types)) {
|
||||||
|
$meta['custom_default_types'] = $types;
|
||||||
|
}
|
||||||
|
if (! empty($defaults)) {
|
||||||
|
$meta['custom_defaults'] = $defaults;
|
||||||
|
}
|
||||||
|
if ($meta !== ($payload['meta'] ?? [])) {
|
||||||
|
$payload['meta'] = $meta;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore meta typing/defaults failures
|
||||||
|
}
|
||||||
$template = DocumentTemplate::create($payload);
|
$template = DocumentTemplate::create($payload);
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Predloga uspešno shranjena. (v'.$template->version.')')->with('template_id', $template->id);
|
return redirect()->back()->with('success', 'Predloga uspešno shranjena. (v'.$template->version.')')->with('template_id', $template->id);
|
||||||
@@ -229,4 +374,29 @@ private function ensurePermission(): void
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse common Word run boundaries and proofing spans so tokens like {{client.person.full_name}}
|
||||||
|
* appear contiguous in XML for scanning.
|
||||||
|
*/
|
||||||
|
private static function normalizeDocxXmlTokens(string $xml): string
|
||||||
|
{
|
||||||
|
// Remove proofing error markers
|
||||||
|
$xml = preg_replace('#<w:proofErr[^>]*/>#i', '', $xml) ?? $xml;
|
||||||
|
// Iteratively collapse boundaries between text runs, even if w:rPr is present
|
||||||
|
$patterns = [
|
||||||
|
'#</w:t>\s*</w:r>\s*(?:<w:proofErr[^>]*/>\s*)*(?:<w:r[^>]*>\s*(?:<w:rPr>.*?</w:rPr>\s*)*)?<w:t[^>]*>#is',
|
||||||
|
];
|
||||||
|
$prev = null;
|
||||||
|
while ($prev !== $xml) {
|
||||||
|
$prev = $xml;
|
||||||
|
foreach ($patterns as $pat) {
|
||||||
|
$xml = preg_replace($pat, '', $xml) ?? $xml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove zero-width and soft hyphen characters
|
||||||
|
$xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml);
|
||||||
|
|
||||||
|
return $xml;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\EmailLog;
|
||||||
|
use App\Models\EmailTemplate;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class EmailLogController extends Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', EmailTemplate::class); // reuse same permission gate for admin area
|
||||||
|
|
||||||
|
$query = EmailLog::query()
|
||||||
|
->with(['template:id,name'])
|
||||||
|
->orderByDesc('created_at');
|
||||||
|
|
||||||
|
$status = trim((string) $request->input('status', ''));
|
||||||
|
if ($status !== '') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
if ($email = trim((string) $request->input('to'))) {
|
||||||
|
$query->where('to_email', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], $email).'%');
|
||||||
|
}
|
||||||
|
if ($subject = trim((string) $request->input('subject'))) {
|
||||||
|
$query->where('subject', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], $subject).'%');
|
||||||
|
}
|
||||||
|
if ($templateId = (int) $request->input('template_id')) {
|
||||||
|
$query->where('template_id', $templateId);
|
||||||
|
}
|
||||||
|
if ($from = $request->date('date_from')) {
|
||||||
|
$query->whereDate('created_at', '>=', $from);
|
||||||
|
}
|
||||||
|
if ($to = $request->date('date_to')) {
|
||||||
|
$query->whereDate('created_at', '<=', $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logs = $query->paginate(20)->withQueryString();
|
||||||
|
$templates = EmailTemplate::query()->orderBy('name')->get(['id', 'name']);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/EmailLogs/Index', [
|
||||||
|
'logs' => $logs,
|
||||||
|
'filters' => [
|
||||||
|
'status' => $status,
|
||||||
|
'to' => $email ?? '',
|
||||||
|
'subject' => $subject ?? '',
|
||||||
|
'template_id' => $templateId ?: null,
|
||||||
|
'date_from' => $request->input('date_from'),
|
||||||
|
'date_to' => $request->input('date_to'),
|
||||||
|
],
|
||||||
|
'templates' => $templates,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(EmailLog $emailLog): Response
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', EmailTemplate::class);
|
||||||
|
|
||||||
|
$emailLog->load(['template:id,name', 'body']);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/EmailLogs/Show', [
|
||||||
|
'log' => $emailLog,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,908 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreEmailTemplateRequest;
|
||||||
|
use App\Jobs\SendEmailTemplateJob;
|
||||||
|
use App\Models\Activity;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\ClientCase;
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\EmailLog;
|
||||||
|
use App\Models\EmailLogStatus;
|
||||||
|
use App\Models\EmailTemplate;
|
||||||
|
use App\Services\EmailTemplateRenderer;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
|
||||||
|
|
||||||
|
class EmailTemplateController extends Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public function update(\App\Http\Requests\UpdateEmailTemplateRequest $request, EmailTemplate $emailTemplate)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $emailTemplate);
|
||||||
|
$data = $request->validated();
|
||||||
|
$emailTemplate->fill($data)->save();
|
||||||
|
// Move any tmp images referenced in HTML into permanent storage and attach as documents
|
||||||
|
$this->adoptTmpImages($emailTemplate);
|
||||||
|
|
||||||
|
return redirect()->route('admin.email-templates.edit', $emailTemplate)->with('success', 'Template updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', EmailTemplate::class);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/EmailTemplates/Index', [
|
||||||
|
'templates' => EmailTemplate::orderBy('name')->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
$this->authorize('create', EmailTemplate::class);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/EmailTemplates/Edit', [
|
||||||
|
'template' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreEmailTemplateRequest $request)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$tpl = EmailTemplate::create($data);
|
||||||
|
// Move any tmp images referenced in HTML into permanent storage and attach as documents
|
||||||
|
$this->adoptTmpImages($tpl);
|
||||||
|
|
||||||
|
return redirect()->route('admin.email-templates.edit', $tpl)->with('success', 'Template created');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a quick preview of the email template with the provided context.
|
||||||
|
* Does not persist any changes or inline CSS; intended for fast editor feedback.
|
||||||
|
*/
|
||||||
|
public function preview(Request $request, EmailTemplate $emailTemplate): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('view', $emailTemplate);
|
||||||
|
|
||||||
|
$renderer = app(EmailTemplateRenderer::class);
|
||||||
|
$subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template);
|
||||||
|
$html = (string) ($request->input('html') ?? $emailTemplate->html_template);
|
||||||
|
$text = (string) ($request->input('text') ?? $emailTemplate->text_template);
|
||||||
|
|
||||||
|
// Do not persist tmp images for preview, but allow showing them if already accessible
|
||||||
|
// Optionally repair missing img src and attach from template documents for a better preview
|
||||||
|
if (! empty($html)) {
|
||||||
|
$html = $this->repairImgWithoutSrc($html);
|
||||||
|
$html = $this->attachSrcFromTemplateDocuments($emailTemplate, $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
if ($activity) {
|
||||||
|
$ctx['activity'] = $activity;
|
||||||
|
// Derive base entities from activity when not explicitly provided
|
||||||
|
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||||
|
$ctx['contract'] = $activity->contract;
|
||||||
|
}
|
||||||
|
if ($activity->clientCase && ! isset($ctx['client_case'])) {
|
||||||
|
$ctx['client_case'] = $activity->clientCase;
|
||||||
|
}
|
||||||
|
if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) {
|
||||||
|
$ctx['client'] = $ctx['client_case']->client;
|
||||||
|
$ctx['person'] = optional($ctx['client'])->person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($id = $request->integer('contract_id')) {
|
||||||
|
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||||
|
if ($contract) {
|
||||||
|
$ctx['contract'] = $contract;
|
||||||
|
if ($contract->clientCase) {
|
||||||
|
$ctx['client_case'] = $contract->clientCase;
|
||||||
|
if ($contract->clientCase->client) {
|
||||||
|
$ctx['client'] = $contract->clientCase->client;
|
||||||
|
$ctx['person'] = optional($contract->clientCase->client)->person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) {
|
||||||
|
$case = ClientCase::query()->with(['client.person'])->find($id);
|
||||||
|
if ($case) {
|
||||||
|
$ctx['client_case'] = $case;
|
||||||
|
if ($case->client) {
|
||||||
|
$ctx['client'] = $case->client;
|
||||||
|
$ctx['person'] = optional($case->client)->person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) {
|
||||||
|
$client = Client::query()->with(['person'])->find($id);
|
||||||
|
if ($client) {
|
||||||
|
$ctx['client'] = $client;
|
||||||
|
$ctx['person'] = optional($client)->person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ctx['extra'] = (array) $request->input('extra', []);
|
||||||
|
|
||||||
|
$rendered = $renderer->render([
|
||||||
|
'subject' => $subject,
|
||||||
|
'html' => $html,
|
||||||
|
'text' => $text,
|
||||||
|
], $ctx);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'subject' => $rendered['subject'] ?? $subject,
|
||||||
|
'html' => (string) ($rendered['html'] ?? $html ?? ''),
|
||||||
|
'text' => (string) ($rendered['text'] ?? $text ?? ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(EmailTemplate $emailTemplate): Response
|
||||||
|
{
|
||||||
|
$this->authorize('update', $emailTemplate);
|
||||||
|
$emailTemplate->load(['documents' => function ($q) {
|
||||||
|
$q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']);
|
||||||
|
}]);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/EmailTemplates/Edit', [
|
||||||
|
'template' => $emailTemplate,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendTest(Request $request, EmailTemplate $emailTemplate)
|
||||||
|
{
|
||||||
|
$this->authorize('send', $emailTemplate);
|
||||||
|
|
||||||
|
$renderer = app(EmailTemplateRenderer::class);
|
||||||
|
$subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template);
|
||||||
|
$html = (string) ($request->input('html') ?? $emailTemplate->html_template);
|
||||||
|
$text = (string) ($request->input('text') ?? $emailTemplate->text_template);
|
||||||
|
|
||||||
|
// Adopt tmp images (tmp/email-images) so test email can display images; also persist
|
||||||
|
$html = $this->adoptTmpImagesInHtml($emailTemplate, $html, true);
|
||||||
|
|
||||||
|
// Context resolution
|
||||||
|
$ctx = [];
|
||||||
|
if ($id = $request->integer('activity_id')) {
|
||||||
|
$activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
|
||||||
|
if ($activity) {
|
||||||
|
$ctx['activity'] = $activity;
|
||||||
|
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||||
|
$ctx['contract'] = $activity->contract;
|
||||||
|
}
|
||||||
|
if ($activity->clientCase && ! isset($ctx['client_case'])) {
|
||||||
|
$ctx['client_case'] = $activity->clientCase;
|
||||||
|
}
|
||||||
|
if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) {
|
||||||
|
$ctx['client'] = $ctx['client_case']->client;
|
||||||
|
$ctx['person'] = optional($ctx['client'])->person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($id = $request->integer('contract_id')) {
|
||||||
|
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||||
|
if ($contract) {
|
||||||
|
$ctx['contract'] = $contract;
|
||||||
|
if ($contract->clientCase) {
|
||||||
|
$ctx['client_case'] = $contract->clientCase;
|
||||||
|
if ($contract->clientCase->client) {
|
||||||
|
$ctx['client'] = $contract->clientCase->client;
|
||||||
|
$ctx['person'] = optional($contract->clientCase->client)->person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) {
|
||||||
|
$case = ClientCase::query()->with(['client.person'])->find($id);
|
||||||
|
if ($case) {
|
||||||
|
$ctx['client_case'] = $case;
|
||||||
|
if ($case->client) {
|
||||||
|
$ctx['client'] = $case->client;
|
||||||
|
$ctx['person'] = optional($case->client)->person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) {
|
||||||
|
$client = Client::query()->with(['person'])->find($id);
|
||||||
|
if ($client) {
|
||||||
|
$ctx['client'] = $client;
|
||||||
|
$ctx['person'] = optional($client)->person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ctx['extra'] = (array) $request->input('extra', []);
|
||||||
|
|
||||||
|
// Render preview values; we store a minimal snapshot on the log
|
||||||
|
$rendered = $renderer->render([
|
||||||
|
'subject' => $subject,
|
||||||
|
'html' => $html,
|
||||||
|
'text' => $text,
|
||||||
|
], $ctx);
|
||||||
|
|
||||||
|
$to = (string) $request->input('to');
|
||||||
|
if ($to === '' || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return back()->with('error', 'Invalid target email');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare EmailLog record with queued status
|
||||||
|
$log = new EmailLog;
|
||||||
|
$log->fill([
|
||||||
|
'uuid' => (string) \Str::uuid(),
|
||||||
|
'template_id' => $emailTemplate->id,
|
||||||
|
'to_email' => $to,
|
||||||
|
'to_name' => null,
|
||||||
|
'subject' => (string) ($rendered['subject'] ?? $subject ?? ''),
|
||||||
|
'body_html_hash' => $rendered['html'] ? hash('sha256', $rendered['html']) : null,
|
||||||
|
'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null,
|
||||||
|
'embed_mode' => (string) $request->input('embed', 'base64'),
|
||||||
|
'status' => EmailLogStatus::Queued,
|
||||||
|
'queued_at' => now(),
|
||||||
|
'client_id' => $ctx['client']->id ?? null,
|
||||||
|
'client_case_id' => $ctx['client_case']->id ?? null,
|
||||||
|
'contract_id' => $ctx['contract']->id ?? null,
|
||||||
|
'extra_context' => $ctx['extra'] ?? null,
|
||||||
|
'ip' => $request->ip(),
|
||||||
|
]);
|
||||||
|
$log->save();
|
||||||
|
|
||||||
|
// Store bodies in companion table (optional, enabled here)
|
||||||
|
$log->body()->create([
|
||||||
|
'body_html' => (string) ($rendered['html'] ?? ''),
|
||||||
|
'body_text' => (string) ($rendered['text'] ?? ''),
|
||||||
|
'inline_css' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Dispatch the queued job
|
||||||
|
dispatch(new SendEmailTemplateJob($log->id));
|
||||||
|
|
||||||
|
return back()->with('success', 'Test email queued for '.$to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the final HTML exactly as it will be sent (repair <img>, attach from docs,
|
||||||
|
* inline images to base64, inline CSS). Does not persist any changes or send email.
|
||||||
|
*/
|
||||||
|
public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
|
||||||
|
{
|
||||||
|
$this->authorize('view', $emailTemplate);
|
||||||
|
|
||||||
|
$renderer = app(EmailTemplateRenderer::class);
|
||||||
|
$subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template);
|
||||||
|
$html = (string) ($request->input('html') ?? $emailTemplate->html_template);
|
||||||
|
$text = (string) ($request->input('text') ?? $emailTemplate->text_template);
|
||||||
|
|
||||||
|
// Do not persist tmp images, but allow previewing with them present
|
||||||
|
$html = $this->adoptTmpImagesInHtml($emailTemplate, $html, false);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
if ($activity) {
|
||||||
|
$ctx['activity'] = $activity;
|
||||||
|
if ($activity->contract && ! isset($ctx['contract'])) {
|
||||||
|
$ctx['contract'] = $activity->contract;
|
||||||
|
}
|
||||||
|
if ($activity->clientCase && ! isset($ctx['client_case'])) {
|
||||||
|
$ctx['client_case'] = $activity->clientCase;
|
||||||
|
}
|
||||||
|
if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) {
|
||||||
|
$ctx['client'] = $ctx['client_case']->client;
|
||||||
|
$ctx['person'] = optional($ctx['client'])->person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($id = $request->integer('contract_id')) {
|
||||||
|
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
|
||||||
|
if ($contract) {
|
||||||
|
$ctx['contract'] = $contract;
|
||||||
|
if ($contract->clientCase) {
|
||||||
|
$ctx['client_case'] = $contract->clientCase;
|
||||||
|
if ($contract->clientCase->client) {
|
||||||
|
$ctx['client'] = $contract->clientCase->client;
|
||||||
|
$ctx['person'] = optional($contract->clientCase->client)->person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) {
|
||||||
|
$case = ClientCase::query()->with(['client.person'])->find($id);
|
||||||
|
if ($case) {
|
||||||
|
$ctx['client_case'] = $case;
|
||||||
|
if ($case->client) {
|
||||||
|
$ctx['client'] = $case->client;
|
||||||
|
$ctx['person'] = optional($case->client)->person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) {
|
||||||
|
$client = Client::query()->with(['person'])->find($id);
|
||||||
|
if ($client) {
|
||||||
|
$ctx['client'] = $client;
|
||||||
|
$ctx['person'] = optional($client)->person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ctx['extra'] = (array) $request->input('extra', []);
|
||||||
|
|
||||||
|
$rendered = $renderer->render([
|
||||||
|
'subject' => $subject,
|
||||||
|
'html' => $html,
|
||||||
|
'text' => $text,
|
||||||
|
], $ctx);
|
||||||
|
|
||||||
|
$attachments = [];
|
||||||
|
if (! empty($rendered['html'])) {
|
||||||
|
$rendered['html'] = $this->repairImgWithoutSrc($rendered['html']);
|
||||||
|
$rendered['html'] = $this->attachSrcFromTemplateDocuments($emailTemplate, $rendered['html']);
|
||||||
|
$embed = (string) $request->input('embed', 'base64');
|
||||||
|
if ($embed === 'base64') {
|
||||||
|
try {
|
||||||
|
$imageInliner = app(\App\Services\EmailImageInliner::class);
|
||||||
|
$rendered['html'] = $imageInliner->inline($rendered['html']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$rendered['html'] = $this->absolutizeStorageUrls($request, $rendered['html']);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$inliner = new CssToInlineStyles;
|
||||||
|
$rendered['html'] = $inliner->convert($rendered['html']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'subject' => $rendered['subject'] ?? $subject,
|
||||||
|
'html' => $rendered['html'] ?? '',
|
||||||
|
'text' => $rendered['text'] ?? ($text ?? ''),
|
||||||
|
'attachments' => $attachments,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert any <img src="/storage/..."> (or absolute URLs whose path is /storage/...) to
|
||||||
|
* absolute URLs using the current request scheme+host, so email clients like Gmail can fetch
|
||||||
|
* them through their proxy reliably.
|
||||||
|
*/
|
||||||
|
protected function absolutizeStorageUrls(Request $request, string $html): string
|
||||||
|
{
|
||||||
|
if ($html === '' || stripos($html, '<img') === false) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
$base = (string) (config('app.asset_url') ?: config('app.url'));
|
||||||
|
$host = $base !== '' ? rtrim($base, '/') : $request->getSchemeAndHttpHost();
|
||||||
|
|
||||||
|
return preg_replace_callback('#<img([^>]+)src=["\']([^"\']+)["\']([^>]*)>#i', function (array $m) use ($host) {
|
||||||
|
$before = $m[1] ?? '';
|
||||||
|
$src = $m[2] ?? '';
|
||||||
|
$after = $m[3] ?? '';
|
||||||
|
$path = $src;
|
||||||
|
if (preg_match('#^https?://#i', $src)) {
|
||||||
|
$parts = parse_url($src);
|
||||||
|
$path = $parts['path'] ?? '';
|
||||||
|
if (! preg_match('#^/?storage/#i', (string) $path)) {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (! preg_match('#^/?storage/#i', (string) $path)) {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rel = '/'.ltrim(preg_replace('#^/?storage/#i', 'storage/', (string) $path), '/');
|
||||||
|
$abs = rtrim($host, '/').$rel;
|
||||||
|
|
||||||
|
return '<img'.$before.'src="'.$abs.'"'.$after.'>';
|
||||||
|
}, $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix patterns where an <img ...> tag lacks a src attribute but is immediately followed by a URL.
|
||||||
|
* Example to fix:
|
||||||
|
* <img alt="Logo">\nhttps://domain.tld/storage/email-images/foo.png
|
||||||
|
* becomes:
|
||||||
|
* <img alt="Logo" src="https://domain.tld/storage/email-images/foo.png">
|
||||||
|
* The trailing URL text is removed.
|
||||||
|
*/
|
||||||
|
protected function repairImgWithoutSrc(string $html): string
|
||||||
|
{
|
||||||
|
if ($html === '' || stripos($html, '<img') === false) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to set src on an <img> when not present and keep the in-between content
|
||||||
|
$setSrc = function (array $m): string {
|
||||||
|
$attrs = $m[1] ?? '';
|
||||||
|
$between = $m[2] ?? '';
|
||||||
|
$url = $m[3] ?? '';
|
||||||
|
if (preg_match('#\bsrc\s*=#i', $attrs)) {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<img'.$attrs.' src="'.$url.'">'.$between;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Up to 700 chars of any content (non-greedy) between tag and URL
|
||||||
|
$gap = '(.{0,700}?)';
|
||||||
|
$urlAbs = '(https?://[^\s<>"\']+/storage/[^\s<>"\']+)';
|
||||||
|
$urlRel = '(/storage/[^\s<>"\']+)';
|
||||||
|
|
||||||
|
// Case 1: Plain text URL after <img>
|
||||||
|
$html = preg_replace_callback('#<img([^>]*)>'.$gap.$urlAbs.'#is', $setSrc, $html);
|
||||||
|
$html = preg_replace_callback('#<img([^>]*)>'.$gap.$urlRel.'#is', $setSrc, $html);
|
||||||
|
|
||||||
|
// Case 2: Linked URL after <img> (keep the anchor text, consume the URL into src)
|
||||||
|
$setSrcAnchor = function (array $m): string {
|
||||||
|
$attrs = $m[1] ?? '';
|
||||||
|
$between = $m[2] ?? '';
|
||||||
|
$url = $m[3] ?? '';
|
||||||
|
$anchor = $m[4] ?? '';
|
||||||
|
if (preg_match('#\bsrc\s*=#i', $attrs)) {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the anchor but its href stays as-is; we only set img src
|
||||||
|
return '<img'.$attrs.' src="'.$url.'">'.$between.$anchor;
|
||||||
|
};
|
||||||
|
$html = preg_replace_callback('#<img([^>]*)>'.$gap.$urlAbs.'(\s*<a[^>]+href=["\'][^"\']+["\'][^>]*>.*?</a>)#is', $setSrcAnchor, $html);
|
||||||
|
$html = preg_replace_callback('#<img([^>]*)>'.$gap.$urlRel.'(\s*<a[^>]+href=["\'][^"\']+["\'][^>]*>.*?</a>)#is', $setSrcAnchor, $html);
|
||||||
|
|
||||||
|
// Fallback: if a single image is missing src and there is a single /storage URL anywhere, attach it
|
||||||
|
if (preg_match_all('#<img(?![^>]*\bsrc\s*=)[^>]*>#i', $html, $missingImgs) === 1) {
|
||||||
|
if (count($missingImgs[0]) === 1) {
|
||||||
|
if (preg_match_all('#(?:https?://[^\s<>"\']+)?/storage/[^\s<>"\']+#i', $html, $urls) === 1 && count($urls[0]) === 1) {
|
||||||
|
$onlyUrl = $urls[0][0];
|
||||||
|
$html = preg_replace('#<img((?![^>]*\bsrc\s*=)[^>]*)>#i', '<img$1 src="'.$onlyUrl.'">', $html, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As a conservative fallback, populate missing <img> src attributes using this template's
|
||||||
|
* attached image Documents. We try to match by the alt attribute first (e.g., alt="Logo"
|
||||||
|
* will match a document named "logo.*"); if there is only one image document, we will use it.
|
||||||
|
*/
|
||||||
|
protected function attachSrcFromTemplateDocuments(EmailTemplate $tpl, string $html): string
|
||||||
|
{
|
||||||
|
if ($html === '' || stripos($html, '<img') === false) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect candidate image docs from relation if loaded, otherwise query
|
||||||
|
$docs = $tpl->getRelationValue('documents');
|
||||||
|
if ($docs === null) {
|
||||||
|
$docs = $tpl->documents()->get(['id', 'name', 'path', 'file_name', 'original_name', 'mime_type']);
|
||||||
|
}
|
||||||
|
$imageDocs = collect($docs ?: [])->filter(function ($d) {
|
||||||
|
$mime = strtolower((string) ($d->mime_type ?? ''));
|
||||||
|
|
||||||
|
return $mime === '' || str_starts_with($mime, 'image/');
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
if ($imageDocs->isEmpty()) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build lookups by basename without extension
|
||||||
|
$byStem = [];
|
||||||
|
foreach ($imageDocs as $d) {
|
||||||
|
$base = pathinfo($d->file_name ?: ($d->name ?: ($d->original_name ?: basename((string) $d->path))), PATHINFO_FILENAME);
|
||||||
|
if ($base) {
|
||||||
|
$byStem[strtolower($base)] = $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$callback = function (array $m) use (&$byStem, $imageDocs) {
|
||||||
|
$attrs = $m[1] ?? '';
|
||||||
|
if (preg_match('#\bsrc\s*=#i', $attrs)) {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$alt = null;
|
||||||
|
if (preg_match('#\balt\s*=\s*(?:"([^"]*)"|\'([^\']*)\')#i', $attrs, $am)) {
|
||||||
|
$alt = trim(html_entity_decode($am[1] !== '' ? $am[1] : ($am[2] ?? ''), ENT_QUOTES | ENT_HTML5));
|
||||||
|
}
|
||||||
|
|
||||||
|
$chosen = null;
|
||||||
|
if ($alt) {
|
||||||
|
$key = strtolower(preg_replace('#[^a-z0-9]+#i', '', $alt));
|
||||||
|
// try exact stem
|
||||||
|
if (isset($byStem[strtolower($alt)])) {
|
||||||
|
$chosen = $byStem[strtolower($alt)];
|
||||||
|
}
|
||||||
|
if (! $chosen) {
|
||||||
|
// try relaxed: any stem containing the alt
|
||||||
|
foreach ($byStem as $stem => $doc) {
|
||||||
|
$relaxedStem = preg_replace('#[^a-z0-9]+#i', '', (string) $stem);
|
||||||
|
if ($relaxedStem !== '' && str_contains($relaxedStem, $key)) {
|
||||||
|
$chosen = $doc;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $chosen && method_exists($imageDocs, 'count') && $imageDocs->count() === 1) {
|
||||||
|
$chosen = $imageDocs->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $chosen) {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = '/storage/'.ltrim((string) $chosen->path, '/');
|
||||||
|
|
||||||
|
return '<img'.$attrs.' src="'.$url.'">';
|
||||||
|
};
|
||||||
|
|
||||||
|
$html = preg_replace_callback('#<img([^>]*)>#i', $callback, $html);
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload an image for use in email templates. Stores to a temporary folder first and returns a public URL.
|
||||||
|
*/
|
||||||
|
public function uploadImage(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('create', EmailTemplate::class);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'file' => ['required', 'image', 'max:5120'], // 5MB
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var \Illuminate\Http\UploadedFile $file */
|
||||||
|
$file = $validated['file'];
|
||||||
|
// store into tmp first; move on save
|
||||||
|
$path = $file->store('tmp/email-images', 'public');
|
||||||
|
// Return a relative URL to avoid mismatched host/ports in dev
|
||||||
|
$url = '/storage/'.$path;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'url' => $url,
|
||||||
|
'path' => $path,
|
||||||
|
'tmp' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace an image referenced by the template, updating the existing Document row if found
|
||||||
|
* (and deleting the old file), or creating a new Document if none exists. Returns the new URL.
|
||||||
|
*/
|
||||||
|
public function replaceImage(Request $request, EmailTemplate $emailTemplate)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $emailTemplate);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'file' => ['required', 'image', 'max:5120'],
|
||||||
|
'current_src' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var \Illuminate\Http\UploadedFile $file */
|
||||||
|
$file = $validated['file'];
|
||||||
|
$currentSrc = (string) ($validated['current_src'] ?? '');
|
||||||
|
|
||||||
|
// Normalize current src to a public disk path when possible
|
||||||
|
$currentPath = null;
|
||||||
|
if ($currentSrc !== '') {
|
||||||
|
$parsed = parse_url($currentSrc);
|
||||||
|
$path = $parsed['path'] ?? $currentSrc;
|
||||||
|
// Accept /storage/... or raw path; strip leading storage/
|
||||||
|
if (preg_match('#/storage/(.+)#i', $path, $m)) {
|
||||||
|
$path = $m[1];
|
||||||
|
}
|
||||||
|
$path = ltrim(preg_replace('#^storage/#', '', $path), '/');
|
||||||
|
if ($path !== '') {
|
||||||
|
$currentPath = $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing document for this template matching the path
|
||||||
|
$doc = null;
|
||||||
|
if ($currentPath) {
|
||||||
|
$doc = $emailTemplate->documents()->where('path', $currentPath)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the new file
|
||||||
|
$ext = $file->getClientOriginalExtension();
|
||||||
|
$nameBase = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) ?: 'image';
|
||||||
|
$dest = 'email-images/'.$nameBase.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
|
||||||
|
Storage::disk('public')->put($dest, File::get($file->getRealPath()));
|
||||||
|
|
||||||
|
// Delete old file if we will update an existing document
|
||||||
|
if ($doc && $doc->path && Storage::disk('public')->exists($doc->path)) {
|
||||||
|
try {
|
||||||
|
Storage::disk('public')->delete($doc->path);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$full = storage_path('app/public/'.$dest);
|
||||||
|
try {
|
||||||
|
$mime = File::exists($full) ? File::mimeType($full) : null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$mime = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$size = Storage::disk('public')->size($dest);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$size = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($doc) {
|
||||||
|
$doc->forceFill([
|
||||||
|
'name' => basename($dest),
|
||||||
|
'path' => $dest,
|
||||||
|
'file_name' => basename($dest),
|
||||||
|
'original_name' => $file->getClientOriginalName(),
|
||||||
|
'extension' => $ext ?: null,
|
||||||
|
'mime_type' => $mime,
|
||||||
|
'size' => $size,
|
||||||
|
])->save();
|
||||||
|
} else {
|
||||||
|
$doc = $emailTemplate->documents()->create([
|
||||||
|
'name' => basename($dest),
|
||||||
|
'description' => null,
|
||||||
|
'user_id' => optional(auth()->user())->id,
|
||||||
|
'disk' => 'public',
|
||||||
|
'path' => $dest,
|
||||||
|
'file_name' => basename($dest),
|
||||||
|
'original_name' => $file->getClientOriginalName(),
|
||||||
|
'extension' => $ext ?: null,
|
||||||
|
'mime_type' => $mime,
|
||||||
|
'size' => $size,
|
||||||
|
'is_public' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'url' => '/storage/'.$dest,
|
||||||
|
'path' => $dest,
|
||||||
|
'document_id' => $doc->id,
|
||||||
|
'replaced' => (bool) $currentPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an attached image Document from the given email template.
|
||||||
|
*/
|
||||||
|
public function deleteImage(Request $request, EmailTemplate $emailTemplate, Document $document)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $emailTemplate);
|
||||||
|
|
||||||
|
// Ensure the document belongs to this template (polymorphic relation)
|
||||||
|
if ((int) $document->documentable_id !== (int) $emailTemplate->id || $document->documentable_type !== EmailTemplate::class) {
|
||||||
|
return response()->json(['message' => 'Document does not belong to this template.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Force delete to remove underlying file as well (Document model handles file deletion on force delete)
|
||||||
|
$document->forceDelete();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['message' => 'Failed to delete image: '.$e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan HTML for images stored in /storage/tmp/email-images and move them into a permanent
|
||||||
|
* location under /storage/email-images, create Document records and update the HTML.
|
||||||
|
*/
|
||||||
|
protected function adoptTmpImages(EmailTemplate $tpl): void
|
||||||
|
{
|
||||||
|
$html = (string) ($tpl->html_template ?? '');
|
||||||
|
if ($html === '' || stripos($html, 'tmp/email-images/') === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match any tmp paths inside src attributes, accepting absolute or relative URLs
|
||||||
|
$paths = [];
|
||||||
|
$matches = [];
|
||||||
|
if (preg_match_all('#/storage/tmp/email-images/[^"\']+#i', $html, $matches)) {
|
||||||
|
$paths = array_merge($paths, $matches[0]);
|
||||||
|
}
|
||||||
|
if (preg_match_all('#tmp/email-images/[^"\']+#i', $html, $matches)) {
|
||||||
|
$paths = array_merge($paths, $matches[0]);
|
||||||
|
}
|
||||||
|
$paths = array_values(array_unique($paths));
|
||||||
|
if (empty($paths)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($paths as $tmpRel) {
|
||||||
|
// Normalize path (strip any leading storage/)
|
||||||
|
// Normalize to disk-relative path
|
||||||
|
$tmpRel = ltrim(preg_replace('#^/?storage/#i', '', $tmpRel), '/');
|
||||||
|
if (! Storage::disk('public')->exists($tmpRel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = pathinfo($tmpRel, PATHINFO_EXTENSION);
|
||||||
|
$base = pathinfo($tmpRel, PATHINFO_FILENAME);
|
||||||
|
$candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
|
||||||
|
// Ensure dest doesn't exist
|
||||||
|
while (Storage::disk('public')->exists($candidate)) {
|
||||||
|
$candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the file
|
||||||
|
Storage::disk('public')->move($tmpRel, $candidate);
|
||||||
|
|
||||||
|
// Create Document record
|
||||||
|
try {
|
||||||
|
$full = storage_path('app/public/'.$candidate);
|
||||||
|
$mime = File::exists($full) ? File::mimeType($full) : null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$mime = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$size = Storage::disk('public')->size($candidate);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$size = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tpl->documents()->create([
|
||||||
|
'name' => basename($candidate),
|
||||||
|
'description' => null,
|
||||||
|
'user_id' => optional(auth()->user())->id,
|
||||||
|
'disk' => 'public',
|
||||||
|
'path' => $candidate,
|
||||||
|
'file_name' => basename($candidate),
|
||||||
|
'original_name' => basename($candidate),
|
||||||
|
'extension' => $ext ?: null,
|
||||||
|
'mime_type' => $mime,
|
||||||
|
'size' => $size,
|
||||||
|
'is_public' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update HTML to reference the new permanent path (use relative /storage URL)
|
||||||
|
$to = '/storage/'.$candidate;
|
||||||
|
$from = ['/storage/'.$tmpRel, $tmpRel];
|
||||||
|
$html = str_replace($from, $to, $html);
|
||||||
|
// Also replace absolute URL variants like https://domain/storage/<path>
|
||||||
|
$pattern = '#https?://[^"\']+/storage/'.preg_quote($tmpRel, '#').'#i';
|
||||||
|
$html = preg_replace($pattern, $to, $html);
|
||||||
|
}
|
||||||
|
if ($html !== (string) ($tpl->html_template ?? '')) {
|
||||||
|
$tpl->forceFill(['html_template' => $html])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move any tmp images present in the provided HTML into permanent storage, attach documents,
|
||||||
|
* and return the updated HTML. Optionally persist the template's HTML.
|
||||||
|
*/
|
||||||
|
protected function adoptTmpImagesInHtml(EmailTemplate $tpl, string $html, bool $persistTemplate = false): string
|
||||||
|
{
|
||||||
|
if ($html === '' || stripos($html, 'tmp/email-images/') === false) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
$paths = [];
|
||||||
|
$matches = [];
|
||||||
|
if (preg_match_all('#/storage/tmp/email-images/[^"\']+#i', $html, $matches)) {
|
||||||
|
$paths = array_merge($paths, $matches[0]);
|
||||||
|
}
|
||||||
|
if (preg_match_all('#tmp/email-images/[^"\']+#i', $html, $matches)) {
|
||||||
|
$paths = array_merge($paths, $matches[0]);
|
||||||
|
}
|
||||||
|
$paths = array_values(array_unique($paths));
|
||||||
|
if (empty($paths)) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
foreach ($paths as $tmpRel) {
|
||||||
|
$tmpRel = ltrim(preg_replace('#^/?storage/#i', '', $tmpRel), '/');
|
||||||
|
if (! Storage::disk('public')->exists($tmpRel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ext = pathinfo($tmpRel, PATHINFO_EXTENSION);
|
||||||
|
$base = pathinfo($tmpRel, PATHINFO_FILENAME);
|
||||||
|
$candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
|
||||||
|
while (Storage::disk('public')->exists($candidate)) {
|
||||||
|
$candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : '');
|
||||||
|
}
|
||||||
|
Storage::disk('public')->move($tmpRel, $candidate);
|
||||||
|
try {
|
||||||
|
$mime = File::exists(storage_path('app/public/'.$candidate)) ? File::mimeType(storage_path('app/public/'.$candidate)) : null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$mime = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$size = Storage::disk('public')->size($candidate);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$size = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tpl->documents()->create([
|
||||||
|
'name' => basename($candidate),
|
||||||
|
'description' => null,
|
||||||
|
'user_id' => optional(auth()->user())->id,
|
||||||
|
'disk' => 'public',
|
||||||
|
'path' => $candidate,
|
||||||
|
'file_name' => basename($candidate),
|
||||||
|
'original_name' => basename($candidate),
|
||||||
|
'extension' => $ext ?: null,
|
||||||
|
'mime_type' => $mime,
|
||||||
|
'size' => $size,
|
||||||
|
'is_public' => true,
|
||||||
|
]);
|
||||||
|
$to = '/storage/'.$candidate;
|
||||||
|
$from = ['/storage/'.$tmpRel, $tmpRel];
|
||||||
|
$html = str_replace($from, $to, $html);
|
||||||
|
$pattern = '#https?://[^"\']+/storage/'.preg_quote($tmpRel, '#').'#i';
|
||||||
|
$html = preg_replace($pattern, $to, $html);
|
||||||
|
}
|
||||||
|
if ($persistTemplate && $tpl->exists) {
|
||||||
|
$tpl->forceFill(['html_template' => $html])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small JSON endpoints to support cascading selects in editor preview.
|
||||||
|
*/
|
||||||
|
public function clients(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', EmailTemplate::class);
|
||||||
|
$items = Client::query()->with(['person'])->latest('id')->limit(50)->get();
|
||||||
|
|
||||||
|
return response()->json($items->map(fn ($c) => [
|
||||||
|
'id' => $c->id,
|
||||||
|
'label' => trim(($c->person->full_name ?? '').' #'.$c->id) ?: ('Client #'.$c->id),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function casesForClient(Request $request, Client $client)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', EmailTemplate::class);
|
||||||
|
$items = ClientCase::query()
|
||||||
|
->with(['person'])
|
||||||
|
->where('client_id', $client->id)
|
||||||
|
->latest('id')
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json($items->map(function ($cs) {
|
||||||
|
$person = $cs->person->full_name ?? '';
|
||||||
|
$ref = $cs->reference ?? '';
|
||||||
|
$base = trim(($ref !== '' ? ($ref.' ') : '').'#'.$cs->id);
|
||||||
|
$label = trim(($person !== '' ? ($person.' — ') : '').$base);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $cs->id,
|
||||||
|
'label' => $label !== '' ? $label : ('Case #'.$cs->id),
|
||||||
|
];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contractsForCase(Request $request, ClientCase $clientCase)
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', EmailTemplate::class);
|
||||||
|
$items = Contract::query()->where('client_case_id', $clientCase->id)->latest('id')->limit(50)->get();
|
||||||
|
|
||||||
|
return response()->json($items->map(fn ($ct) => [
|
||||||
|
'id' => $ct->id,
|
||||||
|
'label' => trim(($ct->reference ?? '').' #'.$ct->id) ?: ('Contract #'.$ct->id),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreMailProfileRequest;
|
||||||
|
use App\Http\Requests\UpdateMailProfileRequest;
|
||||||
|
use App\Models\MailProfile;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Symfony\Component\Mailer\Mailer as SymfonyMailer;
|
||||||
|
use Symfony\Component\Mailer\Transport;
|
||||||
|
use Symfony\Component\Mime\Address;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
|
||||||
|
class MailProfileController extends Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', MailProfile::class);
|
||||||
|
$profiles = MailProfile::query()
|
||||||
|
->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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/MailProfiles/Index', [
|
||||||
|
'profiles' => $profiles,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreMailProfileRequest $request)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$profile = new MailProfile;
|
||||||
|
foreach ($data as $key => $val) {
|
||||||
|
if ($key === 'password') {
|
||||||
|
$profile->password = $val; // triggers mutator to encrypt
|
||||||
|
} else {
|
||||||
|
$profile->{$key} = $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$profile->save();
|
||||||
|
|
||||||
|
return back()->with('success', 'Mail profile created');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateMailProfileRequest $request, MailProfile $mailProfile)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
foreach ($data as $key => $val) {
|
||||||
|
if ($key === 'password') {
|
||||||
|
if ($val !== null && $val !== '') {
|
||||||
|
$mailProfile->password = $val;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$mailProfile->{$key} = $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$mailProfile->save();
|
||||||
|
|
||||||
|
return back()->with('success', 'Mail profile updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggle(Request $request, MailProfile $mailProfile)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $mailProfile);
|
||||||
|
$mailProfile->active = ! $mailProfile->active;
|
||||||
|
$mailProfile->save();
|
||||||
|
|
||||||
|
return back()->with('success', 'Status updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test(Request $request, MailProfile $mailProfile)
|
||||||
|
{
|
||||||
|
$this->authorize('test', $mailProfile);
|
||||||
|
$mailProfile->forceFill([
|
||||||
|
'test_status' => 'queued',
|
||||||
|
'test_checked_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
\App\Jobs\TestMailProfileConnection::dispatch($mailProfile->id);
|
||||||
|
|
||||||
|
return back()->with('success', 'Test queued');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(MailProfile $mailProfile)
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $mailProfile);
|
||||||
|
$mailProfile->delete();
|
||||||
|
|
||||||
|
return back()->with('success', 'Mail profile deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendTest(Request $request, MailProfile $mailProfile)
|
||||||
|
{
|
||||||
|
$this->authorize('test', $mailProfile);
|
||||||
|
|
||||||
|
$to = (string) ($request->input('to') ?: $mailProfile->from_address);
|
||||||
|
if ($to === '' || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return back()->with('error', 'Missing or invalid target email address');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build DSN for Symfony Mailer transport based on profile
|
||||||
|
$host = $mailProfile->host;
|
||||||
|
$port = (int) ($mailProfile->port ?: 587);
|
||||||
|
$encryption = $mailProfile->encryption ?: 'tls';
|
||||||
|
$username = $mailProfile->username ?: '';
|
||||||
|
$password = (string) ($mailProfile->decryptPassword() ?? '');
|
||||||
|
|
||||||
|
// Map encryption to Symfony DSN
|
||||||
|
$scheme = $encryption === 'ssl' ? 'smtps' : 'smtp';
|
||||||
|
$query = '';
|
||||||
|
if ($encryption === 'tls') {
|
||||||
|
$query = '?encryption=tls';
|
||||||
|
}
|
||||||
|
$dsn = sprintf('%s://%s:%s@%s:%d%s', $scheme, rawurlencode($username), rawurlencode($password), $host, $port, $query);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$transport = Transport::fromDsn($dsn);
|
||||||
|
$mailer = new SymfonyMailer($transport);
|
||||||
|
|
||||||
|
$fromAddr = $mailProfile->from_address ?: $username;
|
||||||
|
$fromName = (string) ($mailProfile->from_name ?: (config('mail.from.name') ?? config('app.name') ?? ''));
|
||||||
|
|
||||||
|
$html = '<p>This is a <strong>test email</strong> from profile <code>'.e($mailProfile->name).'</code> at '.e(now()->toDateTimeString()).'.</p>';
|
||||||
|
$text = 'This is a test email from profile "'.$mailProfile->name.'" at '.now()->toDateTimeString().'.';
|
||||||
|
|
||||||
|
// Build email
|
||||||
|
$fromAddress = $fromName !== '' ? new Address($fromAddr, $fromName) : new Address($fromAddr);
|
||||||
|
$email = (new Email)
|
||||||
|
->from($fromAddress)
|
||||||
|
->to($to)
|
||||||
|
->subject('Test email - '.$mailProfile->name)
|
||||||
|
->text($text)
|
||||||
|
->html($html);
|
||||||
|
|
||||||
|
$mailer->send($email);
|
||||||
|
|
||||||
|
$mailProfile->forceFill([
|
||||||
|
'last_success_at' => now(),
|
||||||
|
'last_error_at' => null,
|
||||||
|
'last_error_message' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return back()->with('success', 'Test email sent to '.$to);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$mailProfile->forceFill([
|
||||||
|
'last_error_at' => now(),
|
||||||
|
'last_error_message' => $e->getMessage(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return back()->with('error', 'Failed to send test: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StorePackageFromContractsRequest;
|
||||||
|
use App\Http\Requests\StorePackageRequest;
|
||||||
|
use App\Jobs\PackageItemSmsJob;
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\Package;
|
||||||
|
use App\Models\PackageItem;
|
||||||
|
use App\Models\SmsTemplate;
|
||||||
|
use App\Services\Contact\PhoneSelector;
|
||||||
|
use App\Services\Sms\SmsService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class PackageController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$packages = Package::query()
|
||||||
|
->latest('id')
|
||||||
|
->paginate(20);
|
||||||
|
// Minimal lookups for create form (active only)
|
||||||
|
$profiles = \App\Models\SmsProfile::query()
|
||||||
|
->where('active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name']);
|
||||||
|
$senders = \App\Models\SmsSender::query()
|
||||||
|
->where('active', true)
|
||||||
|
->orderBy('sname')
|
||||||
|
->get(['id', 'profile_id', 'sname', 'phone_number']);
|
||||||
|
$templates = \App\Models\SmsTemplate::query()
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'content']);
|
||||||
|
$segments = \App\Models\Segment::query()
|
||||||
|
->where('active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name']);
|
||||||
|
// Provide a lightweight list of recent clients with person names for filtering
|
||||||
|
$clients = \App\Models\Client::query()
|
||||||
|
->with(['person' => function ($q) {
|
||||||
|
$q->select('id', 'uuid', 'full_name');
|
||||||
|
}])
|
||||||
|
->latest('id')
|
||||||
|
->get(['id', 'uuid', 'person_id'])
|
||||||
|
->map(function ($c) {
|
||||||
|
return [
|
||||||
|
'id' => $c->id,
|
||||||
|
'uuid' => $c->uuid,
|
||||||
|
'name' => $c->person?->full_name ?? ('Client #'.$c->id),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Packages/Index', [
|
||||||
|
'packages' => $packages,
|
||||||
|
'profiles' => $profiles,
|
||||||
|
'senders' => $senders,
|
||||||
|
'templates' => $templates,
|
||||||
|
'segments' => $segments,
|
||||||
|
'clients' => $clients,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Package $package, SmsService $sms): Response
|
||||||
|
{
|
||||||
|
$items = $package->items()->latest('id')->paginate(25);
|
||||||
|
|
||||||
|
// Preload contracts/accounts for current page items to compute per-item previews
|
||||||
|
$contractIds = collect($items->items())
|
||||||
|
->map(fn ($it) => (array) ($it->target_json ?? []))
|
||||||
|
->map(fn ($t) => $t['contract_id'] ?? null)
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
$contracts = $contractIds->isNotEmpty()
|
||||||
|
? Contract::query()->with('account.type')->whereIn('id', $contractIds)->get()->keyBy('id')
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
// Attach rendered_preview to each item
|
||||||
|
$collection = collect($items->items());
|
||||||
|
$collection = $collection->transform(function ($it) use ($sms, $contracts) {
|
||||||
|
$payload = (array) ($it->payload_json ?? []);
|
||||||
|
$tgt = (array) ($it->target_json ?? []);
|
||||||
|
$vars = (array) ($payload['variables'] ?? []);
|
||||||
|
if (! empty($tgt['contract_id']) && $contracts->has($tgt['contract_id'])) {
|
||||||
|
$c = $contracts->get($tgt['contract_id']);
|
||||||
|
$vars['contract'] = [
|
||||||
|
'id' => $c->id,
|
||||||
|
'uuid' => $c->uuid,
|
||||||
|
'reference' => $c->reference,
|
||||||
|
'start_date' => (string) ($c->start_date ?? ''),
|
||||||
|
'end_date' => (string) ($c->end_date ?? ''),
|
||||||
|
];
|
||||||
|
// Include contract.meta as flattened key-value pairs
|
||||||
|
if (is_array($c->meta) && ! empty($c->meta)) {
|
||||||
|
$vars['contract']['meta'] = $this->flattenMeta($c->meta);
|
||||||
|
}
|
||||||
|
if ($c->account) {
|
||||||
|
$initialRaw = (string) $c->account->initial_amount;
|
||||||
|
$balanceRaw = (string) $c->account->balance_amount;
|
||||||
|
$vars['account'] = [
|
||||||
|
'id' => $c->account->id,
|
||||||
|
'reference' => $c->account->reference,
|
||||||
|
// Use EU formatted values for SMS previews
|
||||||
|
'initial_amount' => $sms->formatAmountEu($initialRaw),
|
||||||
|
'balance_amount' => $sms->formatAmountEu($balanceRaw),
|
||||||
|
// Also expose raw values
|
||||||
|
'initial_amount_raw' => $initialRaw,
|
||||||
|
'balance_amount_raw' => $balanceRaw,
|
||||||
|
'type' => $c->account->type?->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer recorded message from result_json if available (sent items)
|
||||||
|
$result = (array) ($it->result_json ?? []);
|
||||||
|
$rendered = $result['message'] ?? null;
|
||||||
|
if (! $rendered) {
|
||||||
|
$body = isset($payload['body']) ? trim((string) $payload['body']) : '';
|
||||||
|
if ($body !== '') {
|
||||||
|
$rendered = $sms->renderContent($body, $vars);
|
||||||
|
} elseif (! empty($payload['template_id'])) {
|
||||||
|
$tpl = \App\Models\SmsTemplate::find((int) $payload['template_id']);
|
||||||
|
if ($tpl) {
|
||||||
|
$rendered = $sms->renderContent($tpl->content, $vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$it->rendered_preview = $rendered;
|
||||||
|
|
||||||
|
return $it;
|
||||||
|
});
|
||||||
|
// Replace paginator collection
|
||||||
|
if (method_exists($items, 'setCollection')) {
|
||||||
|
$items->setCollection($collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a preview of message content from the first item (shared payload across package)
|
||||||
|
$preview = null;
|
||||||
|
$firstItem = $package->items()->oldest('id')->first();
|
||||||
|
if ($firstItem) {
|
||||||
|
$payload = (array) ($firstItem->payload_json ?? []);
|
||||||
|
$body = isset($payload['body']) ? trim((string) $payload['body']) : '';
|
||||||
|
// Enrich variables with contract/account for preview if available
|
||||||
|
$vars = (array) ($payload['variables'] ?? []);
|
||||||
|
$tgt = (array) ($firstItem->target_json ?? []);
|
||||||
|
if (! empty($tgt['contract_id'])) {
|
||||||
|
$c = Contract::query()->with('account.type')->find($tgt['contract_id']);
|
||||||
|
if ($c) {
|
||||||
|
$vars['contract'] = [
|
||||||
|
'id' => $c->id,
|
||||||
|
'uuid' => $c->uuid,
|
||||||
|
'reference' => $c->reference,
|
||||||
|
'start_date' => (string) ($c->start_date ?? ''),
|
||||||
|
'end_date' => (string) ($c->end_date ?? ''),
|
||||||
|
];
|
||||||
|
// Include contract.meta as flattened key-value pairs
|
||||||
|
if (is_array($c->meta) && ! empty($c->meta)) {
|
||||||
|
$vars['contract']['meta'] = $this->flattenMeta($c->meta);
|
||||||
|
}
|
||||||
|
if ($c->account) {
|
||||||
|
$initialRaw = (string) $c->account->initial_amount;
|
||||||
|
$balanceRaw = (string) $c->account->balance_amount;
|
||||||
|
$vars['account'] = [
|
||||||
|
'id' => $c->account->id,
|
||||||
|
'reference' => $c->account->reference,
|
||||||
|
'initial_amount' => $sms->formatAmountEu($initialRaw),
|
||||||
|
'balance_amount' => $sms->formatAmountEu($balanceRaw),
|
||||||
|
'initial_amount_raw' => $initialRaw,
|
||||||
|
'balance_amount_raw' => $balanceRaw,
|
||||||
|
'type' => $c->account->type?->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($body !== '') {
|
||||||
|
$preview = [
|
||||||
|
'source' => 'body',
|
||||||
|
'content' => $sms->renderContent($body, $vars),
|
||||||
|
];
|
||||||
|
} elseif (! empty($payload['template_id'])) {
|
||||||
|
/** @var SmsTemplate|null $tpl */
|
||||||
|
$tpl = SmsTemplate::find((int) $payload['template_id']);
|
||||||
|
if ($tpl) {
|
||||||
|
$content = $sms->renderContent($tpl->content, $vars);
|
||||||
|
$preview = [
|
||||||
|
'source' => 'template',
|
||||||
|
'template' => [
|
||||||
|
'id' => $tpl->id,
|
||||||
|
'name' => $tpl->name,
|
||||||
|
],
|
||||||
|
'content' => $content,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Packages/Show', [
|
||||||
|
'package' => $package,
|
||||||
|
'items' => $items,
|
||||||
|
'preview' => $preview,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StorePackageRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$package = Package::query()->create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'type' => $data['type'],
|
||||||
|
'status' => Package::STATUS_DRAFT,
|
||||||
|
'name' => $data['name'] ?? null,
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'meta' => $data['meta'] ?? [],
|
||||||
|
'created_by' => optional($request->user())->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
dd($data['items']);
|
||||||
|
|
||||||
|
$items = collect($data['items'])
|
||||||
|
->map(function (array $row) {
|
||||||
|
return new PackageItem([
|
||||||
|
'status' => 'queued',
|
||||||
|
'target_json' => [
|
||||||
|
'number' => (string) $row['number'],
|
||||||
|
'phone_id' => $row['phone_id'] ?? null,
|
||||||
|
],
|
||||||
|
'payload_json' => $row['payload'] ?? [],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$package->items()->saveMany($items);
|
||||||
|
$package->total_items = $items->count();
|
||||||
|
$package->save();
|
||||||
|
|
||||||
|
return back()->with('success', 'Package created');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(Package $package): RedirectResponse
|
||||||
|
{
|
||||||
|
if (! in_array($package->status, [Package::STATUS_DRAFT, Package::STATUS_FAILED], true)) {
|
||||||
|
return back()->with('error', 'Package not in a dispatchable state.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) {
|
||||||
|
return new PackageItemSmsJob($item->id);
|
||||||
|
})->all();
|
||||||
|
|
||||||
|
if (empty($jobs)) {
|
||||||
|
return back()->with('error', 'No items to dispatch.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$package->status = Package::STATUS_QUEUED;
|
||||||
|
$package->save();
|
||||||
|
|
||||||
|
Bus::batch($jobs)
|
||||||
|
->name('pkg:'.$package->id.' ('.$package->type.')')
|
||||||
|
->then(function () use ($package) {
|
||||||
|
// If finished counters not set by items (e.g., empty), finalize
|
||||||
|
$package->refresh();
|
||||||
|
if (($package->sent_count + $package->failed_count) >= $package->total_items) {
|
||||||
|
$finalStatus = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED;
|
||||||
|
$package->status = $finalStatus;
|
||||||
|
$package->finished_at = now();
|
||||||
|
$package->save();
|
||||||
|
} else {
|
||||||
|
$package->status = Package::STATUS_RUNNING;
|
||||||
|
$package->save();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->onQueue('sms')
|
||||||
|
->dispatch();
|
||||||
|
|
||||||
|
return back()->with('success', 'Package dispatched');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Package $package): RedirectResponse
|
||||||
|
{
|
||||||
|
$package->status = Package::STATUS_CANCELED;
|
||||||
|
$package->save();
|
||||||
|
|
||||||
|
return back()->with('success', 'Package canceled');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List contracts for a given segment and include selected phone per person.
|
||||||
|
*/
|
||||||
|
public function contracts(Request $request, PhoneSelector $selector): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||||
|
'q' => ['nullable', 'string'],
|
||||||
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||||
|
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||||
|
'only_mobile' => ['nullable', 'boolean'],
|
||||||
|
'only_validated' => ['nullable', 'boolean'],
|
||||||
|
'start_date_from' => ['nullable', 'date'],
|
||||||
|
'start_date_to' => ['nullable', 'date'],
|
||||||
|
'promise_date_from' => ['nullable', 'date'],
|
||||||
|
'promise_date_to' => ['nullable', 'date'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||||
|
$perPage = (int) ($request->input('per_page') ?? 25);
|
||||||
|
|
||||||
|
$query = Contract::query()
|
||||||
|
->with([
|
||||||
|
'clientCase.person.phones',
|
||||||
|
'clientCase.client.person',
|
||||||
|
'account',
|
||||||
|
])
|
||||||
|
->select('contracts.*')
|
||||||
|
->latest('contracts.id');
|
||||||
|
|
||||||
|
// Optional segment filter
|
||||||
|
if ($segmentId) {
|
||||||
|
$query->join('contract_segment', function ($j) use ($segmentId) {
|
||||||
|
$j->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||||
|
->where('contract_segment.segment_id', '=', $segmentId)
|
||||||
|
->where('contract_segment.active', true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($q = trim((string) $request->input('q'))) {
|
||||||
|
$query->where(function ($w) use ($q) {
|
||||||
|
$w->where('contracts.reference', 'ILIKE', "%{$q}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($clientId = $request->integer('client_id')) {
|
||||||
|
$query->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
|
||||||
|
->where('client_cases.client_id', $clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range filters for start_date
|
||||||
|
if ($startDateFrom = $request->input('start_date_from')) {
|
||||||
|
$query->where('contracts.start_date', '>=', $startDateFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($startDateTo = $request->input('start_date_to')) {
|
||||||
|
$query->where('contracts.start_date', '<=', $startDateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range filters for account.promise_date
|
||||||
|
$promiseDateFrom = $request->input('promise_date_from');
|
||||||
|
$promiseDateTo = $request->input('promise_date_to');
|
||||||
|
|
||||||
|
if ($promiseDateFrom || $promiseDateTo) {
|
||||||
|
$query->whereHas('account', function ($q) use ($promiseDateFrom, $promiseDateTo) {
|
||||||
|
if ($promiseDateFrom) {
|
||||||
|
$q->where('promise_date', '>=', $promiseDateFrom);
|
||||||
|
}
|
||||||
|
if ($promiseDateTo) {
|
||||||
|
$q->where('promise_date', '<=', $promiseDateTo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional phone filters
|
||||||
|
if ($request->boolean('only_mobile') || $request->boolean('only_validated')) {
|
||||||
|
$query->whereHas('clientCase.person.phones', function ($q) use ($request) {
|
||||||
|
if ($request->boolean('only_mobile')) {
|
||||||
|
$q->where('person_phones.phone_type', 'mobile');
|
||||||
|
}
|
||||||
|
if ($request->boolean('only_validated')) {
|
||||||
|
$q->where('person_phones.validated', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$contracts = $query->paginate($perPage);
|
||||||
|
|
||||||
|
$data = collect($contracts->items())->map(function (Contract $contract) use ($selector) {
|
||||||
|
$person = $contract->clientCase?->person;
|
||||||
|
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
||||||
|
$phone = $selected['phone'];
|
||||||
|
$clientPerson = $contract->clientCase?->client?->person;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $contract->id,
|
||||||
|
'uuid' => $contract->uuid,
|
||||||
|
'reference' => $contract->reference,
|
||||||
|
'start_date' => $contract->start_date,
|
||||||
|
'promise_date' => $contract->account?->promise_date,
|
||||||
|
'case' => [
|
||||||
|
'id' => $contract->clientCase?->id,
|
||||||
|
'uuid' => $contract->clientCase?->uuid,
|
||||||
|
],
|
||||||
|
// Primer: the case person
|
||||||
|
'person' => [
|
||||||
|
'id' => $person?->id,
|
||||||
|
'uuid' => $person?->uuid,
|
||||||
|
'full_name' => $person?->full_name,
|
||||||
|
],
|
||||||
|
// Stranka: the client person
|
||||||
|
'client' => $clientPerson ? [
|
||||||
|
'id' => $contract->clientCase?->client?->id,
|
||||||
|
'uuid' => $contract->clientCase?->client?->uuid,
|
||||||
|
'name' => $clientPerson->full_name,
|
||||||
|
] : null,
|
||||||
|
'selected_phone' => $phone ? [
|
||||||
|
'id' => $phone->id,
|
||||||
|
'number' => $phone->nu,
|
||||||
|
'validated' => $phone->validated,
|
||||||
|
'type' => $phone->phone_type?->value,
|
||||||
|
] : null,
|
||||||
|
'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $data,
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $contracts->currentPage(),
|
||||||
|
'last_page' => $contracts->lastPage(),
|
||||||
|
'per_page' => $contracts->perPage(),
|
||||||
|
'total' => $contracts->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an SMS package from a list of contracts by selecting recipient phones.
|
||||||
|
*/
|
||||||
|
public function storeFromContracts(StorePackageFromContractsRequest $request, PhoneSelector $selector): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
// Load contracts with people, phones and account (for template placeholders)
|
||||||
|
$contracts = Contract::query()
|
||||||
|
->with(['clientCase.person.phones', 'account.type'])
|
||||||
|
->whereIn('id', $data['contract_ids'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
$seen = collect(); // de-dup by phone_id or number
|
||||||
|
$skipped = 0;
|
||||||
|
foreach ($contracts as $contract) {
|
||||||
|
$person = $contract->clientCase?->person;
|
||||||
|
if (! $person) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$selected = $selector->selectForPerson($person);
|
||||||
|
/** @var ?\App\Models\Person\PersonPhone $phone */
|
||||||
|
$phone = $selected['phone'];
|
||||||
|
if (! $phone) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu;
|
||||||
|
/*if ($seen->contains($key)) {
|
||||||
|
// skip duplicates across multiple contracts/persons
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}*/
|
||||||
|
$seen->push($key);
|
||||||
|
$items[] = [
|
||||||
|
'number' => (string) $phone->nu,
|
||||||
|
'phone_id' => $phone->id,
|
||||||
|
'payload' => $data['payload'] ?? [],
|
||||||
|
// Keep context for variable rendering during send
|
||||||
|
'contract_id' => $contract->id,
|
||||||
|
'account_id' => $contract->account?->id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($items)) {
|
||||||
|
return back()->with('error', 'No recipients found for selected contracts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$package = Package::query()->create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'type' => $data['type'],
|
||||||
|
'status' => Package::STATUS_DRAFT,
|
||||||
|
'name' => $data['name'] ?? null,
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'meta' => array_merge($data['meta'] ?? [], [
|
||||||
|
'source' => 'contracts',
|
||||||
|
'skipped' => $skipped,
|
||||||
|
]),
|
||||||
|
'created_by' => optional($request->user())->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$packageItems = collect($items)->map(function (array $row) {
|
||||||
|
return new PackageItem([
|
||||||
|
'status' => 'queued',
|
||||||
|
'target_json' => [
|
||||||
|
'number' => $row['number'],
|
||||||
|
'phone_id' => $row['phone_id'],
|
||||||
|
'contract_id' => $row['contract_id'] ?? null,
|
||||||
|
'account_id' => $row['account_id'] ?? null,
|
||||||
|
],
|
||||||
|
'payload_json' => $row['payload'] ?? [],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$package->items()->saveMany($packageItems);
|
||||||
|
$package->total_items = $packageItems->count();
|
||||||
|
$package->save();
|
||||||
|
|
||||||
|
return back()->with('success', 'Package created from contracts');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten nested meta structure into dot-notation key-value pairs.
|
||||||
|
* Extracts 'value' from objects with {title, value, type} structure.
|
||||||
|
* Also creates direct access aliases for nested fields (skipping numeric keys).
|
||||||
|
*/
|
||||||
|
private function flattenMeta(array $meta, string $prefix = ''): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($meta as $key => $value) {
|
||||||
|
$newKey = $prefix === '' ? $key : "{$prefix}.{$key}";
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
// Check if it's a structured meta entry with 'value' field
|
||||||
|
if (isset($value['value'])) {
|
||||||
|
$result[$newKey] = $value['value'];
|
||||||
|
// If parent key is numeric, also create direct alias without the number
|
||||||
|
if ($prefix !== '' && is_numeric($key)) {
|
||||||
|
$result[$key] = $value['value'];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Recursively flatten nested arrays
|
||||||
|
$nested = $this->flattenMeta($value, $newKey);
|
||||||
|
$result = array_merge($result, $nested);
|
||||||
|
|
||||||
|
// If current key is numeric, also flatten without it for easier access
|
||||||
|
if (is_numeric($key)) {
|
||||||
|
$directNested = $this->flattenMeta($value, $prefix);
|
||||||
|
foreach ($directNested as $dk => $dv) {
|
||||||
|
// Only add if not already set (prefer first occurrence)
|
||||||
|
if (! isset($result[$dk])) {
|
||||||
|
$result[$dk] = $dv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$result[$newKey] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\StorePermissionRequest;
|
use App\Http\Requests\StorePermissionRequest;
|
||||||
|
use App\Http\Requests\UpdatePermissionRequest;
|
||||||
use App\Models\Permission;
|
use App\Models\Permission;
|
||||||
|
use App\Models\Role;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -14,7 +16,7 @@ class PermissionController extends Controller
|
|||||||
public function index(): Response
|
public function index(): Response
|
||||||
{
|
{
|
||||||
$permissions = Permission::query()
|
$permissions = Permission::query()
|
||||||
->select('id','name','slug','description','created_at')
|
->select('id', 'name', 'slug', 'description', 'created_at')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
@@ -22,15 +24,51 @@ public function index(): Response
|
|||||||
'permissions' => $permissions,
|
'permissions' => $permissions,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(): Response
|
public function create(): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('Admin/Permissions/Create');
|
$roles = Role::orderBy('name')->get(['id', 'name', 'slug']);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Permissions/Create', [
|
||||||
|
'roles' => $roles,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StorePermissionRequest $request): RedirectResponse
|
public function store(StorePermissionRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
Permission::create($request->validated());
|
$data = $request->validated();
|
||||||
|
$roleIds = $data['roles'] ?? [];
|
||||||
|
unset($data['roles']);
|
||||||
|
|
||||||
return redirect()->route('admin.index')->with('success', 'Dovoljenje ustvarjeno.');
|
$permission = Permission::create($data);
|
||||||
|
if (! empty($roleIds)) {
|
||||||
|
$permission->roles()->sync($roleIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('admin.permissions.index')->with('success', 'Dovoljenje ustvarjeno.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Permission $permission): Response
|
||||||
|
{
|
||||||
|
$roles = Role::orderBy('name')->get(['id', 'name', 'slug']);
|
||||||
|
$selected = $permission->roles()->pluck('roles.id');
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Permissions/Edit', [
|
||||||
|
'permission' => $permission->only('id', 'name', 'slug', 'description'),
|
||||||
|
'roles' => $roles,
|
||||||
|
'selectedRoleIds' => $selected,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdatePermissionRequest $request, Permission $permission): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$roleIds = $data['roles'] ?? [];
|
||||||
|
unset($data['roles']);
|
||||||
|
|
||||||
|
$permission->update($data);
|
||||||
|
$permission->roles()->sync($roleIds);
|
||||||
|
|
||||||
|
return redirect()->route('admin.permissions.index')->with('success', 'Dovoljenje posodobljeno.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SmsLog;
|
||||||
|
use App\Models\SmsProfile;
|
||||||
|
use App\Models\SmsTemplate;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class SmsLogController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = SmsLog::query()->with(['profile:id,name', 'template:id,name,slug']);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
$status = $request->string('status')->toString();
|
||||||
|
$profileId = $request->integer('profile_id');
|
||||||
|
$templateId = $request->integer('template_id');
|
||||||
|
$search = trim((string) $request->input('search', ''));
|
||||||
|
$from = $request->date('from');
|
||||||
|
$to = $request->date('to');
|
||||||
|
|
||||||
|
if ($status !== '') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
if ($profileId) {
|
||||||
|
$query->where('profile_id', $profileId);
|
||||||
|
}
|
||||||
|
if ($templateId) {
|
||||||
|
$query->where('template_id', $templateId);
|
||||||
|
}
|
||||||
|
if ($search !== '') {
|
||||||
|
$query->where(function ($q) use ($search): void {
|
||||||
|
$q->where('to_number', 'ILIKE', "%$search%")
|
||||||
|
->orWhere('sender', 'ILIKE', "%$search%")
|
||||||
|
->orWhere('provider_message_id', 'ILIKE', "%$search%")
|
||||||
|
->orWhere('message', 'ILIKE', "%$search%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ($from) {
|
||||||
|
$query->whereDate('created_at', '>=', $from);
|
||||||
|
}
|
||||||
|
if ($to) {
|
||||||
|
$query->whereDate('created_at', '<=', $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logs = $query->orderByDesc('id')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json($logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
|
||||||
|
$templates = SmsTemplate::query()->orderBy('name')->get(['id', 'name', 'slug']);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/SmsLogs/Index', [
|
||||||
|
'logs' => $logs,
|
||||||
|
'profiles' => $profiles,
|
||||||
|
'templates' => $templates,
|
||||||
|
'filters' => [
|
||||||
|
'status' => $status ?: null,
|
||||||
|
'profile_id' => $profileId ?: null,
|
||||||
|
'template_id' => $templateId ?: null,
|
||||||
|
'search' => $search ?: null,
|
||||||
|
'from' => $from ? $from->format('Y-m-d') : null,
|
||||||
|
'to' => $to ? $to->format('Y-m-d') : null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(SmsLog $smsLog)
|
||||||
|
{
|
||||||
|
$smsLog->load(['profile:id,name', 'template:id,name,slug']);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/SmsLogs/Show', [
|
||||||
|
'log' => $smsLog,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreSmsProfileRequest;
|
||||||
|
use App\Http\Requests\TestSendSmsRequest;
|
||||||
|
use App\Jobs\SendSmsJob;
|
||||||
|
use App\Models\SmsProfile;
|
||||||
|
use App\Models\SmsSender;
|
||||||
|
use App\Services\Sms\SmsService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class SmsProfileController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$profiles = SmsProfile::query()->with(['senders:id,profile_id,sname,active'])->orderBy('name')->get([
|
||||||
|
'id', 'uuid', 'name', 'active', 'api_username', 'default_sender_id', 'settings', 'created_at', 'updated_at',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Inertia requests must receive an Inertia response
|
||||||
|
if ($request->headers->has('X-Inertia')) {
|
||||||
|
return Inertia::render('Admin/SmsProfiles/Index', [
|
||||||
|
'initialProfiles' => $profiles,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON/AJAX API
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json(['profiles' => $profiles]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to Inertia page for normal browser navigation
|
||||||
|
return Inertia::render('Admin/SmsProfiles/Index', [
|
||||||
|
'initialProfiles' => $profiles,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreSmsProfileRequest $request)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$profile = new SmsProfile;
|
||||||
|
$profile->uuid = (string) Str::uuid();
|
||||||
|
$profile->name = $data['name'];
|
||||||
|
$profile->active = (bool) ($data['active'] ?? true);
|
||||||
|
$profile->api_username = $data['api_username'];
|
||||||
|
// write-only attribute setter will encrypt and store to encrypted_api_password
|
||||||
|
$profile->api_password = $data['api_password'];
|
||||||
|
$profile->save();
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json(['profile' => $profile], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'SMS profil je ustvarjen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSend(SmsProfile $profile, TestSendSmsRequest $request, SmsService $sms)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$sender = null;
|
||||||
|
if (! empty($data['sender_id'])) {
|
||||||
|
$sender = SmsSender::query()->where('id', $data['sender_id'])->where('profile_id', $profile->id)->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue the SMS send (admin test send - no activity created)
|
||||||
|
SendSmsJob::dispatch(
|
||||||
|
profileId: $profile->id,
|
||||||
|
to: (string) $data['to'],
|
||||||
|
content: (string) $data['message'],
|
||||||
|
senderId: $sender?->id,
|
||||||
|
countryCode: $data['country_code'] ?? null,
|
||||||
|
deliveryReport: (bool) ($data['delivery_report'] ?? false),
|
||||||
|
clientReference: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json(['queued' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Testni SMS je bil dodan v čakalno vrsto.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function balance(SmsProfile $smsProfile, SmsService $sms)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$balance = (string) $sms->getCreditBalance($smsProfile);
|
||||||
|
|
||||||
|
return response()->json(['balance' => $balance]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Return a graceful payload so UI doesn't break; also include message for optional UI/tooling
|
||||||
|
return response()->json([
|
||||||
|
'balance' => '—',
|
||||||
|
'error' => 'Unable to fetch balance: '.$e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function price(SmsProfile $smsProfile, SmsService $sms)
|
||||||
|
{
|
||||||
|
$quotes = $sms->getPriceQuotes($smsProfile);
|
||||||
|
|
||||||
|
return response()->json(['quotes' => $quotes]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreSmsSenderRequest;
|
||||||
|
use App\Http\Requests\UpdateSmsSenderRequest;
|
||||||
|
use App\Models\SmsProfile;
|
||||||
|
use App\Models\SmsSender;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class SmsSenderController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$senders = SmsSender::query()
|
||||||
|
->with(['profile:id,name'])
|
||||||
|
->orderBy('id', 'desc')
|
||||||
|
->get(['id', 'profile_id', 'sname', 'phone_number', 'description', 'active', 'created_at']);
|
||||||
|
|
||||||
|
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/SmsSenders/Index', [
|
||||||
|
'initialSenders' => $senders,
|
||||||
|
'profiles' => $profiles,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreSmsSenderRequest $request)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$sender = SmsSender::create([
|
||||||
|
'profile_id' => $data['profile_id'],
|
||||||
|
'sname' => $data['sname'],
|
||||||
|
'phone_number' => $data['phone_number'] ?? null,
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'active' => (bool) ($data['active'] ?? true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json(['sender' => $sender], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Pošiljatelj je ustvarjen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateSmsSenderRequest $request, SmsSender $smsSender)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$smsSender->forceFill([
|
||||||
|
'profile_id' => $data['profile_id'],
|
||||||
|
'sname' => $data['sname'],
|
||||||
|
'phone_number' => $data['phone_number'] ?? null,
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'active' => (bool) ($data['active'] ?? $smsSender->active),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json(['sender' => $smsSender]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Pošiljatelj je posodobljen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggle(Request $request, SmsSender $smsSender)
|
||||||
|
{
|
||||||
|
$smsSender->active = ! $smsSender->active;
|
||||||
|
$smsSender->save();
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json(['sender' => $smsSender]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Stanje pošiljatelja je posodobljeno.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, SmsSender $smsSender)
|
||||||
|
{
|
||||||
|
$smsSender->delete();
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Pošiljatelj je izbrisan.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreSmsTemplateRequest;
|
||||||
|
use App\Http\Requests\TestSendSmsTemplateRequest;
|
||||||
|
use App\Http\Requests\UpdateSmsTemplateRequest;
|
||||||
|
use App\Models\SmsProfile;
|
||||||
|
use App\Models\SmsSender;
|
||||||
|
use App\Models\SmsTemplate;
|
||||||
|
use App\Services\Sms\SmsService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class SmsTemplateController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$templates = SmsTemplate::query()
|
||||||
|
->with(['defaultProfile:id,name', 'defaultSender:id,sname'])
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'uuid', 'name', 'slug', 'content', 'variables_json', 'is_active', 'default_profile_id', 'default_sender_id', 'created_at']);
|
||||||
|
|
||||||
|
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
|
||||||
|
$senders = SmsSender::query()->orderBy('sname')->get(['id', 'profile_id', 'sname', 'active']);
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'templates' => $templates,
|
||||||
|
'profiles' => $profiles,
|
||||||
|
'senders' => $senders,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Admin/SmsTemplates/Index', [
|
||||||
|
'initialTemplates' => $templates,
|
||||||
|
'profiles' => $profiles,
|
||||||
|
'senders' => $senders,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
|
||||||
|
$senders = SmsSender::query()->orderBy('sname')->get(['id', 'profile_id', 'sname', 'active']);
|
||||||
|
$actions = \App\Models\Action::query()
|
||||||
|
->with(['decisions:id,name'])
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name']);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/SmsTemplates/Edit', [
|
||||||
|
'template' => null,
|
||||||
|
'profiles' => $profiles,
|
||||||
|
'senders' => $senders,
|
||||||
|
'actions' => $actions,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(SmsTemplate $smsTemplate)
|
||||||
|
{
|
||||||
|
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
|
||||||
|
$senders = SmsSender::query()->orderBy('sname')->get(['id', 'profile_id', 'sname', 'active']);
|
||||||
|
$actions = \App\Models\Action::query()
|
||||||
|
->with(['decisions:id,name'])
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name']);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/SmsTemplates/Edit', [
|
||||||
|
'template' => $smsTemplate->only(['id', 'uuid', 'name', 'slug', 'content', 'variables_json', 'is_active', 'default_profile_id', 'default_sender_id', 'allow_custom_body', 'action_id', 'decision_id']),
|
||||||
|
'profiles' => $profiles,
|
||||||
|
'senders' => $senders,
|
||||||
|
'actions' => $actions,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreSmsTemplateRequest $request)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$tpl = new SmsTemplate;
|
||||||
|
$tpl->uuid = (string) Str::uuid();
|
||||||
|
$tpl->name = $data['name'];
|
||||||
|
$tpl->slug = $data['slug'];
|
||||||
|
$tpl->content = $data['content'] ?? '';
|
||||||
|
$tpl->variables_json = $data['variables_json'] ?? null;
|
||||||
|
$tpl->is_active = (bool) ($data['is_active'] ?? true);
|
||||||
|
$tpl->default_profile_id = $data['default_profile_id'] ?? null;
|
||||||
|
$tpl->default_sender_id = $data['default_sender_id'] ?? null;
|
||||||
|
$tpl->allow_custom_body = (bool) ($data['allow_custom_body'] ?? false);
|
||||||
|
$tpl->action_id = $data['action_id'] ?? null;
|
||||||
|
$tpl->decision_id = $data['decision_id'] ?? null;
|
||||||
|
$tpl->save();
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json(['template' => $tpl], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('admin.sms-templates.edit', $tpl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateSmsTemplateRequest $request, SmsTemplate $smsTemplate)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$smsTemplate->forceFill([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'slug' => $data['slug'],
|
||||||
|
'content' => $data['content'] ?? '',
|
||||||
|
'variables_json' => $data['variables_json'] ?? null,
|
||||||
|
'is_active' => (bool) ($data['is_active'] ?? $smsTemplate->is_active),
|
||||||
|
'default_profile_id' => $data['default_profile_id'] ?? null,
|
||||||
|
'default_sender_id' => $data['default_sender_id'] ?? null,
|
||||||
|
'allow_custom_body' => (bool) ($data['allow_custom_body'] ?? $smsTemplate->allow_custom_body),
|
||||||
|
'action_id' => $data['action_id'] ?? null,
|
||||||
|
'decision_id' => $data['decision_id'] ?? null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json(['template' => $smsTemplate]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'SMS predloga je posodobljena.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggle(Request $request, SmsTemplate $smsTemplate)
|
||||||
|
{
|
||||||
|
$smsTemplate->is_active = ! $smsTemplate->is_active;
|
||||||
|
$smsTemplate->save();
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json(['template' => $smsTemplate]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Stanje predloge je posodobljeno.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, SmsTemplate $smsTemplate)
|
||||||
|
{
|
||||||
|
$smsTemplate->delete();
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Predloga je izbrisana.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendTest(TestSendSmsTemplateRequest $request, SmsTemplate $smsTemplate, SmsService $sms)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$profile = null;
|
||||||
|
if (! empty($data['profile_id'])) {
|
||||||
|
$profile = SmsProfile::query()->findOrFail($data['profile_id']);
|
||||||
|
}
|
||||||
|
$sender = null;
|
||||||
|
if (! empty($data['sender_id'])) {
|
||||||
|
$sender = SmsSender::query()->findOrFail($data['sender_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$variables = (array) ($data['variables'] ?? []);
|
||||||
|
|
||||||
|
if (! empty($data['custom_content']) && $smsTemplate->allow_custom_body) {
|
||||||
|
// Use custom content when allowed
|
||||||
|
if (! $profile) {
|
||||||
|
$profile = $smsTemplate->defaultProfile;
|
||||||
|
}
|
||||||
|
if (! $profile) {
|
||||||
|
throw new \InvalidArgumentException('SMS profile is required to send a message.');
|
||||||
|
}
|
||||||
|
$log = $sms->sendRaw(
|
||||||
|
profile: $profile,
|
||||||
|
to: $data['to'],
|
||||||
|
content: (string) $data['custom_content'],
|
||||||
|
sender: $sender,
|
||||||
|
countryCode: $data['country_code'] ?? null,
|
||||||
|
deliveryReport: (bool) ($data['delivery_report'] ?? false),
|
||||||
|
);
|
||||||
|
$log->template_id = $smsTemplate->id;
|
||||||
|
$log->save();
|
||||||
|
} else {
|
||||||
|
$log = $sms->sendFromTemplate(
|
||||||
|
template: $smsTemplate,
|
||||||
|
to: $data['to'],
|
||||||
|
variables: $variables,
|
||||||
|
profile: $profile,
|
||||||
|
sender: $sender,
|
||||||
|
countryCode: $data['country_code'] ?? null,
|
||||||
|
deliveryReport: (bool) ($data['delivery_report'] ?? false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->expectsJson()) {
|
||||||
|
return response()->json(['log' => $log]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Testni SMS je bil poslan.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,14 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\StoreUserRequest;
|
||||||
use App\Models\Permission;
|
use App\Models\Permission;
|
||||||
use App\Models\Role;
|
use App\Models\Role;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ public function index(Request $request): Response
|
|||||||
{
|
{
|
||||||
Gate::authorize('manage-settings');
|
Gate::authorize('manage-settings');
|
||||||
|
|
||||||
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email']);
|
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active']);
|
||||||
$roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']);
|
$roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']);
|
||||||
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
|
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
|
||||||
|
|
||||||
@@ -29,6 +31,23 @@ public function index(Request $request): Response
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function store(StoreUserRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'email' => $validated['email'],
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! empty($validated['roles'])) {
|
||||||
|
$user->roles()->sync($validated['roles']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Uporabnik uspešno ustvarjen');
|
||||||
|
}
|
||||||
|
|
||||||
public function update(Request $request, User $user): RedirectResponse
|
public function update(Request $request, User $user): RedirectResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('manage-settings');
|
Gate::authorize('manage-settings');
|
||||||
@@ -42,4 +61,16 @@ public function update(Request $request, User $user): RedirectResponse
|
|||||||
|
|
||||||
return back()->with('success', 'Roles updated');
|
return back()->with('success', 'Roles updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function toggleActive(User $user): RedirectResponse
|
||||||
|
{
|
||||||
|
Gate::authorize('manage-settings');
|
||||||
|
|
||||||
|
$user->active = ! $user->active;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
$status = $user->active ? 'aktiviran' : 'deaktiviran';
|
||||||
|
|
||||||
|
return back()->with('success', "Uporabnik {$status}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
use App\Models\CaseObject;
|
use App\Models\CaseObject;
|
||||||
use App\Models\ClientCase;
|
use App\Models\ClientCase;
|
||||||
use App\Models\Contract;
|
use App\Models\Contract;
|
||||||
use Illuminate\Database\QueryException;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class CaseObjectController extends Controller
|
class CaseObjectController extends Controller
|
||||||
@@ -27,8 +26,8 @@ public function store(ClientCase $clientCase, string $uuid, Request $request)
|
|||||||
|
|
||||||
public function update(ClientCase $clientCase, int $id, Request $request)
|
public function update(ClientCase $clientCase, int $id, Request $request)
|
||||||
{
|
{
|
||||||
$object = CaseObject::where('id', $id)
|
$object = CaseObject::where('id', $id)
|
||||||
->whereHas('contract', fn($q) => $q->where('client_case_id', $clientCase->id))
|
->whereHas('contract', fn ($q) => $q->where('client_case_id', $clientCase->id))
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
@@ -45,8 +44,8 @@ public function update(ClientCase $clientCase, int $id, Request $request)
|
|||||||
|
|
||||||
public function destroy(ClientCase $clientCase, int $id)
|
public function destroy(ClientCase $clientCase, int $id)
|
||||||
{
|
{
|
||||||
$object = CaseObject::where('id', $id)
|
$object = CaseObject::where('id', $id)
|
||||||
->whereHas('contract', fn($q) => $q->where('client_case_id', $clientCase->id))
|
->whereHas('contract', fn ($q) => $q->where('client_case_id', $clientCase->id))
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$object->delete();
|
$object->delete();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,7 @@ public function index(Client $client, Request $request)
|
|||||||
|
|
||||||
return Inertia::render('Client/Index', [
|
return Inertia::render('Client/Index', [
|
||||||
'clients' => $query
|
'clients' => $query
|
||||||
->paginate(15)
|
->paginate($request->integer('perPage', 15))
|
||||||
->withQueryString(),
|
->withQueryString(),
|
||||||
'filters' => $request->only(['search']),
|
'filters' => $request->only(['search']),
|
||||||
]);
|
]);
|
||||||
@@ -63,7 +63,7 @@ public function show(Client $client, Request $request)
|
|||||||
{
|
{
|
||||||
|
|
||||||
$data = $client::query()
|
$data = $client::query()
|
||||||
->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts'])])
|
->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts', 'emails', 'client'])])
|
||||||
->findOrFail($client->id);
|
->findOrFail($client->id);
|
||||||
|
|
||||||
$types = [
|
$types = [
|
||||||
@@ -78,8 +78,7 @@ public function show(Client $client, Request $request)
|
|||||||
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
|
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
|
||||||
'person',
|
'person',
|
||||||
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
|
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
|
||||||
)
|
))
|
||||||
)
|
|
||||||
->addSelect([
|
->addSelect([
|
||||||
'active_contracts_count' => \DB::query()
|
'active_contracts_count' => \DB::query()
|
||||||
->from('contracts')
|
->from('contracts')
|
||||||
@@ -105,13 +104,77 @@ public function show(Client $client, Request $request)
|
|||||||
])
|
])
|
||||||
->where('active', 1)
|
->where('active', 1)
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->paginate(15)
|
->paginate($request->integer('perPage', 15))
|
||||||
->withQueryString(),
|
->withQueryString(),
|
||||||
'types' => $types,
|
'types' => $types,
|
||||||
'filters' => $request->only(['search']),
|
'filters' => $request->only(['search']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function contracts(Client $client, Request $request)
|
||||||
|
{
|
||||||
|
$data = $client->load(['person' => fn ($q) => $q->with(['addresses', 'phones', 'bankAccounts', 'emails'])]);
|
||||||
|
|
||||||
|
$from = $request->input('from');
|
||||||
|
$to = $request->input('to');
|
||||||
|
$search = $request->input('search');
|
||||||
|
$segmentsParam = $request->input('segments');
|
||||||
|
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
|
||||||
|
|
||||||
|
$contractsQuery = \App\Models\Contract::query()
|
||||||
|
->whereHas('clientCase', function ($q) use ($client) {
|
||||||
|
$q->where('client_id', $client->id);
|
||||||
|
})
|
||||||
|
->with([
|
||||||
|
'clientCase:id,uuid,person_id',
|
||||||
|
'clientCase.person:id,full_name',
|
||||||
|
'segments' => function ($q) {
|
||||||
|
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||||
|
},
|
||||||
|
'account:id,accounts.contract_id,balance_amount',
|
||||||
|
])
|
||||||
|
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->when($from || $to, function ($q) use ($from, $to) {
|
||||||
|
if (! empty($from)) {
|
||||||
|
$q->whereDate('start_date', '>=', $from);
|
||||||
|
}
|
||||||
|
if (! empty($to)) {
|
||||||
|
$q->whereDate('start_date', '<=', $to);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->when($search, function ($q) use ($search) {
|
||||||
|
$q->where(function ($inner) use ($search) {
|
||||||
|
$inner->where('reference', 'ilike', '%'.$search.'%')
|
||||||
|
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
||||||
|
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->when($segmentIds, function ($q) use ($segmentIds) {
|
||||||
|
$q->whereHas('segments', function ($s) use ($segmentIds) {
|
||||||
|
$s->whereIn('segments.id', $segmentIds)
|
||||||
|
->where('contract_segment.active', true);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->orderByDesc('start_date');
|
||||||
|
|
||||||
|
$segments = \App\Models\Segment::orderBy('name')->get(['id', 'name']);
|
||||||
|
|
||||||
|
$types = [
|
||||||
|
'address_types' => \App\Models\Person\AddressType::all(),
|
||||||
|
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('Client/Contracts', [
|
||||||
|
'client' => $data,
|
||||||
|
'contracts' => $contractsQuery->paginate($request->integer('perPage', 20))->withQueryString(),
|
||||||
|
'filters' => $request->only(['from', 'to', 'search', 'segments']),
|
||||||
|
'segments' => $segments,
|
||||||
|
'types' => $types,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -158,4 +221,75 @@ public function update(Client $client, Request $request)
|
|||||||
|
|
||||||
return to_route('client.show', $client);
|
return to_route('client.show', $client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emergency endpoint: if the linked person record is missing (hard deleted) or soft deleted,
|
||||||
|
* create a new minimal Person and re-point all related child records (emails, phones, addresses, bank accounts,
|
||||||
|
* client cases) from the old person_id to the new one, then update the client itself.
|
||||||
|
*/
|
||||||
|
public function emergencyCreatePerson(Client $client, Request $request)
|
||||||
|
{
|
||||||
|
$oldPersonId = $client->person_id;
|
||||||
|
|
||||||
|
// If person exists and is not trashed, abort – nothing to do
|
||||||
|
/** @var \App\Models\Person\Person|null $existing */
|
||||||
|
$existing = \App\Models\Person\Person::withTrashed()->find($oldPersonId);
|
||||||
|
if ($existing && ! $existing->trashed()) {
|
||||||
|
return redirect()->back()->with('flash', [
|
||||||
|
'type' => 'info',
|
||||||
|
'message' => 'Person already exists – emergency creation not needed.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'full_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'first_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'last_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'tax_number' => ['nullable', 'string', 'max:99'],
|
||||||
|
'social_security_number' => ['nullable', 'string', 'max:99'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Provide sensible fallbacks.
|
||||||
|
$fullName = $data['full_name'] ?? trim(($data['first_name'] ?? '').' '.($data['last_name'] ?? ''));
|
||||||
|
if ($fullName === '') {
|
||||||
|
$fullName = 'Unknown Person';
|
||||||
|
}
|
||||||
|
|
||||||
|
$newPerson = null;
|
||||||
|
|
||||||
|
\DB::transaction(function () use ($oldPersonId, $client, $fullName, $data, &$newPerson) {
|
||||||
|
$newPerson = \App\Models\Person\Person::create([
|
||||||
|
'nu' => null, // boot event will generate
|
||||||
|
'first_name' => $data['first_name'] ?? null,
|
||||||
|
'last_name' => $data['last_name'] ?? null,
|
||||||
|
'full_name' => $fullName,
|
||||||
|
'gender' => null,
|
||||||
|
'birthday' => null,
|
||||||
|
'tax_number' => $data['tax_number'] ?? null,
|
||||||
|
'social_security_number' => $data['social_security_number'] ?? null,
|
||||||
|
'description' => $data['description'] ?? 'Emergency recreated person',
|
||||||
|
'group_id' => 1,
|
||||||
|
'type_id' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Re-point related records referencing the old (missing) person id
|
||||||
|
$tables = [
|
||||||
|
'emails', 'person_phones', 'person_addresses', 'bank_accounts', 'client_cases',
|
||||||
|
];
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
\DB::table($table)->where('person_id', $oldPersonId)->update(['person_id' => $newPerson->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally update the client itself (only this one; avoid touching other potential clients)
|
||||||
|
$client->person_id = $newPerson->id;
|
||||||
|
$client->save();
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->back()->with('flash', [
|
||||||
|
'type' => 'success',
|
||||||
|
'message' => 'New person created and related records re-linked.',
|
||||||
|
'person_uuid' => $newPerson?->uuid,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ public function index()
|
|||||||
{
|
{
|
||||||
return Inertia::render('Settings/ContractConfigs/Index', [
|
return Inertia::render('Settings/ContractConfigs/Index', [
|
||||||
'configs' => ContractConfig::with(['type:id,name', 'segment:id,name'])->get(),
|
'configs' => ContractConfig::with(['type:id,name', 'segment:id,name'])->get(),
|
||||||
'types' => ContractType::query()->get(['id','name']),
|
'types' => ContractType::query()->get(['id', 'name']),
|
||||||
'segments' => Segment::query()->where('active', true)->get(['id','name']),
|
'segments' => Segment::query()->where('active', true)->get(['id', 'name']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +40,8 @@ public function store(Request $request)
|
|||||||
ContractConfig::create([
|
ContractConfig::create([
|
||||||
'contract_type_id' => $data['contract_type_id'],
|
'contract_type_id' => $data['contract_type_id'],
|
||||||
'segment_id' => $data['segment_id'],
|
'segment_id' => $data['segment_id'],
|
||||||
'is_initial' => (bool)($data['is_initial'] ?? false),
|
'is_initial' => (bool) ($data['is_initial'] ?? false),
|
||||||
'active' => (bool)($data['active'] ?? true),
|
'active' => (bool) ($data['active'] ?? true),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return back()->with('success', 'Configuration created');
|
return back()->with('success', 'Configuration created');
|
||||||
@@ -57,8 +57,8 @@ public function update(ContractConfig $config, Request $request)
|
|||||||
|
|
||||||
$config->update([
|
$config->update([
|
||||||
'segment_id' => $data['segment_id'],
|
'segment_id' => $data['segment_id'],
|
||||||
'is_initial' => (bool)($data['is_initial'] ?? $config->is_initial),
|
'is_initial' => (bool) ($data['is_initial'] ?? $config->is_initial),
|
||||||
'active' => (bool)($data['active'] ?? $config->active),
|
'active' => (bool) ($data['active'] ?? $config->active),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return back()->with('success', 'Configuration updated');
|
return back()->with('success', 'Configuration updated');
|
||||||
@@ -67,6 +67,7 @@ public function update(ContractConfig $config, Request $request)
|
|||||||
public function destroy(ContractConfig $config)
|
public function destroy(ContractConfig $config)
|
||||||
{
|
{
|
||||||
$config->delete();
|
$config->delete();
|
||||||
|
|
||||||
return back()->with('success', 'Configuration deleted');
|
return back()->with('success', 'Configuration deleted');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,26 +4,28 @@
|
|||||||
|
|
||||||
use App\Models\Contract;
|
use App\Models\Contract;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
|
||||||
class ContractController extends Controller
|
class ContractController extends Controller
|
||||||
{
|
{
|
||||||
|
public function index(Contract $contract)
|
||||||
public function index(Contract $contract) {
|
{
|
||||||
return Inertia::render('Contract/Index', [
|
return Inertia::render('Contract/Index', [
|
||||||
'contracts' => $contract::with(['type', 'debtor'])
|
'contracts' => $contract::with(['type', 'debtor'])
|
||||||
->where('active', 1)
|
->where('active', 1)
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->paginate(10),
|
->paginate(10),
|
||||||
'person_types' => \App\Models\Person\PersonType::all(['id', 'name', 'description'])
|
'person_types' => \App\Models\Person\PersonType::all(['id', 'name', 'description'])
|
||||||
->where('deleted', 0)
|
->where('deleted', 0),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Contract $contract){
|
public function show(Contract $contract)
|
||||||
|
{
|
||||||
return inertia('Contract/Show', [
|
return inertia('Contract/Show', [
|
||||||
'contract' => $contract::with(['type', 'client', 'debtor'])->findOrFail($contract->id)
|
'contract' => $contract::with(['type', 'client', 'debtor'])->findOrFail($contract->id),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,15 +35,15 @@ public function store(Request $request)
|
|||||||
|
|
||||||
$clientCase = \App\Models\ClientCase::where('uuid', $uuid)->firstOrFail();
|
$clientCase = \App\Models\ClientCase::where('uuid', $uuid)->firstOrFail();
|
||||||
|
|
||||||
if( isset($clientCase->id) ){
|
if (isset($clientCase->id)) {
|
||||||
|
|
||||||
\DB::transaction(function() use ($request, $clientCase){
|
\DB::transaction(function () use ($request, $clientCase) {
|
||||||
|
|
||||||
//Create contract
|
// Create contract
|
||||||
$clientCase->contracts()->create([
|
$clientCase->contracts()->create([
|
||||||
'reference' => $request->input('reference'),
|
'reference' => $request->input('reference'),
|
||||||
'start_date' => date('Y-m-d', strtotime($request->input('start_date'))),
|
'start_date' => date('Y-m-d', strtotime($request->input('start_date'))),
|
||||||
'type_id' => $request->input('type_id')
|
'type_id' => $request->input('type_id'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -50,12 +52,79 @@ public function store(Request $request)
|
|||||||
return to_route('clientCase.show', $clientCase);
|
return to_route('clientCase.show', $clientCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Contract $contract, Request $request){
|
public function update(Contract $contract, Request $request)
|
||||||
|
{
|
||||||
$contract->update([
|
$contract->update([
|
||||||
'referenca' => $request->input('referenca'),
|
'referenca' => $request->input('referenca'),
|
||||||
'type_id' => $request->input('type_id')
|
'type_id' => $request->input('type_id'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public function segment(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'segment_id' => ['required', 'integer', Rule::exists('segments', 'id')->where('active', true)],
|
||||||
|
'contracts' => ['required', 'array', 'min:1'],
|
||||||
|
'contracts.*' => ['string', Rule::exists('contracts', 'uuid')],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$segmentId = (int) $data['segment_id'];
|
||||||
|
$uuids = array_values($data['contracts']);
|
||||||
|
|
||||||
|
$contracts = Contract::query()
|
||||||
|
->whereIn('uuid', $uuids)
|
||||||
|
->get(['id', 'client_case_id']);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($contracts, $segmentId) {
|
||||||
|
foreach ($contracts as $contract) {
|
||||||
|
// Ensure the segment is attached to the client case and active
|
||||||
|
$attached = DB::table('client_case_segment')
|
||||||
|
->where('client_case_id', $contract->client_case_id)
|
||||||
|
->where('segment_id', $segmentId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $attached) {
|
||||||
|
DB::table('client_case_segment')->insert([
|
||||||
|
'client_case_id' => $contract->client_case_id,
|
||||||
|
'segment_id' => $segmentId,
|
||||||
|
'active' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
} elseif (! $attached->active) {
|
||||||
|
DB::table('client_case_segment')
|
||||||
|
->where('id', $attached->id)
|
||||||
|
->update(['active' => true, 'updated_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate all current contract segments
|
||||||
|
DB::table('contract_segment')
|
||||||
|
->where('contract_id', $contract->id)
|
||||||
|
->update(['active' => false, 'updated_at' => now()]);
|
||||||
|
|
||||||
|
// Activate or attach the target segment
|
||||||
|
$pivot = DB::table('contract_segment')
|
||||||
|
->where('contract_id', $contract->id)
|
||||||
|
->where('segment_id', $segmentId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($pivot) {
|
||||||
|
DB::table('contract_segment')
|
||||||
|
->where('id', $pivot->id)
|
||||||
|
->update(['active' => true, 'updated_at' => now()]);
|
||||||
|
} else {
|
||||||
|
DB::table('contract_segment')->insert([
|
||||||
|
'contract_id' => $contract->id,
|
||||||
|
'segment_id' => $segmentId,
|
||||||
|
'active' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return back()->with('success', __('Pogodbe so bile preusmerjene v izbrani segment.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
use App\Models\DocumentTemplate;
|
use App\Models\DocumentTemplate;
|
||||||
use App\Services\Documents\TokenValueResolver;
|
use App\Services\Documents\TokenValueResolver;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
@@ -16,38 +17,77 @@
|
|||||||
|
|
||||||
class ContractDocumentGenerationController extends Controller
|
class ContractDocumentGenerationController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(Request $request, Contract $contract): Response
|
public function __invoke(Request $request, Contract $contract): Response|RedirectResponse
|
||||||
{
|
{
|
||||||
|
// Inertia requests include the X-Inertia header and should receive redirects or Inertia responses, not JSON
|
||||||
|
$isInertia = (bool) $request->header('X-Inertia');
|
||||||
|
// For non-Inertia POSTs, prefer JSON responses by default (including tests)
|
||||||
|
$wantsJson = ! $isInertia;
|
||||||
if (Gate::denies('read')) { // baseline read permission required to generate
|
if (Gate::denies('read')) { // baseline read permission required to generate
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'template_slug' => ['required', 'string', 'exists:document_templates,slug'],
|
'template_slug' => ['required', 'string', 'exists:document_templates,slug'],
|
||||||
|
'template_version' => ['nullable', 'integer'],
|
||||||
|
'custom' => ['nullable', 'array'],
|
||||||
|
'custom.*' => ['nullable'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$template = DocumentTemplate::where('slug', $request->template_slug)
|
// Prefer explicitly requested version if provided and active; otherwise use latest active
|
||||||
|
$baseQuery = DocumentTemplate::query()
|
||||||
|
->where('slug', $request->template_slug)
|
||||||
->where('core_entity', 'contract')
|
->where('core_entity', 'contract')
|
||||||
->where('active', true)
|
->where('active', true);
|
||||||
->orderByDesc('version')
|
if ($request->filled('template_version')) {
|
||||||
->firstOrFail();
|
$template = (clone $baseQuery)->where('version', (int) $request->integer('template_version'))->first();
|
||||||
|
if (! $template) {
|
||||||
|
$template = (clone $baseQuery)->orderByDesc('version')->firstOrFail();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$template = $baseQuery->orderByDesc('version')->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
// Load related data minimally
|
// Load related data minimally
|
||||||
$contract->load(['clientCase.client.person', 'clientCase.person', 'clientCase.client']);
|
$contract->load(['clientCase.client.person', 'clientCase.person', 'clientCase.client']);
|
||||||
|
|
||||||
$renderer = app(\App\Services\Documents\DocxTemplateRenderer::class);
|
$renderer = app(\App\Services\Documents\DocxTemplateRenderer::class);
|
||||||
try {
|
try {
|
||||||
|
// For custom tokens: pass overrides via request bag; service already reads request()->input('custom') if present.
|
||||||
$result = $renderer->render($template, $contract, Auth::user());
|
$result = $renderer->render($template, $contract, Auth::user());
|
||||||
} catch (\App\Services\Documents\Exceptions\UnresolvedTokensException $e) {
|
} catch (\App\Services\Documents\Exceptions\UnresolvedTokensException $e) {
|
||||||
return response()->json([
|
if ($wantsJson) {
|
||||||
'status' => 'error',
|
return response()->json([
|
||||||
'message' => 'Unresolved tokens detected.',
|
'status' => 'error',
|
||||||
'tokens' => $e->unresolved ?? [],
|
'message' => 'Unresolved tokens detected.',
|
||||||
], 422);
|
'tokens' => $e->unresolved ?? [],
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return back with validation-like errors so Inertia can surface them via onError
|
||||||
|
return back()->withErrors([
|
||||||
|
'document' => 'Unresolved tokens detected.',
|
||||||
|
])->with('unresolved_tokens', $e->unresolved ?? []);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return response()->json([
|
try {
|
||||||
'status' => 'error',
|
logger()->error('ContractDocumentGenerationController generation failed', [
|
||||||
'message' => 'Generation failed.',
|
'template_id' => $template->id ?? null,
|
||||||
], 500);
|
'template_slug' => $template->slug ?? null,
|
||||||
|
'template_version' => $template->version ?? null,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $logEx) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wantsJson) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Generation failed.',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->withErrors([
|
||||||
|
'document' => 'Generation failed.',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$doc = new Document;
|
$doc = new Document;
|
||||||
@@ -108,10 +148,32 @@ public function __invoke(Request $request, Contract $contract): Response
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
if ($wantsJson) {
|
||||||
'status' => 'ok',
|
return response()->json([
|
||||||
'document_uuid' => $doc->uuid,
|
'status' => 'ok',
|
||||||
'path' => $doc->path,
|
'document_uuid' => $doc->uuid,
|
||||||
|
'path' => $doc->path,
|
||||||
|
'stats' => $result['stats'] ?? null,
|
||||||
|
'template' => [
|
||||||
|
'id' => $template->id,
|
||||||
|
'slug' => $template->slug,
|
||||||
|
'version' => $template->version,
|
||||||
|
'file_path' => $template->file_path,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash some lightweight info if needed by the UI; Inertia will GET the page after redirect
|
||||||
|
return back()->with([
|
||||||
|
'doc_generated' => [
|
||||||
|
'uuid' => $doc->uuid,
|
||||||
|
'path' => $doc->path,
|
||||||
|
'template' => [
|
||||||
|
'slug' => $template->slug,
|
||||||
|
'version' => $template->version,
|
||||||
|
],
|
||||||
|
'stats' => $result['stats'] ?? null,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,23 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Activity;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\ClientCase;
|
|
||||||
use App\Models\Document;
|
|
||||||
use App\Models\FieldJob;
|
|
||||||
use App\Models\Import; // assuming model name Import
|
|
||||||
use App\Models\Activity; // if this model exists
|
|
||||||
use App\Models\Contract;
|
use App\Models\Contract;
|
||||||
use Illuminate\Support\Carbon;
|
use App\Models\Document; // assuming model name Import
|
||||||
use Illuminate\Support\Facades\Schema;
|
use App\Models\FieldJob; // if this model exists
|
||||||
|
use App\Models\Import;
|
||||||
|
use App\Models\SmsLog;
|
||||||
|
use App\Models\SmsProfile;
|
||||||
|
use App\Services\Sms\SmsService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\CarbonPeriod;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(): Response
|
public function __invoke(SmsService $sms): Response
|
||||||
{
|
{
|
||||||
$today = now()->startOfDay();
|
$today = now()->startOfDay();
|
||||||
$yesterday = now()->subDay()->startOfDay();
|
$yesterday = now()->subDay()->startOfDay();
|
||||||
@@ -36,15 +36,15 @@ public function __invoke(): Response
|
|||||||
}
|
}
|
||||||
$documentsToday = Document::whereDate('created_at', $today)->count();
|
$documentsToday = Document::whereDate('created_at', $today)->count();
|
||||||
$activeImports = Import::whereIn('status', ['queued', 'processing'])->count();
|
$activeImports = Import::whereIn('status', ['queued', 'processing'])->count();
|
||||||
$activeContracts = Contract::where('active', 1)->count();
|
$activeContracts = Contract::where('active', 1)->count();
|
||||||
|
|
||||||
// Basic activities deferred list (limit 10)
|
// Basic activities deferred list (limit 10)
|
||||||
$activities = Activity::query()
|
$activities = Activity::query()
|
||||||
->with(['clientCase:id,uuid'])
|
->with(['clientCase:id,uuid'])
|
||||||
->latest()
|
->latest()
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get(['id','note','created_at','client_case_id','contract_id','action_id','decision_id'])
|
->get(['id', 'note', 'created_at', 'client_case_id', 'contract_id', 'action_id', 'decision_id'])
|
||||||
->map(fn($a) => [
|
->map(fn ($a) => [
|
||||||
'id' => $a->id,
|
'id' => $a->id,
|
||||||
'note' => $a->note,
|
'note' => $a->note,
|
||||||
'created_at' => $a->created_at,
|
'created_at' => $a->created_at,
|
||||||
@@ -59,78 +59,120 @@ public function __invoke(): Response
|
|||||||
$start = now()->subDays(6)->startOfDay();
|
$start = now()->subDays(6)->startOfDay();
|
||||||
$end = now()->endOfDay();
|
$end = now()->endOfDay();
|
||||||
|
|
||||||
$dateKeys = collect(range(0,6))
|
$dateKeys = collect(range(0, 6))
|
||||||
->map(fn($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
||||||
|
|
||||||
$clientTrendRaw = Client::whereBetween('created_at', [$start,$end])
|
$clientTrendRaw = Client::whereBetween('created_at', [$start, $end])
|
||||||
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
||||||
->groupBy('d')
|
->groupBy('d')
|
||||||
->pluck('c','d');
|
->pluck('c', 'd');
|
||||||
$documentTrendRaw = Document::whereBetween('created_at', [$start,$end])
|
$documentTrendRaw = Document::whereBetween('created_at', [$start, $end])
|
||||||
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
||||||
->groupBy('d')
|
->groupBy('d')
|
||||||
->pluck('c','d');
|
->pluck('c', 'd');
|
||||||
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start,$end])
|
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
||||||
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
||||||
->groupBy('d')
|
->groupBy('d')
|
||||||
->pluck('c','d');
|
->pluck('c', 'd');
|
||||||
$importTrendRaw = Import::whereBetween('created_at', [$start,$end])
|
$importTrendRaw = Import::whereBetween('created_at', [$start, $end])
|
||||||
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
||||||
->groupBy('d')
|
->groupBy('d')
|
||||||
->pluck('c','d');
|
->pluck('c', 'd');
|
||||||
|
|
||||||
// Completed field jobs last 7 days
|
// Completed field jobs last 7 days
|
||||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||||
->whereBetween('completed_at', [$start, $end])
|
->whereBetween('completed_at', [$start, $end])
|
||||||
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
|
||||||
->groupBy('d')
|
->groupBy('d')
|
||||||
->pluck('c','d');
|
->pluck('c', 'd');
|
||||||
|
|
||||||
$trends = [
|
$trends = [
|
||||||
'clients_new' => $dateKeys->map(fn($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(),
|
'clients_new' => $dateKeys->map(fn ($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(),
|
||||||
'documents_new' => $dateKeys->map(fn($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(),
|
'documents_new' => $dateKeys->map(fn ($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(),
|
||||||
'field_jobs' => $dateKeys->map(fn($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
|
'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
|
||||||
'imports_new' => $dateKeys->map(fn($d) => (int) ($importTrendRaw[$d] ?? 0))->values(),
|
'imports_new' => $dateKeys->map(fn ($d) => (int) ($importTrendRaw[$d] ?? 0))->values(),
|
||||||
'field_jobs_completed' => $dateKeys->map(fn($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
|
'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
|
||||||
'labels' => $dateKeys,
|
'labels' => $dateKeys,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Stale client cases (no activity in last 7 days)
|
// Stale client cases (no activity in last 7 days)
|
||||||
$staleCases = \App\Models\ClientCase::query()
|
$staleCases = \App\Models\ClientCase::query()
|
||||||
->leftJoin('activities', function($join) {
|
->leftJoin('activities', function ($join) {
|
||||||
$join->on('activities.client_case_id', '=', 'client_cases.id')
|
$join->on('activities.client_case_id', '=', 'client_cases.id')
|
||||||
->whereNull('activities.deleted_at');
|
->whereNull('activities.deleted_at');
|
||||||
})
|
})
|
||||||
->selectRaw('client_cases.id, client_cases.uuid, client_cases.client_ref, MAX(activities.created_at) as last_activity_at, client_cases.created_at')
|
->selectRaw('client_cases.id, client_cases.uuid, client_cases.client_ref, MAX(activities.created_at) as last_activity_at, client_cases.created_at')
|
||||||
->groupBy('client_cases.id','client_cases.uuid','client_cases.client_ref','client_cases.created_at')
|
->groupBy('client_cases.id', 'client_cases.uuid', 'client_cases.client_ref', 'client_cases.created_at')
|
||||||
->havingRaw('(MAX(activities.created_at) IS NULL OR MAX(activities.created_at) < ?) AND client_cases.created_at < ?', [$staleThreshold, $staleThreshold])
|
->havingRaw('(MAX(activities.created_at) IS NULL OR MAX(activities.created_at) < ?) AND client_cases.created_at < ?', [$staleThreshold, $staleThreshold])
|
||||||
->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC')
|
->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get()
|
->get()
|
||||||
->map(fn($c) => [
|
->map(function ($c) {
|
||||||
'id' => $c->id,
|
// Reference point: last activity if exists, else creation.
|
||||||
'uuid' => $c->uuid,
|
$reference = $c->last_activity_at ? \Illuminate\Support\Carbon::parse($c->last_activity_at) : $c->created_at;
|
||||||
'client_ref' => $c->client_ref,
|
// Use minute precision to avoid jumping to 1 too early (e.g. created just before midnight).
|
||||||
'last_activity_at' => $c->last_activity_at,
|
$minutes = $reference ? max(0, $reference->diffInMinutes(now())) : 0;
|
||||||
'created_at' => $c->created_at,
|
$daysFraction = $minutes / 1440; // 60 * 24
|
||||||
'days_stale' => $c->last_activity_at ? now()->diffInDays($c->last_activity_at) : now()->diffInDays($c->created_at),
|
// Provide both fractional and integer versions (integer preserved for backwards compatibility if needed)
|
||||||
]);
|
$daysInteger = (int) floor($daysFraction);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $c->id,
|
||||||
|
'uuid' => $c->uuid,
|
||||||
|
'client_ref' => $c->client_ref,
|
||||||
|
'last_activity_at' => $c->last_activity_at,
|
||||||
|
'created_at' => $c->created_at,
|
||||||
|
'days_without_activity' => round($daysFraction, 4), // fractional for finer UI decision (<1 day)
|
||||||
|
'days_stale' => $daysInteger, // legacy key (integer)
|
||||||
|
'has_activity' => (bool) $c->last_activity_at,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
// Field jobs assigned today
|
// Field jobs assigned today
|
||||||
$fieldJobsAssignedToday = FieldJob::query()
|
$fieldJobsAssignedToday = FieldJob::query()
|
||||||
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
||||||
->select(['id','assigned_user_id','priority','assigned_at','created_at','contract_id'])
|
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
||||||
|
->with(['contract' => function ($q) {
|
||||||
|
$q->select('id', 'uuid', 'reference', 'client_case_id')
|
||||||
|
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
|
||||||
|
}])
|
||||||
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
|
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
|
||||||
->limit(15)
|
->limit(15)
|
||||||
->get();
|
->get()
|
||||||
|
->map(function ($fj) {
|
||||||
|
$contract = $fj->contract;
|
||||||
|
$segmentId = null;
|
||||||
|
if ($contract && method_exists($contract, 'segments')) {
|
||||||
|
// Determine active segment via pivot active flag if present
|
||||||
|
$activeSeg = $contract->segments->first();
|
||||||
|
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
|
||||||
|
$segmentId = $activeSeg->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $fj->id,
|
||||||
|
'priority' => $fj->priority,
|
||||||
|
// Normalize to ISO8601 strings so FE retains timezone & time component
|
||||||
|
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
||||||
|
'created_at' => $fj->created_at?->toIso8601String(),
|
||||||
|
'contract' => $contract ? [
|
||||||
|
'uuid' => $contract->uuid,
|
||||||
|
'reference' => $contract->reference,
|
||||||
|
'client_case_uuid' => optional($contract->clientCase)->uuid,
|
||||||
|
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
|
||||||
|
'segment_id' => $segmentId,
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
// Imports in progress (queued / processing)
|
// Imports in progress (queued / processing)
|
||||||
$importsInProgress = Import::query()
|
$importsInProgress = Import::query()
|
||||||
->whereIn('status', ['queued','processing'])
|
->whereIn('status', ['queued', 'processing'])
|
||||||
->latest('created_at')
|
->latest('created_at')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get(['id','uuid','file_name','status','total_rows','imported_rows','valid_rows','invalid_rows','started_at'])
|
->get(['id', 'uuid', 'file_name', 'status', 'total_rows', 'imported_rows', 'valid_rows', 'invalid_rows', 'started_at'])
|
||||||
->map(fn($i) => [
|
->map(fn ($i) => [
|
||||||
'id' => $i->id,
|
'id' => $i->id,
|
||||||
'uuid' => $i->uuid,
|
'uuid' => $i->uuid,
|
||||||
'file_name' => $i->file_name,
|
'file_name' => $i->file_name,
|
||||||
@@ -139,7 +181,7 @@ public function __invoke(): Response
|
|||||||
'imported_rows' => $i->imported_rows,
|
'imported_rows' => $i->imported_rows,
|
||||||
'valid_rows' => $i->valid_rows,
|
'valid_rows' => $i->valid_rows,
|
||||||
'invalid_rows' => $i->invalid_rows,
|
'invalid_rows' => $i->invalid_rows,
|
||||||
'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1,$i->total_rows))*100,1) : null,
|
'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1, $i->total_rows)) * 100, 1) : null,
|
||||||
'started_at' => $i->started_at,
|
'started_at' => $i->started_at,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -148,7 +190,7 @@ public function __invoke(): Response
|
|||||||
->where('active', true)
|
->where('active', true)
|
||||||
->latest('updated_at')
|
->latest('updated_at')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get(['id','name','slug','version','updated_at']);
|
->get(['id', 'name', 'slug', 'version', 'updated_at']);
|
||||||
|
|
||||||
// System health (deferred)
|
// System health (deferred)
|
||||||
$queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null;
|
$queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null;
|
||||||
@@ -184,6 +226,52 @@ public function __invoke(): Response
|
|||||||
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
|
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
|
||||||
'importsInProgress' => fn () => $importsInProgress,
|
'importsInProgress' => fn () => $importsInProgress,
|
||||||
'activeTemplates' => fn () => $activeTemplates,
|
'activeTemplates' => fn () => $activeTemplates,
|
||||||
|
'smsStats' => function () use ($sms, $today) {
|
||||||
|
// Aggregate counts per profile for today
|
||||||
|
$counts = SmsLog::query()
|
||||||
|
->whereDate('created_at', $today)
|
||||||
|
->selectRaw('profile_id, status, COUNT(*) as c')
|
||||||
|
->groupBy('profile_id', 'status')
|
||||||
|
->get()
|
||||||
|
->groupBy('profile_id')
|
||||||
|
->map(function ($rows) {
|
||||||
|
$map = [
|
||||||
|
'queued' => 0,
|
||||||
|
'sent' => 0,
|
||||||
|
'delivered' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$map[$r->status] = (int) $r->c;
|
||||||
|
}
|
||||||
|
$map['total'] = array_sum($map);
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Important: include credential fields so provider calls have proper credentials
|
||||||
|
$profiles = SmsProfile::query()
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
|
||||||
|
|
||||||
|
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
|
||||||
|
// Provider balance may fail; guard and present a placeholder.
|
||||||
|
try {
|
||||||
|
$balance = $sms->getCreditBalance($p);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$balance = '—';
|
||||||
|
}
|
||||||
|
$c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $p->id,
|
||||||
|
'name' => $p->name,
|
||||||
|
'active' => (bool) $p->active,
|
||||||
|
'balance' => $balance,
|
||||||
|
'today' => $c,
|
||||||
|
];
|
||||||
|
})->values();
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class DebtController extends Controller
|
class DebtController extends Controller
|
||||||
{
|
{
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -132,6 +132,77 @@ public function assign(Request $request)
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk assign multiple contracts to a single user.
|
||||||
|
*/
|
||||||
|
public function assignBulk(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'contract_uuids' => 'required|array|min:1',
|
||||||
|
'contract_uuids.*' => 'required|string|distinct|exists:contracts,uuid',
|
||||||
|
'assigned_user_id' => 'required|integer|exists:users,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::transaction(function () use ($data) {
|
||||||
|
$setting = FieldJobSetting::query()->latest('id')->first();
|
||||||
|
|
||||||
|
if (! $setting) {
|
||||||
|
throw new Exception('No Field Job Setting found. Create one in Settings → Field Job Settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! ($setting->action_id && $setting->assign_decision_id)) {
|
||||||
|
throw new Exception('The current Field Job Setting is missing an action or assign decision. Please update it in Settings → Field Job Settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$assigneeName = User::query()->where('id', $data['assigned_user_id'])->value('name');
|
||||||
|
$noteBase = 'Terensko opravilo dodeljeno'.($assigneeName ? ' uporabniku '.$assigneeName : '');
|
||||||
|
|
||||||
|
// Load all contracts in one query
|
||||||
|
$contracts = Contract::query()->whereIn('uuid', $data['contract_uuids'])->get();
|
||||||
|
|
||||||
|
foreach ($contracts as $contract) {
|
||||||
|
// Skip if already has an active job
|
||||||
|
$hasActive = FieldJob::query()
|
||||||
|
->where('contract_id', $contract->id)
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($hasActive) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = FieldJob::create([
|
||||||
|
'field_job_setting_id' => $setting->id,
|
||||||
|
'assigned_user_id' => $data['assigned_user_id'],
|
||||||
|
'contract_id' => $contract->id,
|
||||||
|
'assigned_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Activity::create([
|
||||||
|
'due_date' => null,
|
||||||
|
'amount' => null,
|
||||||
|
'note' => $noteBase,
|
||||||
|
'action_id' => $setting->action_id,
|
||||||
|
'decision_id' => $setting->assign_decision_id,
|
||||||
|
'client_case_id' => $contract->client_case_id,
|
||||||
|
'contract_id' => $contract->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Move contract to the configured segment for field jobs
|
||||||
|
$job->moveContractToSegment($setting->segment_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return back()->with('success', 'Field jobs assigned.');
|
||||||
|
} catch (QueryException $e) {
|
||||||
|
return back()->withErrors(['database' => 'Database error: '.$e->getMessage()]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return back()->withErrors(['error' => 'Error: '.$e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function cancel(Request $request)
|
public function cancel(Request $request)
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ public function store(Request $request)
|
|||||||
'size' => $file->getSize(),
|
'size' => $file->getSize(),
|
||||||
'sheet_name' => $validated['sheet_name'] ?? null,
|
'sheet_name' => $validated['sheet_name'] ?? null,
|
||||||
'status' => 'uploaded',
|
'status' => 'uploaded',
|
||||||
|
'show_missing' => false,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'has_header' => $validated['has_header'] ?? true,
|
'has_header' => $validated['has_header'] ?? true,
|
||||||
],
|
],
|
||||||
@@ -155,6 +156,7 @@ public function store(Request $request)
|
|||||||
'id' => $import->id,
|
'id' => $import->id,
|
||||||
'uuid' => $import->uuid,
|
'uuid' => $import->uuid,
|
||||||
'status' => $import->status,
|
'status' => $import->status,
|
||||||
|
'show_missing' => (bool) ($import->show_missing ?? false),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +356,125 @@ public function getMappings(Import $import)
|
|||||||
return response()->json(['mappings' => $rows]);
|
return response()->json(['mappings' => $rows]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List active, non-archived contracts for the import's client that are NOT present
|
||||||
|
* in the processed import file (based on mapped contract.reference values).
|
||||||
|
* Only available when contract.reference mapping apply_mode is 'keyref'.
|
||||||
|
*/
|
||||||
|
public function missingContracts(Import $import)
|
||||||
|
{
|
||||||
|
// Ensure client context is available
|
||||||
|
if (empty($import->client_id)) {
|
||||||
|
return response()->json(['error' => 'Import has no client bound.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect optional feature flag on import
|
||||||
|
if (! (bool) ($import->show_missing ?? false)) {
|
||||||
|
return response()->json(['error' => 'Missing contracts listing is disabled for this import.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that this import's mappings set contract.reference to keyref mode
|
||||||
|
$mappings = \DB::table('import_mappings')
|
||||||
|
->where('import_id', $import->id)
|
||||||
|
->get(['target_field', 'apply_mode']);
|
||||||
|
$isKeyref = false;
|
||||||
|
foreach ($mappings as $map) {
|
||||||
|
$tf = strtolower((string) ($map->target_field ?? ''));
|
||||||
|
$am = strtolower((string) ($map->apply_mode ?? ''));
|
||||||
|
if (in_array($tf, ['contract.reference', 'contracts.reference'], true) && $am === 'keyref') {
|
||||||
|
$isKeyref = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! $isKeyref) {
|
||||||
|
return response()->json(['error' => 'Missing contracts are only available for keyref mapping on contract.reference.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect referenced contract references from processed rows
|
||||||
|
$present = [];
|
||||||
|
foreach (\App\Models\ImportRow::query()->where('import_id', $import->id)->get(['mapped_data']) as $row) {
|
||||||
|
$md = $row->mapped_data ?? [];
|
||||||
|
if (is_array($md) && isset($md['contract']['reference'])) {
|
||||||
|
$ref = (string) $md['contract']['reference'];
|
||||||
|
if ($ref !== '') {
|
||||||
|
$present[] = preg_replace('/\s+/', '', trim($ref));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$present = array_values(array_unique(array_filter($present)));
|
||||||
|
|
||||||
|
// Query active, non-archived contracts for this client that were not in import
|
||||||
|
// Include person full_name (owner of the client case) and aggregate active accounts' balance_amount
|
||||||
|
$contractsQ = \App\Models\Contract::query()
|
||||||
|
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||||
|
->join('person', 'person.id', '=', 'client_cases.person_id')
|
||||||
|
->leftJoin('accounts', function ($join) {
|
||||||
|
$join->on('accounts.contract_id', '=', 'contracts.id')
|
||||||
|
->where('accounts.active', 1);
|
||||||
|
})
|
||||||
|
->where('client_cases.client_id', $import->client_id)
|
||||||
|
->where('contracts.active', 1)
|
||||||
|
->whereNull('contracts.deleted_at')
|
||||||
|
// Exclude contracts that have any ACTIVE segment marked as excluded
|
||||||
|
->whereNotExists(function ($sq) {
|
||||||
|
$sq->select(\DB::raw(1))
|
||||||
|
->from('contract_segment')
|
||||||
|
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
|
||||||
|
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||||
|
->where('contract_segment.active', true)
|
||||||
|
->where('segments.exclude', true);
|
||||||
|
})
|
||||||
|
->when(count($present) > 0, function ($q) use ($present) {
|
||||||
|
$q->whereNotIn('contracts.reference', $present);
|
||||||
|
})
|
||||||
|
->groupBy('contracts.uuid', 'contracts.reference', 'client_cases.uuid', 'person.full_name')
|
||||||
|
->orderBy('contracts.reference')
|
||||||
|
->get([
|
||||||
|
'contracts.uuid as uuid',
|
||||||
|
'contracts.reference as reference',
|
||||||
|
'client_cases.uuid as case_uuid',
|
||||||
|
'person.full_name as full_name',
|
||||||
|
\DB::raw('COALESCE(SUM(accounts.balance_amount), 0) as balance_amount'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'missing' => $contractsQ,
|
||||||
|
'count' => $contractsQ->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update import options (e.g., booleans like show_missing, reactivate) from the UI.
|
||||||
|
*/
|
||||||
|
public function updateOptions(Request $request, Import $import)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'show_missing' => 'nullable|boolean',
|
||||||
|
'reactivate' => 'nullable|boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = [];
|
||||||
|
if (array_key_exists('show_missing', $data)) {
|
||||||
|
$payload['show_missing'] = (bool) $data['show_missing'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('reactivate', $data)) {
|
||||||
|
$payload['reactivate'] = (bool) $data['reactivate'];
|
||||||
|
}
|
||||||
|
if (! empty($payload)) {
|
||||||
|
$import->update($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'import' => [
|
||||||
|
'id' => $import->id,
|
||||||
|
'uuid' => $import->uuid,
|
||||||
|
'show_missing' => (bool) ($import->show_missing ?? false),
|
||||||
|
'reactivate' => (bool) ($import->reactivate ?? false),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch recent import events (logs) for an import
|
// Fetch recent import events (logs) for an import
|
||||||
public function getEvents(Import $import)
|
public function getEvents(Import $import)
|
||||||
{
|
{
|
||||||
@@ -368,6 +489,90 @@ public function getEvents(Import $import)
|
|||||||
return response()->json(['events' => $events]);
|
return response()->json(['events' => $events]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List unresolved keyref contract rows (based on events containing keyref-not-found)
|
||||||
|
public function missingKeyrefRows(Import $import)
|
||||||
|
{
|
||||||
|
// Identify row IDs from events. Prefer specific event key, fallback to message pattern
|
||||||
|
$rowIds = \App\Models\ImportEvent::query()
|
||||||
|
->where('import_id', $import->id)
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->where('event', 'contract_keyref_not_found')
|
||||||
|
->orWhereRaw('LOWER(message) LIKE ?', ['%keyref%not found%']);
|
||||||
|
})
|
||||||
|
->whereNotNull('import_row_id')
|
||||||
|
->pluck('import_row_id')
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($rowIds->isEmpty()) {
|
||||||
|
return response()->json([
|
||||||
|
'columns' => (array) ($import->meta['columns'] ?? []),
|
||||||
|
'rows' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = \App\Models\ImportRow::query()
|
||||||
|
->where('import_id', $import->id)
|
||||||
|
->whereIn('id', $rowIds)
|
||||||
|
->orderBy('row_number')
|
||||||
|
->get(['id', 'row_number', 'raw_data']);
|
||||||
|
|
||||||
|
$columns = (array) ($import->meta['columns'] ?? []);
|
||||||
|
// If no stored header, derive from first row raw_data keys
|
||||||
|
if (empty($columns)) {
|
||||||
|
$first = $rows->first();
|
||||||
|
if ($first && is_array($first->raw_data)) {
|
||||||
|
$columns = array_keys($first->raw_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize each row to ordered array by $columns
|
||||||
|
$dataRows = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$line = [];
|
||||||
|
foreach ($columns as $col) {
|
||||||
|
$line[] = (string) ($r->raw_data[$col] ?? '');
|
||||||
|
}
|
||||||
|
$dataRows[] = [
|
||||||
|
'id' => $r->id,
|
||||||
|
'row_number' => $r->row_number,
|
||||||
|
'values' => $line,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'columns' => $columns,
|
||||||
|
'rows' => $dataRows,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export unresolved keyref rows as CSV (includes header if available)
|
||||||
|
public function exportMissingKeyrefCsv(Import $import)
|
||||||
|
{
|
||||||
|
$json = $this->missingKeyrefRows($import)->getData(true);
|
||||||
|
$columns = $json['columns'] ?? [];
|
||||||
|
$rows = $json['rows'] ?? [];
|
||||||
|
|
||||||
|
$fh = fopen('php://temp', 'r+');
|
||||||
|
if (! empty($columns)) {
|
||||||
|
fputcsv($fh, $columns);
|
||||||
|
}
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
fputcsv($fh, $r['values'] ?? []);
|
||||||
|
}
|
||||||
|
rewind($fh);
|
||||||
|
$csv = stream_get_contents($fh);
|
||||||
|
fclose($fh);
|
||||||
|
|
||||||
|
$filename = 'missing-keyref-rows-'.$import->id.'.csv';
|
||||||
|
|
||||||
|
return response($csv, 200, [
|
||||||
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||||
|
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Preview (up to N) raw CSV rows for an import for mapping review
|
// Preview (up to N) raw CSV rows for an import for mapping review
|
||||||
public function preview(Import $import, Request $request)
|
public function preview(Import $import, Request $request)
|
||||||
{
|
{
|
||||||
@@ -533,6 +738,8 @@ public function show(Import $import)
|
|||||||
'client_id' => $import->client_id,
|
'client_id' => $import->client_id,
|
||||||
'client_uuid' => optional($client)->uuid,
|
'client_uuid' => optional($client)->uuid,
|
||||||
'import_template_id' => $import->import_template_id,
|
'import_template_id' => $import->import_template_id,
|
||||||
|
'show_missing' => (bool) ($import->show_missing ?? false),
|
||||||
|
'reactivate' => (bool) ($import->reactivate ?? false),
|
||||||
'total_rows' => $import->total_rows,
|
'total_rows' => $import->total_rows,
|
||||||
'imported_rows' => $import->imported_rows,
|
'imported_rows' => $import->imported_rows,
|
||||||
'invalid_rows' => $import->invalid_rows,
|
'invalid_rows' => $import->invalid_rows,
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ public function index()
|
|||||||
'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
|
'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
|
||||||
'ui' => ['order' => 6],
|
'ui' => ['order' => 6],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'key' => 'activities',
|
||||||
|
'canonical_root' => 'activity',
|
||||||
|
'label' => 'Activities',
|
||||||
|
'fields' => ['note', 'due_date', 'amount', 'action_id', 'decision_id', 'contract_id', 'client_case_id', 'user_id'],
|
||||||
|
'ui' => ['order' => 7],
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
// Ensure fields are arrays for frontend consumption
|
// Ensure fields are arrays for frontend consumption
|
||||||
|
|||||||
@@ -111,10 +111,10 @@ public function store(Request $request)
|
|||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'reactivate' => 'boolean',
|
'reactivate' => 'boolean',
|
||||||
'entities' => 'nullable|array',
|
'entities' => 'nullable|array',
|
||||||
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
|
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
|
||||||
'mappings' => 'array',
|
'mappings' => 'array',
|
||||||
'mappings.*.source_column' => 'required|string',
|
'mappings.*.source_column' => 'required|string',
|
||||||
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
|
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
|
||||||
'mappings.*.target_field' => 'nullable|string',
|
'mappings.*.target_field' => 'nullable|string',
|
||||||
'mappings.*.transform' => 'nullable|string|max:50',
|
'mappings.*.transform' => 'nullable|string|max:50',
|
||||||
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||||
@@ -124,7 +124,11 @@ public function store(Request $request)
|
|||||||
'meta.segment_id' => 'nullable|integer|exists:segments,id',
|
'meta.segment_id' => 'nullable|integer|exists:segments,id',
|
||||||
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
|
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
|
||||||
'meta.action_id' => 'nullable|integer|exists:actions,id',
|
'meta.action_id' => 'nullable|integer|exists:actions,id',
|
||||||
|
'meta.activity_action_id' => 'nullable|integer|exists:actions,id',
|
||||||
|
'meta.activity_decision_id' => 'nullable|integer|exists:decisions,id',
|
||||||
|
'meta.activity_created_at' => 'nullable|date',
|
||||||
'meta.payments_import' => 'nullable|boolean',
|
'meta.payments_import' => 'nullable|boolean',
|
||||||
|
'meta.history_import' => 'nullable|boolean',
|
||||||
'meta.contract_key_mode' => 'nullable|string|in:reference',
|
'meta.contract_key_mode' => 'nullable|string|in:reference',
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
@@ -142,7 +146,28 @@ public function store(Request $request)
|
|||||||
$template = null;
|
$template = null;
|
||||||
DB::transaction(function () use (&$template, $request, $data) {
|
DB::transaction(function () use (&$template, $request, $data) {
|
||||||
$paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false);
|
$paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false);
|
||||||
|
$historyImport = (bool) (data_get($data, 'meta.history_import') ?? false);
|
||||||
$entities = $data['entities'] ?? [];
|
$entities = $data['entities'] ?? [];
|
||||||
|
if ($historyImport) {
|
||||||
|
$paymentsImport = false; // history import cannot be combined with payments mode
|
||||||
|
$allowedHistoryEntities = ['person', 'person_addresses', 'person_phones', 'contracts', 'activities', 'client_cases'];
|
||||||
|
$entities = array_values(array_intersect($entities, $allowedHistoryEntities));
|
||||||
|
// If contracts are present, ensure accounts are included implicitly for reference consistency
|
||||||
|
if (in_array('contracts', $entities, true) && ! in_array('accounts', $entities, true)) {
|
||||||
|
$entities[] = 'accounts';
|
||||||
|
}
|
||||||
|
// Reject mappings that target disallowed entities for history import
|
||||||
|
$disallowedMappings = collect($data['mappings'] ?? [])->filter(function ($m) use ($allowedHistoryEntities) {
|
||||||
|
if (empty($m['entity'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! in_array($m['entity'], $allowedHistoryEntities, true);
|
||||||
|
});
|
||||||
|
if ($disallowedMappings->isNotEmpty()) {
|
||||||
|
abort(422, 'History import only allows entities: person, person_addresses, person_phones, contracts, activities, client_cases. Remove other mapping entities.');
|
||||||
|
}
|
||||||
|
}
|
||||||
if ($paymentsImport) {
|
if ($paymentsImport) {
|
||||||
$entities = ['contracts', 'accounts', 'payments'];
|
$entities = ['contracts', 'accounts', 'payments'];
|
||||||
}
|
}
|
||||||
@@ -162,7 +187,11 @@ public function store(Request $request)
|
|||||||
'segment_id' => data_get($data, 'meta.segment_id'),
|
'segment_id' => data_get($data, 'meta.segment_id'),
|
||||||
'decision_id' => data_get($data, 'meta.decision_id'),
|
'decision_id' => data_get($data, 'meta.decision_id'),
|
||||||
'action_id' => data_get($data, 'meta.action_id'),
|
'action_id' => data_get($data, 'meta.action_id'),
|
||||||
|
'activity_action_id' => data_get($data, 'meta.activity_action_id'),
|
||||||
|
'activity_decision_id' => data_get($data, 'meta.activity_decision_id'),
|
||||||
|
'activity_created_at' => data_get($data, 'meta.activity_created_at'),
|
||||||
'payments_import' => $paymentsImport ?: null,
|
'payments_import' => $paymentsImport ?: null,
|
||||||
|
'history_import' => $historyImport ?: null,
|
||||||
'contract_key_mode' => data_get($data, 'meta.contract_key_mode'),
|
'contract_key_mode' => data_get($data, 'meta.contract_key_mode'),
|
||||||
], fn ($v) => ! is_null($v) && $v !== ''),
|
], fn ($v) => ! is_null($v) && $v !== ''),
|
||||||
]);
|
]);
|
||||||
@@ -244,7 +273,7 @@ public function addMapping(Request $request, ImportTemplate $template)
|
|||||||
}
|
}
|
||||||
$data = validator($raw, [
|
$data = validator($raw, [
|
||||||
'source_column' => 'required|string',
|
'source_column' => 'required|string',
|
||||||
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
|
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
|
||||||
'target_field' => 'nullable|string',
|
'target_field' => 'nullable|string',
|
||||||
'transform' => 'nullable|string|in:trim,upper,lower',
|
'transform' => 'nullable|string|in:trim,upper,lower',
|
||||||
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||||
@@ -314,7 +343,11 @@ public function update(Request $request, ImportTemplate $template)
|
|||||||
'meta.segment_id' => 'nullable|integer|exists:segments,id',
|
'meta.segment_id' => 'nullable|integer|exists:segments,id',
|
||||||
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
|
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
|
||||||
'meta.action_id' => 'nullable|integer|exists:actions,id',
|
'meta.action_id' => 'nullable|integer|exists:actions,id',
|
||||||
|
'meta.activity_action_id' => 'nullable|integer|exists:actions,id',
|
||||||
|
'meta.activity_decision_id' => 'nullable|integer|exists:decisions,id',
|
||||||
|
'meta.activity_created_at' => 'nullable|date',
|
||||||
'meta.payments_import' => 'nullable|boolean',
|
'meta.payments_import' => 'nullable|boolean',
|
||||||
|
'meta.history_import' => 'nullable|boolean',
|
||||||
'meta.contract_key_mode' => 'nullable|string|in:reference',
|
'meta.contract_key_mode' => 'nullable|string|in:reference',
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
@@ -342,6 +375,11 @@ public function update(Request $request, ImportTemplate $template)
|
|||||||
unset($newMeta[$k]);
|
unset($newMeta[$k]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
foreach (['activity_action_id', 'activity_decision_id', 'activity_created_at'] as $k) {
|
||||||
|
if (array_key_exists($k, $newMeta) && ($newMeta[$k] === '' || is_null($newMeta[$k]))) {
|
||||||
|
unset($newMeta[$k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalize meta (ensure payments entities forced if enabled)
|
// Finalize meta (ensure payments entities forced if enabled)
|
||||||
@@ -349,6 +387,20 @@ public function update(Request $request, ImportTemplate $template)
|
|||||||
if (! empty($finalMeta['payments_import'])) {
|
if (! empty($finalMeta['payments_import'])) {
|
||||||
$finalMeta['entities'] = ['contracts', 'accounts', 'payments'];
|
$finalMeta['entities'] = ['contracts', 'accounts', 'payments'];
|
||||||
}
|
}
|
||||||
|
if (! empty($finalMeta['history_import'])) {
|
||||||
|
$finalMeta['payments_import'] = false;
|
||||||
|
$allowedHistoryEntities = ['person', 'person_addresses', 'person_phones', 'contracts', 'activities', 'client_cases'];
|
||||||
|
$finalMeta['entities'] = array_values(array_intersect($finalMeta['entities'] ?? [], $allowedHistoryEntities));
|
||||||
|
if (in_array('contracts', $finalMeta['entities'] ?? [], true) && ! in_array('accounts', $finalMeta['entities'] ?? [], true)) {
|
||||||
|
$finalMeta['entities'][] = 'accounts';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('activities', $finalMeta['entities'] ?? [], true)) {
|
||||||
|
if (empty($finalMeta['activity_action_id']) || empty($finalMeta['activity_decision_id'])) {
|
||||||
|
return back()->withErrors(['meta.activity_action_id' => 'Activities import requires selecting both a default action and decision.'])->withInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$update = [
|
$update = [
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
@@ -381,10 +433,12 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
|
|||||||
}
|
}
|
||||||
$data = validator($raw, [
|
$data = validator($raw, [
|
||||||
'sources' => 'required|string', // comma and/or newline separated
|
'sources' => 'required|string', // comma and/or newline separated
|
||||||
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
|
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
|
||||||
'default_field' => 'nullable|string', // if provided, used as the field name for all entries
|
'default_field' => 'nullable|string', // if provided, used as the field name for all entries
|
||||||
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||||
'transform' => 'nullable|string|in:trim,upper,lower',
|
'transform' => 'nullable|string|in:trim,upper,lower',
|
||||||
|
'options' => 'nullable|array',
|
||||||
|
'group' => 'nullable|string|max:50', // convenience: will be wrapped into options.group
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
// Accept commas, semicolons, and newlines; strip surrounding quotes/apostrophes and whitespace
|
// Accept commas, semicolons, and newlines; strip surrounding quotes/apostrophes and whitespace
|
||||||
@@ -408,9 +462,18 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
|
|||||||
$entity = $data['entity'] ?? null;
|
$entity = $data['entity'] ?? null;
|
||||||
$defaultField = $data['default_field'] ?? null; // allows forcing a specific field for all
|
$defaultField = $data['default_field'] ?? null; // allows forcing a specific field for all
|
||||||
|
|
||||||
|
// Build options payload once
|
||||||
|
$opts = [];
|
||||||
|
if (isset($data['options']) && is_array($data['options'])) {
|
||||||
|
$opts = $data['options'];
|
||||||
|
}
|
||||||
|
if (! empty($data['group'])) {
|
||||||
|
$opts['group'] = (string) $data['group'];
|
||||||
|
}
|
||||||
|
|
||||||
$created = 0;
|
$created = 0;
|
||||||
$updated = 0;
|
$updated = 0;
|
||||||
DB::transaction(function () use ($template, $list, $apply, $transform, $entity, $defaultField, $basePosition, &$created, &$updated) {
|
DB::transaction(function () use ($template, $list, $apply, $transform, $entity, $defaultField, $basePosition, $opts, &$created, &$updated) {
|
||||||
foreach ($list as $idx => $source) {
|
foreach ($list as $idx => $source) {
|
||||||
$targetField = null;
|
$targetField = null;
|
||||||
if ($defaultField) {
|
if ($defaultField) {
|
||||||
@@ -429,7 +492,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
|
|||||||
'entity' => $entity ?? $existing->entity,
|
'entity' => $entity ?? $existing->entity,
|
||||||
'transform' => $transform ?? $existing->transform,
|
'transform' => $transform ?? $existing->transform,
|
||||||
'apply_mode' => $apply ?? $existing->apply_mode ?? 'both',
|
'apply_mode' => $apply ?? $existing->apply_mode ?? 'both',
|
||||||
'options' => $existing->options,
|
'options' => empty($opts) ? $existing->options : $opts,
|
||||||
// keep existing position
|
// keep existing position
|
||||||
]);
|
]);
|
||||||
$updated++;
|
$updated++;
|
||||||
@@ -441,7 +504,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
|
|||||||
'target_field' => $targetField,
|
'target_field' => $targetField,
|
||||||
'transform' => $transform,
|
'transform' => $transform,
|
||||||
'apply_mode' => $apply,
|
'apply_mode' => $apply,
|
||||||
'options' => null,
|
'options' => empty($opts) ? null : $opts,
|
||||||
'position' => $basePosition + $idx + 1,
|
'position' => $basePosition + $idx + 1,
|
||||||
]);
|
]);
|
||||||
$created++;
|
$created++;
|
||||||
@@ -477,7 +540,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
|
|||||||
}
|
}
|
||||||
$data = validator($raw, [
|
$data = validator($raw, [
|
||||||
'source_column' => 'required|string',
|
'source_column' => 'required|string',
|
||||||
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
|
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments',
|
||||||
'target_field' => 'nullable|string',
|
'target_field' => 'nullable|string',
|
||||||
'transform' => 'nullable|string|in:trim,upper,lower',
|
'transform' => 'nullable|string|in:trim,upper,lower',
|
||||||
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||||
@@ -546,6 +609,10 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
|
|||||||
|
|
||||||
$rows = $template->mappings()->orderBy('position')->get();
|
$rows = $template->mappings()->orderBy('position')->get();
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
$options = $row->options;
|
||||||
|
if (is_array($options) || $options instanceof \JsonSerializable || $options instanceof \stdClass) {
|
||||||
|
$options = json_encode($options);
|
||||||
|
}
|
||||||
\DB::table('import_mappings')->insert([
|
\DB::table('import_mappings')->insert([
|
||||||
'import_id' => $import->id,
|
'import_id' => $import->id,
|
||||||
'entity' => $row->entity,
|
'entity' => $row->entity,
|
||||||
@@ -553,7 +620,7 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
|
|||||||
'target_field' => $row->target_field,
|
'target_field' => $row->target_field,
|
||||||
'transform' => $row->transform,
|
'transform' => $row->transform,
|
||||||
'apply_mode' => $row->apply_mode ?? 'both',
|
'apply_mode' => $row->apply_mode ?? 'both',
|
||||||
'options' => $row->options,
|
'options' => $options,
|
||||||
'position' => $row->position ?? null,
|
'position' => $row->position ?? null,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
@@ -568,6 +635,9 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
|
|||||||
'segment_id' => $tplMeta['segment_id'] ?? null,
|
'segment_id' => $tplMeta['segment_id'] ?? null,
|
||||||
'decision_id' => $tplMeta['decision_id'] ?? null,
|
'decision_id' => $tplMeta['decision_id'] ?? null,
|
||||||
'action_id' => $tplMeta['action_id'] ?? null,
|
'action_id' => $tplMeta['action_id'] ?? null,
|
||||||
|
'activity_action_id' => $tplMeta['activity_action_id'] ?? null,
|
||||||
|
'activity_decision_id' => $tplMeta['activity_decision_id'] ?? null,
|
||||||
|
'activity_created_at' => $tplMeta['activity_created_at'] ?? null,
|
||||||
'template_name' => $template->name,
|
'template_name' => $template->name,
|
||||||
], fn ($v) => ! is_null($v) && $v !== ''));
|
], fn ($v) => ! is_null($v) && $v !== ''));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Activity;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\ClientCase;
|
||||||
|
use App\Models\Contract;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class NotificationController extends Controller
|
||||||
|
{
|
||||||
|
public function unread(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (! $user) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$today = now()->toDateString();
|
||||||
|
$perPage = max(1, min(100, (int) $request->integer('perPage', 15)));
|
||||||
|
$search = trim((string) $request->input('search', ''));
|
||||||
|
$clientUuid = trim((string) $request->input('client', ''));
|
||||||
|
$clientId = null;
|
||||||
|
$clientCaseIdsForFilter = collect();
|
||||||
|
if ($clientUuid !== '') {
|
||||||
|
$clientId = Client::query()->where('uuid', $clientUuid)->value('id');
|
||||||
|
if ($clientId) {
|
||||||
|
$clientCaseIdsForFilter = ClientCase::query()->where('client_id', $clientId)->pluck('id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = Activity::query()
|
||||||
|
->select(['id', 'due_date', 'amount', 'contract_id', 'client_case_id', 'created_at'])
|
||||||
|
->whereNotNull('due_date')
|
||||||
|
->whereDate('due_date', '<=', $today)
|
||||||
|
// Exclude activities that have been marked as read by this user
|
||||||
|
->whereNotExists(function ($q) use ($user, $today) {
|
||||||
|
$q->select(\DB::raw(1))
|
||||||
|
->from('activity_notification_reads')
|
||||||
|
->whereColumn('activity_notification_reads.activity_id', 'activities.id')
|
||||||
|
->where('activity_notification_reads.user_id', $user->id)
|
||||||
|
->whereDate('activity_notification_reads.due_date', '<=', $today)
|
||||||
|
->whereNotNull('activity_notification_reads.read_at');
|
||||||
|
})
|
||||||
|
->when($clientCaseIdsForFilter->isNotEmpty(), function ($q) use ($clientCaseIdsForFilter) {
|
||||||
|
// Filter by clients: activities directly on any of the client's cases OR via contracts under those cases
|
||||||
|
$q->where(function ($qq) use ($clientCaseIdsForFilter) {
|
||||||
|
$qq->whereIn('activities.client_case_id', $clientCaseIdsForFilter)
|
||||||
|
->orWhereIn('activities.contract_id', Contract::query()
|
||||||
|
->select('id')
|
||||||
|
->whereIn('client_case_id', $clientCaseIdsForFilter)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
// allow simple search by contract reference or person name
|
||||||
|
->when($search !== '', function ($q) use ($search) {
|
||||||
|
$s = mb_strtolower($search);
|
||||||
|
$q->leftJoin('contracts', 'contracts.id', '=', 'activities.contract_id')
|
||||||
|
->leftJoin('client_cases', 'client_cases.id', '=', 'activities.client_case_id')
|
||||||
|
->leftJoin('person', 'person.id', '=', 'client_cases.person_id')
|
||||||
|
->where(function ($qq) use ($s) {
|
||||||
|
$qq->whereRaw('LOWER(COALESCE(contracts.reference, \'\')) LIKE ?', ['%'.$s.'%'])
|
||||||
|
->orWhereRaw('LOWER(COALESCE(person.full_name, \'\')) LIKE ?', ['%'.$s.'%']);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->with([
|
||||||
|
'contract' => function ($q) {
|
||||||
|
$q->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.client_case_id'])
|
||||||
|
->with([
|
||||||
|
'clientCase' => function ($qq) {
|
||||||
|
$qq->select(['client_cases.id', 'client_cases.uuid', 'client_cases.client_id'])
|
||||||
|
->with([
|
||||||
|
'client' => function ($qqq) {
|
||||||
|
$qqq->select(['clients.id', 'clients.uuid', 'clients.person_id'])
|
||||||
|
->with([
|
||||||
|
'person' => function ($qqqq) {
|
||||||
|
$qqqq->select(['person.id', 'person.full_name']);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
'account' => function ($qq) {
|
||||||
|
$qq->select(['accounts.id', 'accounts.contract_id', 'accounts.balance_amount', 'accounts.initial_amount']);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
'clientCase' => function ($q) {
|
||||||
|
$q->select(['client_cases.id', 'client_cases.uuid', 'client_cases.person_id', 'client_cases.client_id'])
|
||||||
|
->with([
|
||||||
|
'person' => function ($qq) {
|
||||||
|
$qq->select(['person.id', 'person.full_name']);
|
||||||
|
},
|
||||||
|
'client' => function ($qq) {
|
||||||
|
$qq->select(['clients.id', 'clients.uuid', 'clients.person_id'])
|
||||||
|
->with([
|
||||||
|
'person' => function ($qqq) {
|
||||||
|
$qqq->select(['person.id', 'person.full_name']);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
])
|
||||||
|
// force ordering by due_date DESC only
|
||||||
|
->orderByDesc('activities.due_date');
|
||||||
|
|
||||||
|
// Use a custom page parameter name to match the frontend DataTableServer
|
||||||
|
$activities = $query->paginate($perPage, ['*'], 'unread-page')->withQueryString();
|
||||||
|
|
||||||
|
// Build a distinct clients list for the filter (client UUID + client.person.full_name)
|
||||||
|
// Collect client_case_ids from both direct activities and via contracts
|
||||||
|
$baseForClients = Activity::query()
|
||||||
|
->select(['contract_id', 'client_case_id'])
|
||||||
|
->whereNotNull('due_date')
|
||||||
|
->whereDate('due_date', '<=', $today)
|
||||||
|
// Exclude activities that have been marked as read by this user
|
||||||
|
->whereNotExists(function ($q) use ($user, $today) {
|
||||||
|
$q->select(\DB::raw(1))
|
||||||
|
->from('activity_notification_reads')
|
||||||
|
->whereColumn('activity_notification_reads.activity_id', 'activities.id')
|
||||||
|
->where('activity_notification_reads.user_id', $user->id)
|
||||||
|
->whereDate('activity_notification_reads.due_date', '<=', $today)
|
||||||
|
->whereNotNull('activity_notification_reads.read_at');
|
||||||
|
})
|
||||||
|
->when($clientCaseIdsForFilter->isNotEmpty(), function ($q) use ($clientCaseIdsForFilter) {
|
||||||
|
$q->where(function ($qq) use ($clientCaseIdsForFilter) {
|
||||||
|
$qq->whereIn('activities.client_case_id', $clientCaseIdsForFilter)
|
||||||
|
->orWhereIn('activities.contract_id', Contract::query()->select('id')->whereIn('client_case_id', $clientCaseIdsForFilter));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$contractIds = $baseForClients->pluck('contract_id')->filter()->unique()->values();
|
||||||
|
$directCaseIds = $baseForClients->pluck('client_case_id')->filter()->unique()->values();
|
||||||
|
$mapContractToCase = $contractIds->isNotEmpty()
|
||||||
|
? Contract::query()->whereIn('id', $contractIds)->pluck('client_case_id', 'id')
|
||||||
|
: collect();
|
||||||
|
$caseIds = $directCaseIds
|
||||||
|
->merge($contractIds->map(fn ($cid) => $mapContractToCase->get($cid)))
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
// Map caseIds -> clientIds, then load clients and present as value(label)
|
||||||
|
$clientIds = $caseIds->isNotEmpty()
|
||||||
|
? ClientCase::query()->whereIn('id', $caseIds)->pluck('client_id')->filter()->unique()->values()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$clients = $clientIds->isNotEmpty()
|
||||||
|
? Client::query()
|
||||||
|
->whereIn('id', $clientIds)
|
||||||
|
->with(['person:id,full_name'])
|
||||||
|
->get(['id', 'uuid', 'person_id'])
|
||||||
|
->map(fn ($c) => [
|
||||||
|
'value' => $c->uuid,
|
||||||
|
'label' => optional($c->person)->full_name ?: '(neznana stranka)',
|
||||||
|
])
|
||||||
|
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
|
||||||
|
->values()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
return Inertia::render('Notifications/Unread', [
|
||||||
|
'activities' => $activities,
|
||||||
|
'today' => $today,
|
||||||
|
'clients' => $clients,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class PaymentController extends Controller
|
class PaymentController extends Controller
|
||||||
{
|
{
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -2,62 +2,70 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Person\Person;
|
|
||||||
use App\Models\BankAccount;
|
use App\Models\BankAccount;
|
||||||
|
use App\Models\Person\Person;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
|
||||||
|
|
||||||
class PersonController extends Controller
|
class PersonController extends Controller
|
||||||
{
|
{
|
||||||
//
|
//
|
||||||
public function show(Person $person){
|
public function show(Person $person) {}
|
||||||
|
|
||||||
}
|
public function create(Request $request) {}
|
||||||
|
|
||||||
public function create(Request $request){
|
public function store(Request $request) {}
|
||||||
|
|
||||||
}
|
public function update(Person $person, Request $request)
|
||||||
|
{
|
||||||
public function store(Request $request){
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Person $person, Request $request){
|
|
||||||
$attributes = $request->validate([
|
$attributes = $request->validate([
|
||||||
'full_name' => 'string|max:255',
|
'full_name' => 'string|max:255',
|
||||||
'tax_number' => 'nullable|integer',
|
'tax_number' => 'nullable|integer',
|
||||||
'social_security_number' => 'nullable|integer',
|
'social_security_number' => 'nullable|integer',
|
||||||
'description' => 'nullable|string|max:500'
|
'description' => 'nullable|string|max:500',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$person->update($attributes);
|
$person->update($attributes);
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'Person updated');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'person' => [
|
'person' => [
|
||||||
'full_name' => $person->full_name,
|
'full_name' => $person->full_name,
|
||||||
'tax_number' => $person->tax_number,
|
'tax_number' => $person->tax_number,
|
||||||
'social_security_number' => $person->social_security_number,
|
'social_security_number' => $person->social_security_number,
|
||||||
'description' => $person->description
|
'description' => $person->description,
|
||||||
]
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createAddress(Person $person, Request $request){
|
public function createAddress(Person $person, Request $request)
|
||||||
|
{
|
||||||
$attributes = $request->validate([
|
$attributes = $request->validate([
|
||||||
'address' => 'required|string|max:150',
|
'address' => 'required|string|max:150',
|
||||||
'country' => 'nullable|string',
|
'country' => 'nullable|string',
|
||||||
|
'post_code' => 'nullable|string|max:16',
|
||||||
|
'city' => 'nullable|string|max:100',
|
||||||
'type_id' => 'required|integer|exists:address_types,id',
|
'type_id' => 'required|integer|exists:address_types,id',
|
||||||
'description' => 'nullable|string|max:125'
|
'description' => 'nullable|string|max:125',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Dedup: avoid duplicate address per person by (address, country)
|
// Dedup: avoid duplicate address per person by (address, country)
|
||||||
$address = $person->addresses()->firstOrCreate([
|
$address = $person->addresses()->firstOrCreate([
|
||||||
'address' => $attributes['address'],
|
'address' => $attributes['address'],
|
||||||
'country' => $attributes['country'] ?? null,
|
'country' => $attributes['country'] ?? null,
|
||||||
|
'post_code' => $attributes['post_code'] ?? null,
|
||||||
|
'city' => $attributes['city'] ?? null,
|
||||||
], $attributes);
|
], $attributes);
|
||||||
|
|
||||||
|
// Support Inertia form submissions (redirect back) and JSON (for API/axios)
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'Address created');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id)
|
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,16 +74,22 @@ public function updateAddress(Person $person, int $address_id, Request $request)
|
|||||||
$attributes = $request->validate([
|
$attributes = $request->validate([
|
||||||
'address' => 'required|string|max:150',
|
'address' => 'required|string|max:150',
|
||||||
'country' => 'nullable|string',
|
'country' => 'nullable|string',
|
||||||
|
'post_code' => 'nullable|string|max:16',
|
||||||
|
'city' => 'nullable|string|max:100',
|
||||||
'type_id' => 'required|integer|exists:address_types,id',
|
'type_id' => 'required|integer|exists:address_types,id',
|
||||||
'description' => 'nullable|string|max:125'
|
'description' => 'nullable|string|max:125',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$address = $person->addresses()->with(['type'])->findOrFail($address_id);
|
$address = $person->addresses()->with(['type'])->findOrFail($address_id);
|
||||||
|
|
||||||
$address->update($attributes);
|
$address->update($attributes);
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'Address updated');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'address' => $address
|
'address' => $address,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +97,11 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
|||||||
{
|
{
|
||||||
$address = $person->addresses()->findOrFail($address_id);
|
$address = $person->addresses()->findOrFail($address_id);
|
||||||
$address->delete(); // soft delete
|
$address->delete(); // soft delete
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'Address deleted');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(['status' => 'ok']);
|
return response()->json(['status' => 'ok']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +111,9 @@ public function createPhone(Person $person, Request $request)
|
|||||||
'nu' => 'required|string|max:50',
|
'nu' => 'required|string|max:50',
|
||||||
'country_code' => 'nullable|integer',
|
'country_code' => 'nullable|integer',
|
||||||
'type_id' => 'required|integer|exists:phone_types,id',
|
'type_id' => 'required|integer|exists:phone_types,id',
|
||||||
'description' => 'nullable|string|max:125'
|
'description' => 'nullable|string|max:125',
|
||||||
|
'validated' => 'sometimes|boolean',
|
||||||
|
'phone_type' => 'nullable|in:mobile,landline,voip',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Dedup: avoid duplicate phone per person by (nu, country_code)
|
// Dedup: avoid duplicate phone per person by (nu, country_code)
|
||||||
@@ -101,9 +122,7 @@ public function createPhone(Person $person, Request $request)
|
|||||||
'country_code' => $attributes['country_code'] ?? null,
|
'country_code' => $attributes['country_code'] ?? null,
|
||||||
], $attributes);
|
], $attributes);
|
||||||
|
|
||||||
return response()->json([
|
return back()->with('success', 'Phone added successfully');
|
||||||
'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id)
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatePhone(Person $person, int $phone_id, Request $request)
|
public function updatePhone(Person $person, int $phone_id, Request $request)
|
||||||
@@ -112,23 +131,24 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
|
|||||||
'nu' => 'required|string|max:50',
|
'nu' => 'required|string|max:50',
|
||||||
'country_code' => 'nullable|integer',
|
'country_code' => 'nullable|integer',
|
||||||
'type_id' => 'required|integer|exists:phone_types,id',
|
'type_id' => 'required|integer|exists:phone_types,id',
|
||||||
'description' => 'nullable|string|max:125'
|
'description' => 'nullable|string|max:125',
|
||||||
|
'validated' => 'sometimes|boolean',
|
||||||
|
'phone_type' => 'nullable|in:mobile,landline,voip',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$phone = $person->phones()->with(['type'])->findOrFail($phone_id);
|
$phone = $person->phones()->with(['type'])->findOrFail($phone_id);
|
||||||
|
|
||||||
$phone->update($attributes);
|
$phone->update($attributes);
|
||||||
|
|
||||||
return response()->json([
|
return back()->with('success', 'Phone updated successfully');
|
||||||
'phone' => $phone
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deletePhone(Person $person, int $phone_id, Request $request)
|
public function deletePhone(Person $person, int $phone_id, Request $request)
|
||||||
{
|
{
|
||||||
$phone = $person->phones()->findOrFail($phone_id);
|
$phone = $person->phones()->findOrFail($phone_id);
|
||||||
$phone->delete(); // soft delete
|
$phone->delete(); // soft delete
|
||||||
return response()->json(['status' => 'ok']);
|
|
||||||
|
return back()->with('success', 'Phone deleted');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createEmail(Person $person, Request $request)
|
public function createEmail(Person $person, Request $request)
|
||||||
@@ -139,6 +159,7 @@ public function createEmail(Person $person, Request $request)
|
|||||||
'is_primary' => 'boolean',
|
'is_primary' => 'boolean',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'valid' => 'boolean',
|
'valid' => 'boolean',
|
||||||
|
'receive_auto_mails' => 'sometimes|boolean',
|
||||||
'verified_at' => 'nullable|date',
|
'verified_at' => 'nullable|date',
|
||||||
'preferences' => 'nullable|array',
|
'preferences' => 'nullable|array',
|
||||||
'meta' => 'nullable|array',
|
'meta' => 'nullable|array',
|
||||||
@@ -149,9 +170,7 @@ public function createEmail(Person $person, Request $request)
|
|||||||
'value' => $attributes['value'],
|
'value' => $attributes['value'],
|
||||||
], $attributes);
|
], $attributes);
|
||||||
|
|
||||||
return response()->json([
|
return back()->with('success', 'Email added successfully');
|
||||||
'email' => \App\Models\Email::findOrFail($email->id)
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateEmail(Person $person, int $email_id, Request $request)
|
public function updateEmail(Person $person, int $email_id, Request $request)
|
||||||
@@ -162,6 +181,7 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
|||||||
'is_primary' => 'boolean',
|
'is_primary' => 'boolean',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'valid' => 'boolean',
|
'valid' => 'boolean',
|
||||||
|
'receive_auto_mails' => 'sometimes|boolean',
|
||||||
'verified_at' => 'nullable|date',
|
'verified_at' => 'nullable|date',
|
||||||
'preferences' => 'nullable|array',
|
'preferences' => 'nullable|array',
|
||||||
'meta' => 'nullable|array',
|
'meta' => 'nullable|array',
|
||||||
@@ -171,15 +191,18 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
|||||||
|
|
||||||
$email->update($attributes);
|
$email->update($attributes);
|
||||||
|
|
||||||
return response()->json([
|
return back()->with('success', 'Email updated successfully');
|
||||||
'email' => $email
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteEmail(Person $person, int $email_id, Request $request)
|
public function deleteEmail(Person $person, int $email_id, Request $request)
|
||||||
{
|
{
|
||||||
$email = $person->emails()->findOrFail($email_id);
|
$email = $person->emails()->findOrFail($email_id);
|
||||||
$email->delete();
|
$email->delete();
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'Email deleted');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(['status' => 'ok']);
|
return response()->json(['status' => 'ok']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,8 +225,12 @@ public function createTrr(Person $person, Request $request)
|
|||||||
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||||
$trr = $person->bankAccounts()->create($attributes);
|
$trr = $person->bankAccounts()->create($attributes);
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'TRR added successfully');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'trr' => BankAccount::findOrFail($trr->id)
|
'trr' => BankAccount::findOrFail($trr->id),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,8 +253,12 @@ public function updateTrr(Person $person, int $trr_id, Request $request)
|
|||||||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||||
$trr->update($attributes);
|
$trr->update($attributes);
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'TRR updated successfully');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'trr' => $trr
|
'trr' => $trr,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +266,11 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
|||||||
{
|
{
|
||||||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||||
$trr->delete();
|
$trr->delete();
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'TRR deleted');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(['status' => 'ok']);
|
return response()->json(['status' => 'ok']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,15 @@ public function index(Request $request)
|
|||||||
->whereNull('cancelled_at')
|
->whereNull('cancelled_at')
|
||||||
->with([
|
->with([
|
||||||
'contract' => function ($q) {
|
'contract' => function ($q) {
|
||||||
$q->with(['type:id,name', 'account', 'clientCase.person' => function ($pq) {
|
$q->with([
|
||||||
$pq->with(['addresses', 'phones']);
|
'type:id,name',
|
||||||
}]);
|
'account',
|
||||||
|
'clientCase.person' => function ($pq) {
|
||||||
|
$pq->with(['addresses', 'phones']);
|
||||||
|
},
|
||||||
|
'clientCase.client:id,uuid,person_id',
|
||||||
|
'clientCase.client.person:id,full_name',
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->orderByDesc('assigned_at')
|
->orderByDesc('assigned_at')
|
||||||
@@ -29,113 +35,123 @@ public function index(Request $request)
|
|||||||
|
|
||||||
return Inertia::render('Phone/Index', [
|
return Inertia::render('Phone/Index', [
|
||||||
'jobs' => $jobs,
|
'jobs' => $jobs,
|
||||||
|
'view_mode' => 'assigned',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function completedToday(Request $request)
|
||||||
|
{
|
||||||
|
$userId = $request->user()->id;
|
||||||
|
|
||||||
|
$start = now()->startOfDay();
|
||||||
|
$end = now()->endOfDay();
|
||||||
|
|
||||||
|
$jobs = FieldJob::query()
|
||||||
|
->where('assigned_user_id', $userId)
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->whereBetween('completed_at', [$start, $end])
|
||||||
|
->with([
|
||||||
|
'contract' => function ($q) {
|
||||||
|
$q->with([
|
||||||
|
'type:id,name',
|
||||||
|
'account',
|
||||||
|
'clientCase.person' => function ($pq) {
|
||||||
|
$pq->with(['addresses', 'phones']);
|
||||||
|
},
|
||||||
|
'clientCase.client:id,uuid,person_id',
|
||||||
|
'clientCase.client.person:id,full_name',
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->limit(100)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return Inertia::render('Phone/Index', [
|
||||||
|
'jobs' => $jobs,
|
||||||
|
'view_mode' => 'completed-today',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$userId = $request->user()->id;
|
||||||
|
$completedMode = $request->boolean('completed');
|
||||||
|
|
||||||
// Eager load client case with person details
|
// Eager load case with person details
|
||||||
$case = \App\Models\ClientCase::query()
|
$case = $clientCase->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts');
|
||||||
->with(['person' => fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts'])])
|
|
||||||
->findOrFail($clientCase->id);
|
|
||||||
|
|
||||||
// Determine contracts of this case assigned to the current user via FieldJobs and still active
|
// Query contracts based on field jobs
|
||||||
$assignedContractIds = FieldJob::query()
|
$contractsQuery = FieldJob::query()
|
||||||
->where('assigned_user_id', $userId)
|
->where('assigned_user_id', $userId)
|
||||||
->whereNull('completed_at')
|
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
|
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
|
||||||
->pluck('contract_id')
|
->when($completedMode,
|
||||||
->unique()
|
fn ($q) => $q->whereNull('cancelled_at')->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]),
|
||||||
->values();
|
fn ($q) => $q->whereNull('completed_at')->whereNull('cancelled_at')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get contracts with relationships
|
||||||
$contracts = \App\Models\Contract::query()
|
$contracts = \App\Models\Contract::query()
|
||||||
->where('client_case_id', $case->id)
|
->where('client_case_id', $case->id)
|
||||||
->whereIn('id', $assignedContractIds)
|
->whereIn('id', $contractsQuery->pluck('contract_id')->unique())
|
||||||
->with(['type:id,name', 'account'])
|
->with(['type:id,name', 'account', 'latestObject'])
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// Attach latest object (if any) to each contract as last_object for display
|
// Build merged documents
|
||||||
if ($contracts->isNotEmpty()) {
|
$documents = $case->documents()
|
||||||
$byId = $contracts->keyBy('id');
|
|
||||||
$latestObjects = \App\Models\CaseObject::query()
|
|
||||||
->whereIn('contract_id', $byId->keys())
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->select('id', 'reference', 'name', 'description', 'type', 'contract_id', 'created_at')
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->get()
|
|
||||||
->groupBy('contract_id')
|
|
||||||
->map(function ($group) {
|
|
||||||
return $group->first();
|
|
||||||
});
|
|
||||||
|
|
||||||
foreach ($latestObjects as $cid => $obj) {
|
|
||||||
if (isset($byId[$cid])) {
|
|
||||||
$byId[$cid]->setAttribute('last_object', $obj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build merged documents: case documents + documents of assigned contracts
|
|
||||||
$contractRefMap = [];
|
|
||||||
foreach ($contracts as $c) {
|
|
||||||
$contractRefMap[$c->id] = $c->reference;
|
|
||||||
}
|
|
||||||
|
|
||||||
$contractDocs = \App\Models\Document::query()
|
|
||||||
->where('documentable_type', \App\Models\Contract::class)
|
|
||||||
->whereIn('documentable_id', $assignedContractIds)
|
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->get()
|
->get()
|
||||||
->map(function ($d) use ($contractRefMap) {
|
->map(fn ($d) => array_merge($d->toArray(), [
|
||||||
$arr = $d->toArray();
|
'documentable_type' => \App\Models\ClientCase::class,
|
||||||
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
|
'client_case_uuid' => $case->uuid,
|
||||||
$arr['documentable_type'] = \App\Models\Contract::class;
|
]))
|
||||||
$arr['contract_uuid'] = optional(\App\Models\Contract::withTrashed()->find($d->documentable_id))->uuid;
|
->concat(
|
||||||
|
\App\Models\Document::query()
|
||||||
|
->where('documentable_type', \App\Models\Contract::class)
|
||||||
|
->whereIn('documentable_id', $contracts->pluck('id'))
|
||||||
|
->with('documentable:id,uuid,reference')
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get()
|
||||||
|
->map(fn ($d) => array_merge($d->toArray(), [
|
||||||
|
'contract_reference' => $d->documentable?->reference,
|
||||||
|
'contract_uuid' => $d->documentable?->uuid,
|
||||||
|
]))
|
||||||
|
)
|
||||||
|
->sortByDesc('created_at')
|
||||||
|
->values();
|
||||||
|
|
||||||
return $arr;
|
// Get segment IDs for filtering actions
|
||||||
});
|
$segmentIds = \App\Models\FieldJobSetting::query()
|
||||||
|
->whereIn('id', $contractsQuery->pluck('field_job_setting_id')->filter()->unique())
|
||||||
$caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) {
|
->pluck('segment_id')
|
||||||
$arr = $d->toArray();
|
->filter()
|
||||||
$arr['documentable_type'] = \App\Models\ClientCase::class;
|
->unique();
|
||||||
$arr['client_case_uuid'] = $case->uuid;
|
|
||||||
|
|
||||||
return $arr;
|
|
||||||
});
|
|
||||||
|
|
||||||
$documents = $caseDocs->concat($contractDocs)->sortByDesc('created_at')->values();
|
|
||||||
|
|
||||||
// Provide minimal types for PersonInfoGrid
|
|
||||||
$types = [
|
|
||||||
'address_types' => \App\Models\Person\AddressType::all(),
|
|
||||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Case activities (compact for phone): latest 20 with relations
|
|
||||||
$activities = $case->activities()
|
|
||||||
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->limit(20)
|
|
||||||
->get()
|
|
||||||
->map(function ($a) {
|
|
||||||
$a->setAttribute('user_name', optional($a->user)->name);
|
|
||||||
|
|
||||||
return $a;
|
|
||||||
});
|
|
||||||
|
|
||||||
return Inertia::render('Phone/Case/Index', [
|
return Inertia::render('Phone/Case/Index', [
|
||||||
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(),
|
'client' => $case->client->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts'),
|
||||||
'client_case' => $case,
|
'client_case' => $case,
|
||||||
'contracts' => $contracts,
|
'contracts' => $contracts,
|
||||||
'documents' => $documents,
|
'documents' => $documents,
|
||||||
'types' => $types,
|
'types' => [
|
||||||
|
'address_types' => \App\Models\Person\AddressType::all(),
|
||||||
|
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||||
|
],
|
||||||
'account_types' => \App\Models\AccountType::all(),
|
'account_types' => \App\Models\AccountType::all(),
|
||||||
'actions' => \App\Models\Action::with('decisions')->get(),
|
'actions' => \App\Models\Action::query()
|
||||||
'activities' => $activities,
|
->when($segmentIds->isNotEmpty(), fn ($q) => $q->whereIn('segment_id', $segmentIds))
|
||||||
|
->with([
|
||||||
|
'decisions:id,name,color_tag,auto_mail,email_template_id',
|
||||||
|
'decisions.emailTemplate:id,name,entity_types,allow_attachments',
|
||||||
|
])
|
||||||
|
->get(['id', 'name', 'color_tag', 'segment_id']),
|
||||||
|
'activities' => $case->activities()
|
||||||
|
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(20)
|
||||||
|
->get()
|
||||||
|
->map(fn ($a) => $a->setAttribute('user_name', $a->user?->name)),
|
||||||
|
'completed_mode' => $completedMode,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Post;
|
|
||||||
use App\Http\Requests\StorePostRequest;
|
use App\Http\Requests\StorePostRequest;
|
||||||
use App\Http\Requests\UpdatePostRequest;
|
use App\Http\Requests\UpdatePostRequest;
|
||||||
|
use App\Models\Post;
|
||||||
|
|
||||||
class PostController extends Controller
|
class PostController extends Controller
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,12 +2,20 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Exports\SegmentContractsExport;
|
||||||
|
use App\Http\Requests\ExportSegmentContractsRequest;
|
||||||
use App\Http\Requests\StoreSegmentRequest;
|
use App\Http\Requests\StoreSegmentRequest;
|
||||||
use App\Http\Requests\UpdateSegmentRequest;
|
use App\Http\Requests\UpdateSegmentRequest;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Contract;
|
||||||
use App\Models\Segment;
|
use App\Models\Segment;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
|
||||||
class SegmentController extends Controller
|
class SegmentController extends Controller
|
||||||
{
|
{
|
||||||
@@ -44,56 +52,105 @@ public function index()
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(\App\Models\Segment $segment)
|
public function show(Segment $segment)
|
||||||
{
|
{
|
||||||
// Retrieve contracts that are active in this segment, eager-loading required relations
|
|
||||||
$search = request('search');
|
$search = request('search');
|
||||||
$contractsQuery = \App\Models\Contract::query()
|
$clientFilter = request('client') ?? request('client_id');
|
||||||
->whereHas('segments', function ($q) use ($segment) {
|
$perPage = request()->integer('perPage', request()->integer('per_page', 15));
|
||||||
|
$perPage = max(1, min(200, $perPage));
|
||||||
|
|
||||||
|
$contracts = $this->buildContractsQuery($segment, $search, $clientFilter)
|
||||||
|
->paginate($perPage)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
$contracts = $this->hydrateClientShortcut($contracts);
|
||||||
|
|
||||||
|
$clients = Client::query()
|
||||||
|
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
|
||||||
$q->where('segments.id', $segment->id)
|
$q->where('segments.id', $segment->id)
|
||||||
->where('contract_segment.active', '=', 1);
|
->where('contract_segment.active', '=', 1);
|
||||||
})
|
})
|
||||||
->with([
|
->with(['person:id,full_name'])
|
||||||
'clientCase.person',
|
->get(['uuid', 'person_id'])
|
||||||
'clientCase.client.person',
|
->map(function ($c) {
|
||||||
'type',
|
return [
|
||||||
'account',
|
'uuid' => (string) $c->uuid,
|
||||||
])
|
'name' => (string) optional($c->person)->full_name,
|
||||||
->latest('id');
|
];
|
||||||
|
})
|
||||||
if (!empty($search)) {
|
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
|
||||||
$contractsQuery->where(function ($qq) use ($search) {
|
->values();
|
||||||
$qq->where('contracts.reference', 'ilike', '%'.$search.'%')
|
|
||||||
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
|
||||||
$p->where('full_name', 'ilike', '%'.$search.'%');
|
|
||||||
})
|
|
||||||
->orWhereHas('clientCase.client.person', function ($p) use ($search) {
|
|
||||||
$p->where('full_name', 'ilike', '%'.$search.'%');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$contracts = $contractsQuery
|
|
||||||
->paginate(15)
|
|
||||||
->withQueryString();
|
|
||||||
|
|
||||||
// Mirror client onto the contract to simplify frontend access (c.client.person.full_name)
|
|
||||||
$items = collect($contracts->items());
|
|
||||||
$items->each(function ($contract) {
|
|
||||||
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
|
|
||||||
$contract->setRelation('client', $contract->clientCase->client);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (method_exists($contracts, 'setCollection')) {
|
|
||||||
$contracts->setCollection($items);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Inertia::render('Segments/Show', [
|
return Inertia::render('Segments/Show', [
|
||||||
'segment' => $segment->only(['id','name','description']),
|
'segment' => $segment->only(['id', 'name', 'description']),
|
||||||
'contracts' => $contracts,
|
'contracts' => $contracts,
|
||||||
|
'clients' => $clients,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function export(ExportSegmentContractsRequest $request, Segment $segment)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$client = $this->resolveClient($data['client'] ?? null);
|
||||||
|
$columns = array_values(array_unique($data['columns']));
|
||||||
|
$query = $this->buildContractsQuery(
|
||||||
|
$segment,
|
||||||
|
$data['search'] ?? null,
|
||||||
|
$data['client'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (($data['scope'] ?? ExportSegmentContractsRequest::SCOPE_ALL) === ExportSegmentContractsRequest::SCOPE_CURRENT) {
|
||||||
|
$page = max(1, (int) ($data['page'] ?? 1));
|
||||||
|
$perPage = max(1, min(200, (int) ($data['per_page'] ?? 15)));
|
||||||
|
$query->forPage($page, $perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = $this->buildExportFilename($segment, $client);
|
||||||
|
|
||||||
|
return Excel::download(new SegmentContractsExport($query, $columns), $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveClient(?string $identifier): ?Client
|
||||||
|
{
|
||||||
|
if (empty($identifier)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = Client::query()->with(['person:id,full_name']);
|
||||||
|
|
||||||
|
if (Str::isUuid($identifier)) {
|
||||||
|
$query->where('uuid', $identifier);
|
||||||
|
} elseif (is_numeric($identifier)) {
|
||||||
|
$query->where('id', (int) $identifier);
|
||||||
|
} else {
|
||||||
|
$query->where('uuid', $identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildExportFilename(Segment $segment, ?Client $client): string
|
||||||
|
{
|
||||||
|
$datePrefix = now()->format('dmy');
|
||||||
|
$segmentName = $this->slugify($segment->name ?? 'segment');
|
||||||
|
$base = sprintf('%s_%s-Pogodbe', $datePrefix, $segmentName);
|
||||||
|
|
||||||
|
if ($client && $client->person?->full_name) {
|
||||||
|
$clientName = $this->slugify($client->person->full_name);
|
||||||
|
|
||||||
|
return sprintf('%s_%s.xlsx', $base, $clientName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s.xlsx', $base);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slugify(string $value): string
|
||||||
|
{
|
||||||
|
$slug = trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $value), '-');
|
||||||
|
|
||||||
|
return $slug !== '' ? $slug : 'data';
|
||||||
|
}
|
||||||
|
|
||||||
public function settings(Request $request)
|
public function settings(Request $request)
|
||||||
{
|
{
|
||||||
return Inertia::render('Settings/Segments/Index', [
|
return Inertia::render('Settings/Segments/Index', [
|
||||||
@@ -120,8 +177,65 @@ public function update(UpdateSegmentRequest $request, Segment $segment)
|
|||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'description' => $data['description'] ?? null,
|
'description' => $data['description'] ?? null,
|
||||||
'active' => $data['active'] ?? $segment->active,
|
'active' => $data['active'] ?? $segment->active,
|
||||||
|
'exclude' => $data['exclude'] ?? $segment->exclude,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return to_route('settings.segments')->with('success', 'Segment updated');
|
return to_route('settings.segments')->with('success', 'Segment updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildContractsQuery(Segment $segment, ?string $search, ?string $clientFilter): Builder
|
||||||
|
{
|
||||||
|
$query = Contract::query()
|
||||||
|
->whereHas('segments', function ($q) use ($segment) {
|
||||||
|
$q->where('segments.id', $segment->id)
|
||||||
|
->where('contract_segment.active', '=', 1);
|
||||||
|
})
|
||||||
|
->with([
|
||||||
|
'clientCase.person',
|
||||||
|
'clientCase.client.person',
|
||||||
|
'type',
|
||||||
|
'account',
|
||||||
|
])
|
||||||
|
->latest('id');
|
||||||
|
|
||||||
|
if (! empty($clientFilter)) {
|
||||||
|
$query->whereHas('clientCase.client', function ($q) use ($clientFilter) {
|
||||||
|
if (is_numeric($clientFilter)) {
|
||||||
|
$q->where('clients.id', (int) $clientFilter);
|
||||||
|
} else {
|
||||||
|
$q->where('clients.uuid', $clientFilter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($search)) {
|
||||||
|
$query->where(function ($qq) use ($search) {
|
||||||
|
$qq->where('contracts.reference', 'ilike', '%'.$search.'%')
|
||||||
|
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
||||||
|
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||||
|
})
|
||||||
|
->orWhereHas('clientCase.client.person', function ($p) use ($search) {
|
||||||
|
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hydrateClientShortcut(LengthAwarePaginator $contracts): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$items = collect($contracts->items());
|
||||||
|
$items->each(function (Contract $contract) {
|
||||||
|
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
|
||||||
|
$contract->setRelation('client', $contract->clientCase->client);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (method_exists($contracts, 'setCollection')) {
|
||||||
|
$contracts->setCollection($items);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contracts;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ class SettingController extends Controller
|
|||||||
{
|
{
|
||||||
//
|
//
|
||||||
|
|
||||||
public function index(Request $request){
|
public function index(Request $request)
|
||||||
|
{
|
||||||
return Inertia::render('Settings/Index');
|
return Inertia::render('Settings/Index');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\SmsLog;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class SmsWebhookController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle smsapi.si delivery reports (GET) and replies (POST).
|
||||||
|
* This endpoint accepts both methods as the provider may use either.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request)
|
||||||
|
{
|
||||||
|
// Delivery report via GET: id (int) and status (string)
|
||||||
|
if ($request->query->has('id')) {
|
||||||
|
$providerId = (string) ((int) $request->query('id'));
|
||||||
|
$status = trim(strip_tags((string) $request->query('status', '')));
|
||||||
|
|
||||||
|
$log = SmsLog::query()->where('provider_message_id', $providerId)->first();
|
||||||
|
if ($log) {
|
||||||
|
$meta = (array) $log->meta;
|
||||||
|
$meta['delivery_report'] = [
|
||||||
|
'raw_status' => $status,
|
||||||
|
'received_at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Naive mapping: mark delivered for common success statuses
|
||||||
|
$normalized = strtoupper($status);
|
||||||
|
if (in_array($normalized, ['DELIVERED', 'DELIVRD', 'OK'], true)) {
|
||||||
|
$log->status = 'delivered';
|
||||||
|
$log->delivered_at = now();
|
||||||
|
} elseif (in_array($normalized, ['FAILED', 'UNDELIV', 'UNDELIVERED', 'ERROR'], true)) {
|
||||||
|
$log->status = 'failed';
|
||||||
|
$log->failed_at = now();
|
||||||
|
$log->error_code = $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
$log->meta = $meta;
|
||||||
|
$log->save();
|
||||||
|
} else {
|
||||||
|
Log::warning('sms.webhook.delivery.unknown_id', ['provider_id' => $providerId, 'status' => $status]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply via POST: smsId, m (message), from, to, time
|
||||||
|
if ($request->isMethod('post') && $request->post('smsId')) {
|
||||||
|
$providerId = (string) ((int) $request->post('smsId'));
|
||||||
|
$msg = trim(strip_tags((string) $request->post('m', '')));
|
||||||
|
$fromNumber = (string) $request->post('from', '');
|
||||||
|
$toNumber = (string) $request->post('to', '');
|
||||||
|
$timestamp = (int) $request->post('time', time());
|
||||||
|
|
||||||
|
$log = SmsLog::query()->where('provider_message_id', $providerId)->first();
|
||||||
|
if ($log) {
|
||||||
|
$meta = (array) $log->meta;
|
||||||
|
$replies = isset($meta['replies']) && is_array($meta['replies']) ? $meta['replies'] : [];
|
||||||
|
$replies[] = [
|
||||||
|
'message' => $msg,
|
||||||
|
'from' => $fromNumber,
|
||||||
|
'to' => $toNumber,
|
||||||
|
'time' => date('c', $timestamp),
|
||||||
|
];
|
||||||
|
$meta['replies'] = $replies;
|
||||||
|
$log->meta = $meta;
|
||||||
|
$log->save();
|
||||||
|
} else {
|
||||||
|
Log::warning('sms.webhook.reply.unknown_id', [
|
||||||
|
'provider_id' => $providerId,
|
||||||
|
'from' => $fromNumber,
|
||||||
|
'to' => $toNumber,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown payload
|
||||||
|
return response()->json(['ok' => false, 'reason' => 'unsupported payload'], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Action;
|
use App\Models\Action;
|
||||||
|
use App\Models\ArchiveSetting;
|
||||||
use App\Models\Decision;
|
use App\Models\Decision;
|
||||||
|
use App\Models\EmailTemplate;
|
||||||
use App\Models\Segment;
|
use App\Models\Segment;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
class WorkflowController extends Controller
|
class WorkflowController extends Controller
|
||||||
@@ -14,9 +16,12 @@ class WorkflowController extends Controller
|
|||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
return Inertia::render('Settings/Workflow/Index', [
|
return Inertia::render('Settings/Workflow/Index', [
|
||||||
'actions' => Action::query()->with(['decisions', 'segment'])->withCount('activities')->get(),
|
'actions' => Action::query()->with(['decisions', 'segment'])->withCount('activities')->orderBy('id')->get(),
|
||||||
'decisions' => Decision::query()->with('actions')->withCount('activities')->get(),
|
'decisions' => Decision::query()->with(['actions', 'events'])->withCount('activities')->orderBy('id')->get(),
|
||||||
'segments' => Segment::query()->get(),
|
'segments' => Segment::query()->get(),
|
||||||
|
'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
|
||||||
|
'events' => \App\Models\Event::query()->orderBy('name')->get(['id', 'name', 'key', 'description', 'active']),
|
||||||
|
'archive_settings' => ArchiveSetting::query()->where('enabled', true)->orderBy('id')->get(['id', 'name']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +46,7 @@ public function storeAction(Request $request)
|
|||||||
'segment_id' => $attributes['segment_id'] ?? null,
|
'segment_id' => $attributes['segment_id'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!empty($decisionIds)) {
|
if (! empty($decisionIds)) {
|
||||||
$row->decisions()->sync($decisionIds);
|
$row->decisions()->sync($decisionIds);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -59,12 +64,12 @@ public function updateAction(int $id, Request $request)
|
|||||||
'segment_id' => 'nullable|integer|exists:segments,id',
|
'segment_id' => 'nullable|integer|exists:segments,id',
|
||||||
'decisions' => 'nullable|array',
|
'decisions' => 'nullable|array',
|
||||||
'decisions.*.id' => 'required_with:decisions.*|integer|exists:decisions,id',
|
'decisions.*.id' => 'required_with:decisions.*|integer|exists:decisions,id',
|
||||||
'decisions.*.name' => 'required_with:decisions.*|string|max:50'
|
'decisions.*.name' => 'required_with:decisions.*|string|max:50',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$decisionIds = collect($attributes['decisions'] ?? [])->pluck('id')->toArray();
|
$decisionIds = collect($attributes['decisions'] ?? [])->pluck('id')->toArray();
|
||||||
|
|
||||||
\DB::transaction(function() use ($attributes, $decisionIds, $row) {
|
\DB::transaction(function () use ($attributes, $decisionIds, $row) {
|
||||||
$row->update([
|
$row->update([
|
||||||
'name' => $attributes['name'],
|
'name' => $attributes['name'],
|
||||||
'color_tag' => $attributes['color_tag'],
|
'color_tag' => $attributes['color_tag'],
|
||||||
@@ -81,23 +86,85 @@ public function storeDecision(Request $request)
|
|||||||
$attributes = $request->validate([
|
$attributes = $request->validate([
|
||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'color_tag' => 'nullable|string|max:25',
|
'color_tag' => 'nullable|string|max:25',
|
||||||
|
'auto_mail' => 'sometimes|boolean',
|
||||||
|
'email_template_id' => 'nullable|integer|exists:email_templates,id',
|
||||||
'actions' => 'nullable|array',
|
'actions' => 'nullable|array',
|
||||||
'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id',
|
'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id',
|
||||||
'actions.*.name' => 'required_with:actions.*|string|max:50',
|
'actions.*.name' => 'required_with:actions.*|string|max:50',
|
||||||
|
'events' => 'nullable|array',
|
||||||
|
'events.*.id' => 'required_with:events.*|integer|exists:events,id',
|
||||||
|
'events.*.active' => 'sometimes|boolean',
|
||||||
|
'events.*.run_order' => 'nullable|integer',
|
||||||
|
'events.*.config' => 'nullable|array',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
||||||
|
$eventsPayload = collect($attributes['events'] ?? []);
|
||||||
|
|
||||||
\DB::transaction(function () use ($attributes, $actionIds) {
|
// Extra server-side validation for event-specific config keys
|
||||||
|
$validationErrors = [];
|
||||||
|
foreach ($eventsPayload as $i => $ev) {
|
||||||
|
$idEv = isset($ev['id']) ? (int) $ev['id'] : null;
|
||||||
|
if (! $idEv) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$eventModel = \App\Models\Event::find($idEv);
|
||||||
|
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||||
|
if ($key === 'add_segment') {
|
||||||
|
$seg = $ev['config']['segment_id'] ?? null;
|
||||||
|
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
|
||||||
|
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
||||||
|
}
|
||||||
|
} elseif ($key === 'archive_contract') {
|
||||||
|
$as = $ev['config']['archive_setting_id'] ?? null;
|
||||||
|
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) {
|
||||||
|
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($validationErrors)) {
|
||||||
|
throw ValidationException::withMessages($validationErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
\DB::transaction(function () use ($attributes, $actionIds, $eventsPayload) {
|
||||||
/** @var \App\Models\Decision $row */
|
/** @var \App\Models\Decision $row */
|
||||||
$row = Decision::create([
|
$row = Decision::create([
|
||||||
'name' => $attributes['name'],
|
'name' => $attributes['name'],
|
||||||
'color_tag' => $attributes['color_tag'] ?? null,
|
'color_tag' => $attributes['color_tag'] ?? null,
|
||||||
|
'auto_mail' => (bool) ($attributes['auto_mail'] ?? false),
|
||||||
|
'email_template_id' => $attributes['email_template_id'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!empty($actionIds)) {
|
if (! empty($actionIds)) {
|
||||||
$row->actions()->sync($actionIds);
|
$row->actions()->sync($actionIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach decision events with pivot attributes
|
||||||
|
if ($eventsPayload->isNotEmpty()) {
|
||||||
|
$sync = [];
|
||||||
|
foreach ($eventsPayload as $ev) {
|
||||||
|
$id = (int) ($ev['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cfg = $ev['config'] ?? null;
|
||||||
|
if (is_array($cfg)) {
|
||||||
|
$cfg = json_encode($cfg);
|
||||||
|
} elseif (is_string($cfg)) {
|
||||||
|
$trim = trim($cfg);
|
||||||
|
$cfg = $trim === '' ? null : $cfg;
|
||||||
|
} else {
|
||||||
|
$cfg = null;
|
||||||
|
}
|
||||||
|
$sync[$id] = [
|
||||||
|
'active' => (bool) ($ev['active'] ?? true),
|
||||||
|
'run_order' => isset($ev['run_order']) ? (int) $ev['run_order'] : null,
|
||||||
|
'config' => $cfg,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$row->events()->sync($sync);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return to_route('settings.workflow')->with('success', 'Decision created successfully!');
|
return to_route('settings.workflow')->with('success', 'Decision created successfully!');
|
||||||
@@ -110,19 +177,88 @@ public function updateDecision(int $id, Request $request)
|
|||||||
$attributes = $request->validate([
|
$attributes = $request->validate([
|
||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'color_tag' => 'nullable|string|max:25',
|
'color_tag' => 'nullable|string|max:25',
|
||||||
|
'auto_mail' => 'sometimes|boolean',
|
||||||
|
'email_template_id' => 'nullable|integer|exists:email_templates,id',
|
||||||
'actions' => 'nullable|array',
|
'actions' => 'nullable|array',
|
||||||
'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id',
|
'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id',
|
||||||
'actions.*.name' => 'required_with:actions.*|string|max:50',
|
'actions.*.name' => 'required_with:actions.*|string|max:50',
|
||||||
|
'events' => 'nullable|array',
|
||||||
|
'events.*.id' => 'required_with:events.*|integer|exists:events,id',
|
||||||
|
'events.*.active' => 'sometimes|boolean',
|
||||||
|
'events.*.run_order' => 'nullable|integer',
|
||||||
|
'events.*.config' => 'nullable|array',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
$actionIds = collect($attributes['actions'] ?? [])->pluck('id')->toArray();
|
||||||
|
$eventsPayload = collect($attributes['events'] ?? []);
|
||||||
|
|
||||||
\DB::transaction(function () use ($attributes, $actionIds, $row) {
|
// Extra server-side validation for event-specific config keys
|
||||||
|
$validationErrors = [];
|
||||||
|
foreach ($eventsPayload as $i => $ev) {
|
||||||
|
$idEv = isset($ev['id']) ? (int) $ev['id'] : null;
|
||||||
|
if (! $idEv) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$eventModel = \App\Models\Event::find($idEv);
|
||||||
|
$key = $eventModel?->key ?? ($ev['key'] ?? null);
|
||||||
|
if ($key === 'add_segment') {
|
||||||
|
$seg = $ev['config']['segment_id'] ?? null;
|
||||||
|
if (empty($seg) || ! Segment::where('id', $seg)->exists()) {
|
||||||
|
$validationErrors["events.$i.config.segment_id"] = 'Please select a valid segment for the add_segment event.';
|
||||||
|
}
|
||||||
|
} elseif ($key === 'archive_contract') {
|
||||||
|
$as = $ev['config']['archive_setting_id'] ?? null;
|
||||||
|
if (empty($as) || ! ArchiveSetting::where('id', $as)->exists()) {
|
||||||
|
$validationErrors["events.$i.config.archive_setting_id"] = 'Please select a valid archive setting for the archive_contract event.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($validationErrors)) {
|
||||||
|
throw ValidationException::withMessages($validationErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
\DB::transaction(function () use ($attributes, $actionIds, $eventsPayload, $row) {
|
||||||
$row->update([
|
$row->update([
|
||||||
'name' => $attributes['name'],
|
'name' => $attributes['name'],
|
||||||
'color_tag' => $attributes['color_tag'] ?? null,
|
'color_tag' => $attributes['color_tag'] ?? null,
|
||||||
|
'auto_mail' => (bool) ($attributes['auto_mail'] ?? false),
|
||||||
|
'email_template_id' => $attributes['email_template_id'] ?? null,
|
||||||
]);
|
]);
|
||||||
$row->actions()->sync($actionIds);
|
$row->actions()->sync($actionIds);
|
||||||
|
|
||||||
|
// Sync decision events with pivot attributes
|
||||||
|
if ($eventsPayload->isNotEmpty()) {
|
||||||
|
$sync = [];
|
||||||
|
foreach ($eventsPayload as $ev) {
|
||||||
|
$id = (int) ($ev['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cfg = $ev['config'] ?? null;
|
||||||
|
// ensure string JSON stored; accept already-JSON strings
|
||||||
|
if (is_array($cfg)) {
|
||||||
|
$cfg = json_encode($cfg);
|
||||||
|
} elseif (is_string($cfg)) {
|
||||||
|
$trim = trim($cfg);
|
||||||
|
// If not valid JSON, keep raw string (handler side can parse/ignore)
|
||||||
|
$cfg = $trim === '' ? null : $cfg;
|
||||||
|
} else {
|
||||||
|
$cfg = null;
|
||||||
|
}
|
||||||
|
$sync[$id] = [
|
||||||
|
'active' => (bool) ($ev['active'] ?? true),
|
||||||
|
'run_order' => isset($ev['run_order']) ? (int) $ev['run_order'] : null,
|
||||||
|
'config' => $cfg,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$row->events()->sync($sync);
|
||||||
|
} else {
|
||||||
|
// If empty provided explicitly, detach all to reflect UI intent
|
||||||
|
if (array_key_exists('events', $attributes)) {
|
||||||
|
$row->events()->detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return to_route('settings.workflow')->with('success', 'Decision updated successfully!');
|
return to_route('settings.workflow')->with('success', 'Decision updated successfully!');
|
||||||
@@ -139,6 +275,7 @@ public function destroyAction(int $id)
|
|||||||
$row->decisions()->detach();
|
$row->decisions()->detach();
|
||||||
$row->delete();
|
$row->delete();
|
||||||
});
|
});
|
||||||
|
|
||||||
return back()->with('success', 'Action deleted successfully!');
|
return back()->with('success', 'Action deleted successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +290,7 @@ public function destroyDecision(int $id)
|
|||||||
$row->actions()->detach();
|
$row->actions()->detach();
|
||||||
$row->delete();
|
$row->delete();
|
||||||
});
|
});
|
||||||
|
|
||||||
return back()->with('success', 'Decision deleted successfully!');
|
return back()->with('success', 'Decision deleted successfully!');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureUserIsActive
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if ($user && ! $user->active) {
|
||||||
|
// Revoke all tokens for Sanctum
|
||||||
|
if (method_exists($user, 'tokens')) {
|
||||||
|
$user->tokens()->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout from web guard
|
||||||
|
Auth::guard('web')->logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['message' => 'Vaš račun je bil onemogočen.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('login')->with('error', 'Vaš račun je bil onemogočen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,21 +66,99 @@ public function share(Request $request): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$today = now()->toDateString();
|
$today = now()->toDateString();
|
||||||
|
|
||||||
|
// Base fetch to avoid serialization issues; eager load relations afterwards
|
||||||
$activities = \App\Models\Activity::query()
|
$activities = \App\Models\Activity::query()
|
||||||
->with([
|
->select(['id', 'due_date', 'amount', 'contract_id', 'client_case_id', 'created_at'])
|
||||||
// Include contract uuid and reference, keep id for relation mapping, and client_case_id for nested eager load
|
|
||||||
'contract:id,uuid,reference,client_case_id',
|
|
||||||
// Include client case uuid (id required for mapping, will be hidden in JSON)
|
|
||||||
'contract.clientCase:id,uuid',
|
|
||||||
// Include account amounts; contract_id needed for relation mapping
|
|
||||||
'contract.account:contract_id,balance_amount,initial_amount',
|
|
||||||
])
|
|
||||||
->whereDate('due_date', $today)
|
->whereDate('due_date', $today)
|
||||||
->where('user_id', $user->id)
|
// Exclude activities that have been marked as read by this user
|
||||||
|
->whereNotExists(function ($q) use ($user, $today) {
|
||||||
|
$q->select(\DB::raw(1))
|
||||||
|
->from('activity_notification_reads')
|
||||||
|
->whereColumn('activity_notification_reads.activity_id', 'activities.id')
|
||||||
|
->where('activity_notification_reads.user_id', $user->id)
|
||||||
|
->whereDate('activity_notification_reads.due_date', '<=', $today)
|
||||||
|
->whereNotNull('activity_notification_reads.read_at');
|
||||||
|
})
|
||||||
->orderBy('created_at')
|
->orderBy('created_at')
|
||||||
->limit(20)
|
->limit(20)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
// Eager load needed relations (contracts and client cases) with qualified selects
|
||||||
|
$activities->load([
|
||||||
|
'contract' => function ($q) {
|
||||||
|
$q->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.client_case_id'])
|
||||||
|
->with([
|
||||||
|
// Include client (via case) so the UI can render client.person.full_name
|
||||||
|
'clientCase' => function ($qq) {
|
||||||
|
// Include person_id to ensure nested person loads correctly and to avoid null clientCase due to narrow selects
|
||||||
|
$qq->select(['client_cases.id', 'client_cases.uuid', 'client_cases.client_id', 'client_cases.person_id'])
|
||||||
|
->with([
|
||||||
|
'client' => function ($qqq) {
|
||||||
|
$qqq->select(['clients.id', 'clients.person_id'])
|
||||||
|
->with([
|
||||||
|
'person' => function ($qqqq) {
|
||||||
|
$qqqq->select(['person.id', 'person.full_name']);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
'account' => function ($qq) {
|
||||||
|
$qq->select(['accounts.id', 'accounts.contract_id', 'accounts.balance_amount', 'accounts.initial_amount']);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
'clientCase' => function ($q) {
|
||||||
|
$q->select(['client_cases.id', 'client_cases.uuid', 'client_cases.person_id', 'client_cases.client_id'])
|
||||||
|
->with([
|
||||||
|
'person' => function ($qq) {
|
||||||
|
$qq->select(['person.id', 'person.full_name']);
|
||||||
|
},
|
||||||
|
'client' => function ($qq) {
|
||||||
|
$qq->select(['clients.id', 'clients.person_id'])
|
||||||
|
->with([
|
||||||
|
'person' => function ($qqq) {
|
||||||
|
$qqq->select(['person.id', 'person.full_name']);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// For convenience on the frontend, mirror client onto the contract so it can be accessed as contract.client.person
|
||||||
|
// 1) Build a map of contract_id -> client_id using a lightweight join
|
||||||
|
$contractIds = $activities->pluck('contract_id')->filter()->unique()->values();
|
||||||
|
if ($contractIds->isNotEmpty()) {
|
||||||
|
$mapContractToClient = \App\Models\Contract::query()
|
||||||
|
->whereIn('contracts.id', $contractIds)
|
||||||
|
->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
|
||||||
|
->pluck('client_cases.client_id', 'contracts.id');
|
||||||
|
|
||||||
|
// 2) Load all needed clients with their person
|
||||||
|
$clientIds = $mapContractToClient->filter()->unique()->values();
|
||||||
|
$clientsById = $clientIds->isNotEmpty()
|
||||||
|
? \App\Models\Client::query()
|
||||||
|
->whereIn('clients.id', $clientIds)
|
||||||
|
->with(['person:id,full_name'])
|
||||||
|
->get(['clients.id', 'clients.uuid', 'clients.person_id'])
|
||||||
|
->keyBy('id')
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
// 3) Attach client relation on each contract instance
|
||||||
|
foreach ($activities as $act) {
|
||||||
|
$contract = $act->getRelation('contract');
|
||||||
|
if (! $contract) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cid = $mapContractToClient->get($contract->id);
|
||||||
|
if ($cid && $clientsById->has($cid)) {
|
||||||
|
$contract->setRelation('client', $clientsById->get($cid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'dueToday' => [
|
'dueToday' => [
|
||||||
'count' => $activities->count(),
|
'count' => $activities->count(),
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class StoreUserRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return Gate::allows('manage-settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
||||||
|
'password' => ['required', 'string', Password::defaults(), 'confirmed'],
|
||||||
|
'roles' => ['array'],
|
||||||
|
'roles.*' => ['integer', 'exists:roles,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom error messages.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name.required' => 'Ime uporabnika je obvezno.',
|
||||||
|
'email.required' => 'E-poštni naslov je obvezen.',
|
||||||
|
'email.email' => 'E-poštni naslov mora biti veljaven.',
|
||||||
|
'email.unique' => 'Ta e-poštni naslov je že v uporabi.',
|
||||||
|
'password.required' => 'Geslo je obvezno.',
|
||||||
|
'password.confirmed' => 'Gesli se ne ujemata.',
|
||||||
|
'roles.*.exists' => 'Izbrana vloga ni veljavna.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Exports\SegmentContractsExport;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ExportSegmentContractsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public const SCOPE_CURRENT = 'current';
|
||||||
|
|
||||||
|
public const SCOPE_ALL = 'all';
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$columnRule = Rule::in(SegmentContractsExport::allowedColumns());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'scope' => ['required', Rule::in([self::SCOPE_CURRENT, self::SCOPE_ALL])],
|
||||||
|
'columns' => ['required', 'array', 'min:1'],
|
||||||
|
'columns.*' => ['string', $columnRule],
|
||||||
|
'search' => ['nullable', 'string', 'max:255'],
|
||||||
|
'client' => ['nullable', 'string', 'max:64'],
|
||||||
|
'page' => ['nullable', 'integer', 'min:1'],
|
||||||
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'client' => $this->input('client') ?? $this->input('client_id'),
|
||||||
|
'per_page' => $this->input('per_page') ?? $this->input('perPage'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreEmailTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('create', \App\Models\EmailTemplate::class) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'key' => ['required', 'string', 'max:255', 'unique:email_templates,key'],
|
||||||
|
'subject_template' => ['required', 'string', 'max:1000'],
|
||||||
|
'html_template' => ['nullable', 'string'],
|
||||||
|
'text_template' => ['nullable', 'string'],
|
||||||
|
'entity_types' => ['nullable', 'array'],
|
||||||
|
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
||||||
|
'allow_attachments' => ['sometimes', 'boolean'],
|
||||||
|
'active' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreMailProfileRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() && $this->user()->can('create', \App\Models\MailProfile::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:190'],
|
||||||
|
'host' => ['required', 'string', 'max:190'],
|
||||||
|
'port' => ['required', 'integer', 'between:1,65535'],
|
||||||
|
'encryption' => ['nullable', 'in:ssl,tls,starttls'],
|
||||||
|
'username' => ['nullable', 'string', 'max:190'],
|
||||||
|
'password' => ['required', 'string', 'max:512'],
|
||||||
|
'from_address' => ['required', 'email', 'max:190'],
|
||||||
|
'from_name' => ['nullable', 'string', 'max:190'],
|
||||||
|
'reply_to_address' => ['nullable', 'email', 'max:190'],
|
||||||
|
'reply_to_name' => ['nullable', 'string', 'max:190'],
|
||||||
|
'priority' => ['nullable', 'integer', 'between:0,65535'],
|
||||||
|
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StorePackageFromContractsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('manage-settings') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => ['required', 'in:sms'],
|
||||||
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'meta' => ['nullable', 'array'],
|
||||||
|
|
||||||
|
// Common payload for all items
|
||||||
|
'payload' => ['required', 'array'],
|
||||||
|
'payload.profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
|
||||||
|
'payload.sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
|
||||||
|
'payload.template_id' => ['nullable', 'integer', 'exists:sms_templates,id'],
|
||||||
|
'payload.delivery_report' => ['nullable', 'boolean'],
|
||||||
|
'payload.variables' => ['nullable', 'array'],
|
||||||
|
'payload.body' => ['nullable', 'string'],
|
||||||
|
|
||||||
|
// Source contracts to derive items from
|
||||||
|
'contract_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'contract_ids.*' => ['integer', 'exists:contracts,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StorePackageRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('manage-settings') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => ['required', 'in:sms'],
|
||||||
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'meta' => ['nullable', 'array'],
|
||||||
|
|
||||||
|
// items
|
||||||
|
'items' => ['required', 'array', 'min:1'],
|
||||||
|
'items.*.number' => ['required', 'string'],
|
||||||
|
'items.*.phone_id' => ['nullable', 'integer'],
|
||||||
|
'items.*.payload' => ['nullable', 'array'],
|
||||||
|
'items.*.payload.profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
|
||||||
|
'items.*.payload.sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
|
||||||
|
'items.*.payload.template_id' => ['nullable', 'integer', 'exists:sms_templates,id'],
|
||||||
|
'items.*.payload.delivery_report' => ['nullable', 'boolean'],
|
||||||
|
'items.*.payload.variables' => ['nullable', 'array'],
|
||||||
|
'items.*.payload.body' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ public function rules(): array
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', 'unique:permissions,slug'],
|
'slug' => ['required', 'string', 'max:255', 'alpha_dash', 'unique:permissions,slug'],
|
||||||
'description' => ['nullable', 'string', 'max:500'],
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'roles' => ['sometimes', 'array'],
|
||||||
|
'roles.*' => ['integer', 'exists:roles,id'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreSmsProfileRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('manage-settings') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:190'],
|
||||||
|
'active' => ['sometimes', 'boolean'],
|
||||||
|
'api_username' => ['required', 'string', 'max:190'],
|
||||||
|
'api_password' => ['required', 'string', 'max:500'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreSmsSenderRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('manage-settings') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$pid = (int) $this->input('profile_id');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'profile_id' => ['required', 'integer', 'exists:sms_profiles,id'],
|
||||||
|
'sname' => [
|
||||||
|
'nullable', 'string', 'max:20',
|
||||||
|
Rule::unique('sms_senders', 'sname')->where(fn ($q) => $q->where('profile_id', $pid)),
|
||||||
|
],
|
||||||
|
'phone_number' => ['nullable', 'string', 'max:30'],
|
||||||
|
'description' => ['nullable', 'string', 'max:190'],
|
||||||
|
'active' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreSmsTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('manage-settings') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:190'],
|
||||||
|
'slug' => ['required', 'string', 'max:190', 'alpha_dash', 'unique:sms_templates,slug'],
|
||||||
|
// Content is required unless template allows custom body
|
||||||
|
'content' => [Rule::requiredIf(fn () => ! (bool) $this->input('allow_custom_body')), 'nullable', 'string', 'max:1000'],
|
||||||
|
'variables_json' => ['nullable', 'array'],
|
||||||
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
|
'default_profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
|
||||||
|
'default_sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
|
||||||
|
'allow_custom_body' => ['sometimes', 'boolean'],
|
||||||
|
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||||
|
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class TestSendSmsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('manage-settings') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'to' => ['required', 'string', 'max:30'], // E.164-ish; we can refine later
|
||||||
|
'message' => ['required', 'string', 'max:1000'],
|
||||||
|
'sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
|
||||||
|
'delivery_report' => ['sometimes', 'boolean'],
|
||||||
|
'country_code' => ['nullable', 'string', 'max:5'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class TestSendSmsTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('manage-settings') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'to' => ['required', 'string', 'max:30'],
|
||||||
|
'variables' => ['nullable', 'array'],
|
||||||
|
'profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
|
||||||
|
'sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
|
||||||
|
'delivery_report' => ['sometimes', 'boolean'],
|
||||||
|
'country_code' => ['nullable', 'string', 'max:5'],
|
||||||
|
'custom_content' => ['nullable', 'string', 'max:1000'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@ public function rules(): array
|
|||||||
'date_formats.*' => ['nullable', 'string', 'max:40'],
|
'date_formats.*' => ['nullable', 'string', 'max:40'],
|
||||||
'meta' => ['sometimes', 'array'],
|
'meta' => ['sometimes', 'array'],
|
||||||
'meta.*' => ['nullable'],
|
'meta.*' => ['nullable'],
|
||||||
|
'meta.custom_defaults' => ['nullable', 'array'],
|
||||||
|
'meta.custom_defaults.*' => ['nullable'],
|
||||||
|
'meta.custom_default_types' => ['nullable', 'array'],
|
||||||
|
'meta.custom_default_types.*' => ['nullable', 'in:string,number,date,text'],
|
||||||
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||||
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||||
'activity_note_template' => ['nullable', 'string'],
|
'activity_note_template' => ['nullable', 'string'],
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateEmailTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('update', $this->route('emailTemplate')) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$id = $this->route('emailTemplate')?->id;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'key' => ['required', 'string', 'max:255', 'unique:email_templates,key,'.$id],
|
||||||
|
'subject_template' => ['required', 'string', 'max:1000'],
|
||||||
|
'html_template' => ['nullable', 'string'],
|
||||||
|
'text_template' => ['nullable', 'string'],
|
||||||
|
'entity_types' => ['nullable', 'array'],
|
||||||
|
'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
|
||||||
|
'allow_attachments' => ['sometimes', 'boolean'],
|
||||||
|
'active' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateMailProfileRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() && $this->user()->can('update', $this->route('mail_profile'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['sometimes', 'required', 'string', 'max:190'],
|
||||||
|
'host' => ['sometimes', 'required', 'string', 'max:190'],
|
||||||
|
'port' => ['sometimes', 'required', 'integer', 'between:1,65535'],
|
||||||
|
'encryption' => ['nullable', 'in:ssl,tls,starttls'],
|
||||||
|
'username' => ['nullable', 'string', 'max:190'],
|
||||||
|
'password' => ['nullable', 'string', 'max:512'],
|
||||||
|
'from_address' => ['sometimes', 'required', 'email', 'max:190'],
|
||||||
|
'from_name' => ['nullable', 'string', 'max:190'],
|
||||||
|
'reply_to_address' => ['nullable', 'email', 'max:190'],
|
||||||
|
'reply_to_name' => ['nullable', 'string', 'max:190'],
|
||||||
|
'priority' => ['nullable', 'integer', 'between:0,65535'],
|
||||||
|
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'active' => ['nullable', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdatePermissionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->hasPermission('manage-settings') || $this->user()?->hasRole('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$permissionId = $this->route('permission')->id ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('permissions', 'slug')->ignore($permissionId)],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'roles' => ['sometimes', 'array'],
|
||||||
|
'roles.*' => ['integer', 'exists:roles,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name.required' => 'Ime je obvezno.',
|
||||||
|
'slug.required' => 'Slug je obvezen.',
|
||||||
|
'slug.unique' => 'Slug že obstaja.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ public function rules(): array
|
|||||||
'name' => ['required', 'string', 'max:50'],
|
'name' => ['required', 'string', 'max:50'],
|
||||||
'description' => ['nullable', 'string', 'max:255'],
|
'description' => ['nullable', 'string', 'max:255'],
|
||||||
'active' => ['boolean'],
|
'active' => ['boolean'],
|
||||||
|
'exclude' => ['boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateSmsSenderRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('manage-settings') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$pid = (int) $this->input('profile_id');
|
||||||
|
$id = (int) ($this->route('smsSender')?->id ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'profile_id' => ['required', 'integer', 'exists:sms_profiles,id'],
|
||||||
|
'sname' => [
|
||||||
|
'nullable', 'string', 'max:20',
|
||||||
|
Rule::unique('sms_senders', 'sname')
|
||||||
|
->ignore($id)
|
||||||
|
->where(fn ($q) => $q->where('profile_id', $pid)),
|
||||||
|
],
|
||||||
|
'phone_number' => ['nullable', 'string', 'max:30'],
|
||||||
|
'description' => ['nullable', 'string', 'max:190'],
|
||||||
|
'active' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateSmsTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('manage-settings') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$id = (int) ($this->route('smsTemplate')?->id ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:190'],
|
||||||
|
'slug' => ['required', 'string', 'max:190', 'alpha_dash', Rule::unique('sms_templates', 'slug')->ignore($id)],
|
||||||
|
// Content is required unless template allows custom body
|
||||||
|
'content' => [Rule::requiredIf(fn () => ! (bool) $this->input('allow_custom_body')), 'nullable', 'string', 'max:1000'],
|
||||||
|
'variables_json' => ['nullable', 'array'],
|
||||||
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
|
'default_profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
|
||||||
|
'default_sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
|
||||||
|
'allow_custom_body' => ['sometimes', 'boolean'],
|
||||||
|
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||||
|
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ class PersonCollection extends ResourceCollection
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'data' => $this->collection
|
'data' => $this->collection,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Activity;
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\FieldJob;
|
||||||
|
use App\Models\FieldJobSetting;
|
||||||
|
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\Facades\DB;
|
||||||
|
|
||||||
|
class EndFieldJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $activityId,
|
||||||
|
public ?int $contractId = null,
|
||||||
|
public array $config = []
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$activity = Activity::query()->find($this->activityId);
|
||||||
|
|
||||||
|
// Determine target contract ID
|
||||||
|
$contractId = $this->contractId;
|
||||||
|
if (! $contractId && $activity) {
|
||||||
|
$contractId = $activity->contract_id;
|
||||||
|
}
|
||||||
|
if (! $contractId) {
|
||||||
|
\Log::warning('EndFieldJob: missing contract id', ['activity_id' => $this->activityId]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use latest FieldJobSetting as the source of action/decision/segments
|
||||||
|
$setting = FieldJobSetting::query()->latest('id')->first();
|
||||||
|
|
||||||
|
$triggeredByEvent = (bool) $activity; // this job is invoked from a decision event when an Activity exists
|
||||||
|
|
||||||
|
DB::transaction(function () use ($contractId, $setting, $triggeredByEvent): void {
|
||||||
|
// Find active field job for this contract
|
||||||
|
$job = FieldJob::query()
|
||||||
|
->where('contract_id', $contractId)
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($job) {
|
||||||
|
// Complete the job (updated hook moves segment appropriately)
|
||||||
|
$job->completed_at = now();
|
||||||
|
$job->save();
|
||||||
|
|
||||||
|
// Optionally log a completion activity.
|
||||||
|
// By default, we SKIP creating an extra activity when triggered by a decision event (to avoid duplicates).
|
||||||
|
// To force creation from an event, set config['create_activity_from_event'] = true on the decision event.
|
||||||
|
// For non-event triggers, set config['create_activity'] = true to allow creation.
|
||||||
|
$shouldCreateActivity = $triggeredByEvent
|
||||||
|
? (bool) ($this->config['create_activity_from_event'] ?? false)
|
||||||
|
: (bool) ($this->config['create_activity'] ?? false);
|
||||||
|
|
||||||
|
if ($shouldCreateActivity) {
|
||||||
|
$job->loadMissing('contract');
|
||||||
|
$actionId = optional($job->setting)->action_id ?? optional($setting)->action_id;
|
||||||
|
$decisionId = optional($job->setting)->complete_decision_id ?? optional($setting)->complete_decision_id;
|
||||||
|
if ($actionId && $decisionId && $job->contract) {
|
||||||
|
Activity::create([
|
||||||
|
'due_date' => null,
|
||||||
|
'amount' => null,
|
||||||
|
'note' => 'Terensko opravilo zaključeno',
|
||||||
|
'action_id' => $actionId,
|
||||||
|
'decision_id' => $decisionId,
|
||||||
|
'client_case_id' => $job->contract->client_case_id,
|
||||||
|
'contract_id' => $job->contract_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No active job: still move contract to the configured return segment if available
|
||||||
|
if ($setting && $setting->return_segment_id) {
|
||||||
|
$tmp = new FieldJob;
|
||||||
|
$tmp->contract_id = $contractId;
|
||||||
|
$tmp->moveContractToSegment($setting->return_segment_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
\Log::info('EndFieldJob executed', [
|
||||||
|
'activity_id' => $this->activityId,
|
||||||
|
'contract_id' => $contractId,
|
||||||
|
'config' => $this->config,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\Package;
|
||||||
|
use App\Models\PackageItem;
|
||||||
|
use App\Models\SmsProfile;
|
||||||
|
use App\Models\SmsSender;
|
||||||
|
use App\Models\SmsTemplate;
|
||||||
|
use App\Services\Sms\SmsService;
|
||||||
|
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\Facades\Redis;
|
||||||
|
|
||||||
|
class PackageItemSmsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public int $packageItemId)
|
||||||
|
{
|
||||||
|
$this->onQueue('sms');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(SmsService $sms): 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; // canceled or missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already finalized to avoid double counting on retries
|
||||||
|
if (in_array($item->status, ['sent', 'failed', 'canceled', 'skipped'], true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Mark processing on first entry
|
||||||
|
if ($item->status === 'queued') {
|
||||||
|
$item->status = 'processing';
|
||||||
|
$item->save();
|
||||||
|
$package->increment('processing_count');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = (array) $item->payload_json;
|
||||||
|
$target = (array) $item->target_json;
|
||||||
|
|
||||||
|
$profileId = $payload['profile_id'] ?? null;
|
||||||
|
$senderId = $payload['sender_id'] ?? null;
|
||||||
|
$templateId = $payload['template_id'] ?? null;
|
||||||
|
$deliveryReport = (bool) ($payload['delivery_report'] ?? false);
|
||||||
|
$variables = (array) ($payload['variables'] ?? []);
|
||||||
|
// Enrich variables with contract/account context when available (contracts-based packages)
|
||||||
|
if (! empty($target['contract_id'])) {
|
||||||
|
$contract = Contract::query()->with('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 ?? ''),
|
||||||
|
];
|
||||||
|
// Include contract.meta as flattened key-value pairs for template access
|
||||||
|
if (is_array($contract->meta) && ! empty($contract->meta)) {
|
||||||
|
$variables['contract']['meta'] = $this->flattenMeta($contract->meta);
|
||||||
|
}
|
||||||
|
if ($contract->account) {
|
||||||
|
// Preserve raw values and provide EU-formatted versions for SMS rendering
|
||||||
|
$initialRaw = (string) $contract->account->initial_amount;
|
||||||
|
$balanceRaw = (string) $contract->account->balance_amount;
|
||||||
|
$variables['account'] = [
|
||||||
|
'id' => $contract->account->id,
|
||||||
|
'reference' => $contract->account->reference,
|
||||||
|
// Override placeholders with EU formatted values for SMS
|
||||||
|
'initial_amount' => $sms->formatAmountEu($initialRaw),
|
||||||
|
'balance_amount' => $sms->formatAmountEu($balanceRaw),
|
||||||
|
// Expose raw values too in case templates need them explicitly
|
||||||
|
'initial_amount_raw' => $initialRaw,
|
||||||
|
'balance_amount_raw' => $balanceRaw,
|
||||||
|
'type' => $contract->account->type?->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$bodyOverride = isset($payload['body']) ? trim((string) $payload['body']) : null;
|
||||||
|
if ($bodyOverride === '') {
|
||||||
|
$bodyOverride = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var SmsProfile|null $profile */
|
||||||
|
$profile = $profileId ? SmsProfile::find($profileId) : null;
|
||||||
|
/** @var SmsSender|null $sender */
|
||||||
|
$sender = $senderId ? SmsSender::find($senderId) : null;
|
||||||
|
/** @var SmsTemplate|null $template */
|
||||||
|
$template = $templateId ? SmsTemplate::with(['action', 'decision'])->find($templateId) : null;
|
||||||
|
|
||||||
|
$to = $target['number'] ?? null;
|
||||||
|
if (! is_string($to) || $to === '') {
|
||||||
|
$item->status = 'failed';
|
||||||
|
$item->last_error = 'Missing recipient number.';
|
||||||
|
$item->save();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute throttle key
|
||||||
|
$scope = config('services.sms.throttle.scope', 'global');
|
||||||
|
$provider = config('services.sms.throttle.provider_key', 'smsapi_si');
|
||||||
|
$allow = (int) config('services.sms.throttle.allow', 30);
|
||||||
|
$every = (int) config('services.sms.throttle.every', 60);
|
||||||
|
$jitter = (int) config('services.sms.throttle.jitter_seconds', 2);
|
||||||
|
$key = $scope === 'per_profile' && $profile ? "sms:{$provider}:{$profile->id}" : "sms:{$provider}";
|
||||||
|
|
||||||
|
// Throttle
|
||||||
|
$sendClosure = function () use ($sms, $item, $package, $profile, $sender, $template, $to, $variables, $deliveryReport, $bodyOverride, $target) {
|
||||||
|
// Idempotency key (optional external use)
|
||||||
|
if (empty($item->idempotency_key)) {
|
||||||
|
$hash = sha1(implode('|', [
|
||||||
|
'sms', (string) ($profile?->id ?? ''), (string) ($sender?->id ?? ''), (string) ($template?->id ?? ''), $to, (string) ($bodyOverride ?? ''), json_encode($variables),
|
||||||
|
]));
|
||||||
|
$item->idempotency_key = "pkgitem:{$item->id}:{$hash}";
|
||||||
|
$item->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide whether to use template or raw content
|
||||||
|
$useTemplate = false;
|
||||||
|
if ($template) {
|
||||||
|
if ($bodyOverride) {
|
||||||
|
// If custom body is provided but template does not allow it, force template
|
||||||
|
$useTemplate = ! (bool) ($template->allow_custom_body ?? false);
|
||||||
|
} else {
|
||||||
|
// No custom body provided -> use template
|
||||||
|
$useTemplate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($useTemplate) {
|
||||||
|
$log = $sms->sendFromTemplate(
|
||||||
|
template: $template,
|
||||||
|
to: $to,
|
||||||
|
variables: $variables,
|
||||||
|
profile: $profile,
|
||||||
|
sender: $sender,
|
||||||
|
countryCode: null,
|
||||||
|
deliveryReport: $deliveryReport,
|
||||||
|
clientReference: "pkg:{$item->package_id}:item:{$item->id}",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Either explicit body override or no template
|
||||||
|
$effectiveBody = (string) ($bodyOverride ?? '');
|
||||||
|
if ($effectiveBody === '') {
|
||||||
|
// Avoid provider error for empty body
|
||||||
|
throw new \RuntimeException('Empty SMS body and no template provided.');
|
||||||
|
}
|
||||||
|
if (! $profile && $template) {
|
||||||
|
$profile = $template->defaultProfile;
|
||||||
|
}
|
||||||
|
if (! $profile) {
|
||||||
|
throw new \RuntimeException('Missing SMS profile for raw send.');
|
||||||
|
}
|
||||||
|
$log = $sms->sendRaw(
|
||||||
|
profile: $profile,
|
||||||
|
to: $to,
|
||||||
|
content: $effectiveBody,
|
||||||
|
sender: $sender,
|
||||||
|
countryCode: null,
|
||||||
|
deliveryReport: $deliveryReport,
|
||||||
|
clientReference: "pkg:{$item->package_id}:item:{$item->id}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newStatus = $log->status === 'sent' ? 'sent' : 'failed';
|
||||||
|
$item->status = $newStatus;
|
||||||
|
$item->provider_message_id = $log->provider_message_id;
|
||||||
|
$item->cost = $log->cost;
|
||||||
|
$item->currency = $log->currency;
|
||||||
|
// Persist useful result info including final rendered message for auditing
|
||||||
|
$result = $log->meta ?? [];
|
||||||
|
$result['message'] = $log->message ?? (($useTemplate && isset($template)) ? $sms->renderContent($template->content, $variables) : ($bodyOverride ?? null));
|
||||||
|
$result['template_id'] = $template?->id;
|
||||||
|
$result['render_source'] = $useTemplate ? 'template' : 'body';
|
||||||
|
$item->result_json = $result;
|
||||||
|
$item->last_error = $log->status === 'sent' ? null : ($log->meta['error_message'] ?? 'Failed');
|
||||||
|
$item->save();
|
||||||
|
|
||||||
|
// Create activity if template has action_id and decision_id configured and SMS was sent successfully
|
||||||
|
if ($newStatus === 'sent' && $template && ($template->action_id || $template->decision_id)) {
|
||||||
|
if (! empty($target['contract_id'])) {
|
||||||
|
$contract = Contract::query()->with('clientCase')->find($target['contract_id']);
|
||||||
|
|
||||||
|
if ($contract && $contract->client_case_id) {
|
||||||
|
\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' => "SMS poslan na {$to}: {$result['message']}",
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update package counters atomically
|
||||||
|
if ($newStatus === 'sent') {
|
||||||
|
$package->increment('sent_count');
|
||||||
|
} else {
|
||||||
|
$package->increment('failed_count');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all items processed, finalize package
|
||||||
|
$package->refresh();
|
||||||
|
if (($package->sent_count + $package->failed_count) >= $package->total_items) {
|
||||||
|
$finalStatus = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED;
|
||||||
|
$package->status = $finalStatus;
|
||||||
|
$package->finished_at = now();
|
||||||
|
$package->save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
Redis::throttle($key)->allow($allow)->every($every)->then($sendClosure, function () use ($jitter) {
|
||||||
|
return $this->release(max(1, rand(1, $jitter)));
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Fallback to direct send when Redis unavailable (e.g., test environment)
|
||||||
|
$sendClosure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten nested meta structure into dot-notation key-value pairs.
|
||||||
|
* Extracts 'value' from objects with {title, value, type} structure.
|
||||||
|
* Also creates direct access aliases for nested fields (skipping numeric keys).
|
||||||
|
*/
|
||||||
|
private function flattenMeta(array $meta, string $prefix = ''): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($meta as $key => $value) {
|
||||||
|
$newKey = $prefix === '' ? $key : "{$prefix}.{$key}";
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
// Check if it's a structured meta entry with 'value' field
|
||||||
|
if (isset($value['value'])) {
|
||||||
|
$result[$newKey] = $value['value'];
|
||||||
|
// If parent key is numeric, also create direct alias without the number
|
||||||
|
if ($prefix !== '' && is_numeric($key)) {
|
||||||
|
$result[$key] = $value['value'];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Recursively flatten nested arrays
|
||||||
|
$nested = $this->flattenMeta($value, $newKey);
|
||||||
|
$result = array_merge($result, $nested);
|
||||||
|
|
||||||
|
// If current key is numeric, also flatten without it for easier access
|
||||||
|
if (is_numeric($key)) {
|
||||||
|
$directNested = $this->flattenMeta($value, $prefix);
|
||||||
|
foreach ($directNested as $dk => $dv) {
|
||||||
|
// Only add if not already set (prefer first occurrence)
|
||||||
|
if (! isset($result[$dk])) {
|
||||||
|
$result[$dk] = $dv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$result[$newKey] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Activity;
|
||||||
|
use App\Models\Event as DecisionEventModel;
|
||||||
|
use App\Services\DecisionEvents\DecisionEventContext;
|
||||||
|
use App\Services\DecisionEvents\Registry;
|
||||||
|
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\Facades\DB;
|
||||||
|
|
||||||
|
class RunDecisionEvent implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $activityId,
|
||||||
|
public int $eventId,
|
||||||
|
public string $eventKey,
|
||||||
|
public array $config = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
// Basic idempotency key per activity+event
|
||||||
|
$idempotencyKey = sha1($this->activityId.'|'.$this->eventId.'|'.$this->eventKey);
|
||||||
|
|
||||||
|
// Ensure log table record and uniqueness
|
||||||
|
$exists = DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->first();
|
||||||
|
if ($exists && ($exists->status ?? null) === 'succeeded') {
|
||||||
|
return; // already processed successfully
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$activity = Activity::with(['decision', 'contract', 'clientCase.client', 'user'])->findOrFail($this->activityId);
|
||||||
|
|
||||||
|
if (! $exists) {
|
||||||
|
DB::table('decision_event_logs')->insert([
|
||||||
|
'decision_id' => optional($activity->decision)->id,
|
||||||
|
'event_id' => $this->eventId,
|
||||||
|
'activity_id' => $this->activityId,
|
||||||
|
'handler' => $this->eventKey,
|
||||||
|
'status' => 'queued',
|
||||||
|
'idempotency_key' => $idempotencyKey,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
$exists = (object) ['status' => 'queued'];
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
||||||
|
'status' => 'running',
|
||||||
|
'started_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
$event = DecisionEventModel::findOrFail($this->eventId);
|
||||||
|
|
||||||
|
$handler = Registry::resolve($this->eventKey);
|
||||||
|
$context = new DecisionEventContext(
|
||||||
|
activity: $activity,
|
||||||
|
decision: $activity->decision,
|
||||||
|
contract: $activity->contract,
|
||||||
|
clientCase: $activity->clientCase,
|
||||||
|
client: optional($activity->clientCase)->client,
|
||||||
|
user: $activity->user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$handler->handle($context, $this->config);
|
||||||
|
|
||||||
|
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
||||||
|
'status' => 'succeeded',
|
||||||
|
'finished_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
DB::table('decision_event_logs')->where('idempotency_key', $idempotencyKey)->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => substr($e->getMessage(), 0, 2000),
|
||||||
|
'finished_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
throw $e; // allow retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\EmailLog;
|
||||||
|
use App\Models\EmailLogStatus;
|
||||||
|
use App\Services\EmailSender;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class SendEmailTemplateJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public int $emailLogId)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$log = EmailLog::query()->find($this->emailLogId);
|
||||||
|
if (! $log) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($log->status === EmailLogStatus::Sent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
$log->status = EmailLogStatus::Sending;
|
||||||
|
$log->started_at = now();
|
||||||
|
$log->attempt = (int) ($log->attempt ?: 0) + 1;
|
||||||
|
$log->save();
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var EmailSender $sender */
|
||||||
|
$sender = app(EmailSender::class);
|
||||||
|
$result = $sender->sendFromLog($log);
|
||||||
|
|
||||||
|
$log->status = EmailLogStatus::Sent;
|
||||||
|
$log->sent_at = now();
|
||||||
|
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
||||||
|
$log->save();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$log->status = EmailLogStatus::Failed;
|
||||||
|
$log->failed_at = now();
|
||||||
|
$log->error_message = $e->getMessage();
|
||||||
|
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
||||||
|
$log->save();
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\ClientCase;
|
||||||
|
use App\Models\SmsProfile;
|
||||||
|
use App\Models\SmsSender;
|
||||||
|
use App\Models\SmsTemplate;
|
||||||
|
use App\Services\Sms\SmsService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
|
||||||
|
class SendSmsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $profileId,
|
||||||
|
public string $to,
|
||||||
|
public string $content,
|
||||||
|
public ?int $senderId = null,
|
||||||
|
public ?string $countryCode = null,
|
||||||
|
public bool $deliveryReport = false,
|
||||||
|
public ?string $clientReference = null,
|
||||||
|
// For optional activity creation (case UI only)
|
||||||
|
public ?int $templateId = null,
|
||||||
|
public ?int $clientCaseId = null,
|
||||||
|
public ?int $userId = null,
|
||||||
|
// If provided, update this activity on failure instead of creating a new one
|
||||||
|
public ?int $activityId = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(SmsService $sms): void
|
||||||
|
{
|
||||||
|
// Resolve models
|
||||||
|
/** @var SmsProfile|null $profile */
|
||||||
|
$profile = SmsProfile::find($this->profileId);
|
||||||
|
if (! $profile) {
|
||||||
|
return; // nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var SmsSender|null $sender */
|
||||||
|
$sender = $this->senderId ? SmsSender::find($this->senderId) : null;
|
||||||
|
|
||||||
|
// Apply Redis throttle from config to avoid provider rate limits
|
||||||
|
$scope = config('services.sms.throttle.scope', 'global');
|
||||||
|
$provider = config('services.sms.throttle.provider_key', 'smsapi_si');
|
||||||
|
$allow = (int) config('services.sms.throttle.allow', 30);
|
||||||
|
$every = (int) config('services.sms.throttle.every', 60);
|
||||||
|
$jitter = (int) config('services.sms.throttle.jitter_seconds', 2);
|
||||||
|
$key = $scope === 'per_profile' && $profile ? "sms:{$provider}:{$profile->id}" : "sms:{$provider}";
|
||||||
|
|
||||||
|
$log = null;
|
||||||
|
try {
|
||||||
|
Redis::throttle($key)->allow($allow)->every($every)->then(function () use (&$log, $sms, $profile, $sender) {
|
||||||
|
// Send and get log (handles queued->sent/failed transitions internally)
|
||||||
|
$log = $sms->sendRaw(
|
||||||
|
profile: $profile,
|
||||||
|
to: $this->to,
|
||||||
|
content: $this->content,
|
||||||
|
sender: $sender,
|
||||||
|
countryCode: $this->countryCode,
|
||||||
|
deliveryReport: $this->deliveryReport,
|
||||||
|
clientReference: $this->clientReference,
|
||||||
|
);
|
||||||
|
}, function () use ($jitter) {
|
||||||
|
return $this->release(max(1, rand(1, $jitter)));
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Fallback if Redis is unavailable in test or local env
|
||||||
|
$log = $sms->sendRaw(
|
||||||
|
profile: $profile,
|
||||||
|
to: $this->to,
|
||||||
|
content: $this->content,
|
||||||
|
sender: $sender,
|
||||||
|
countryCode: $this->countryCode,
|
||||||
|
deliveryReport: $this->deliveryReport,
|
||||||
|
clientReference: $this->clientReference,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Update an existing pre-created activity ONLY on failure when activityId is provided
|
||||||
|
if ($this->activityId && $log && ($log->status === 'failed')) {
|
||||||
|
try {
|
||||||
|
$activity = \App\Models\Activity::find($this->activityId);
|
||||||
|
if ($activity) {
|
||||||
|
$note = (string) ($activity->note ?? '');
|
||||||
|
$append = sprintf(
|
||||||
|
' | Napaka: %s',
|
||||||
|
'SMS ni bil poslan!'
|
||||||
|
);
|
||||||
|
$activity->update(['note' => $note.$append]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::warning('SendSmsJob activity update failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'activity_id' => $this->activityId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no pre-created activity is provided and invoked from the case UI with a selected template, create an Activity
|
||||||
|
if (! $this->activityId && $this->templateId && $this->clientCaseId && $log) {
|
||||||
|
try {
|
||||||
|
/** @var SmsTemplate|null $template */
|
||||||
|
$template = SmsTemplate::find($this->templateId);
|
||||||
|
/** @var ClientCase|null $case */
|
||||||
|
$case = ClientCase::find($this->clientCaseId);
|
||||||
|
if ($template && $case) {
|
||||||
|
$note = '';
|
||||||
|
if ($log->status === 'sent') {
|
||||||
|
$note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||||
|
} elseif ($log->status === 'failed') {
|
||||||
|
$note = sprintf(
|
||||||
|
'Št: %s | Telo: %s | Napaka: %s',
|
||||||
|
(string) $this->to,
|
||||||
|
(string) $this->content,
|
||||||
|
'SMS ni bil poslan!'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$case->activities()->create([
|
||||||
|
'note' => $note,
|
||||||
|
'action_id' => $template->action_id,
|
||||||
|
'decision_id' => $template->decision_id,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::warning('SendSmsJob activity creation failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'client_case_id' => $this->clientCaseId,
|
||||||
|
'template_id' => $this->templateId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\MailProfile;
|
||||||
|
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\Facades\Log;
|
||||||
|
|
||||||
|
class TestMailProfileConnection implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $timeout = 30;
|
||||||
|
|
||||||
|
public function __construct(public int $mailProfileId) {}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$profile = MailProfile::find($this->mailProfileId);
|
||||||
|
if (! $profile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->performSmtpAuthTest($profile);
|
||||||
|
$profile->forceFill([
|
||||||
|
'test_status' => 'success',
|
||||||
|
'test_checked_at' => now(),
|
||||||
|
'last_success_at' => now(),
|
||||||
|
'last_error_message' => null,
|
||||||
|
])->save();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('mail_profile.test_failed', [
|
||||||
|
'id' => $profile->id,
|
||||||
|
'host' => $profile->host,
|
||||||
|
'port' => $profile->port,
|
||||||
|
'encryption' => $profile->encryption,
|
||||||
|
// Intentionally NOT logging username/password
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
$profile->forceFill([
|
||||||
|
'test_status' => 'failed',
|
||||||
|
'test_checked_at' => now(),
|
||||||
|
'last_error_at' => now(),
|
||||||
|
'last_error_message' => substr($e->getMessage(), 0, 400),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a real SMTP handshake + (optional) STARTTLS + AUTH LOGIN cycle.
|
||||||
|
* Throws on any protocol / auth failure.
|
||||||
|
*/
|
||||||
|
protected function performSmtpAuthTest(MailProfile $profile): void
|
||||||
|
{
|
||||||
|
$host = $profile->host;
|
||||||
|
$port = (int) $profile->port;
|
||||||
|
$encryption = strtolower((string) ($profile->encryption ?? ''));
|
||||||
|
$username = $profile->username;
|
||||||
|
$password = trim($profile->decryptPassword() ?? '');
|
||||||
|
if (app()->environment('local')) {
|
||||||
|
Log::debug('mail_profile.test.debug_password_length', [
|
||||||
|
'id' => $profile->id,
|
||||||
|
'len' => strlen($password),
|
||||||
|
'empty' => $password === '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($username === '' || $password === '') {
|
||||||
|
throw new \RuntimeException('Missing username or password (decryption may have failed or no password set)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$remote = ($encryption === 'ssl') ? 'ssl://'.$host : $host;
|
||||||
|
$errno = 0;
|
||||||
|
$errstr = '';
|
||||||
|
$socket = @fsockopen($remote, $port, $errno, $errstr, 15);
|
||||||
|
if (! $socket) {
|
||||||
|
throw new \RuntimeException("Connect failed: $errstr ($errno)");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
stream_set_timeout($socket, 15);
|
||||||
|
$this->expect($socket, [220], 'greeting');
|
||||||
|
|
||||||
|
$ehloDomain = gethostname() ?: 'localhost';
|
||||||
|
$this->command($socket, "EHLO $ehloDomain\r\n", [250], 'EHLO');
|
||||||
|
|
||||||
|
if ($encryption === 'tls') {
|
||||||
|
$this->command($socket, "STARTTLS\r\n", [220], 'STARTTLS');
|
||||||
|
if (! stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||||
|
throw new \RuntimeException('STARTTLS negotiation failed');
|
||||||
|
}
|
||||||
|
// Re-issue EHLO after TLS per RFC
|
||||||
|
$this->command($socket, "EHLO $ehloDomain\r\n", [250], 'post-STARTTLS EHLO');
|
||||||
|
}
|
||||||
|
|
||||||
|
// AUTH LOGIN flow
|
||||||
|
$this->command($socket, "AUTH LOGIN\r\n", [334], 'AUTH LOGIN');
|
||||||
|
$this->command($socket, base64_encode($username)."\r\n", [334], 'AUTH username');
|
||||||
|
$authResp = $this->command($socket, base64_encode($password)."\r\n", [235], 'AUTH password');
|
||||||
|
|
||||||
|
// Cleanly quit
|
||||||
|
$this->command($socket, "QUIT\r\n", [221], 'QUIT');
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fclose($socket);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a command and assert expected code(s). Returns the last response line.
|
||||||
|
*/
|
||||||
|
protected function command($socket, string $cmd, array $expect, string $context): string
|
||||||
|
{
|
||||||
|
fwrite($socket, $cmd);
|
||||||
|
|
||||||
|
return $this->expect($socket, $expect, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read lines until final line (code + space). Validate code.
|
||||||
|
*/
|
||||||
|
protected function expect($socket, array $expectedCodes, string $context): string
|
||||||
|
{
|
||||||
|
$lines = [];
|
||||||
|
while (true) {
|
||||||
|
$line = fgets($socket, 2048);
|
||||||
|
if ($line === false) {
|
||||||
|
throw new \RuntimeException("SMTP read failure during $context");
|
||||||
|
}
|
||||||
|
$lines[] = rtrim($line, "\r\n");
|
||||||
|
if (preg_match('/^(\d{3})([ -])/', $line, $m)) {
|
||||||
|
$code = (int) $m[1];
|
||||||
|
$more = $m[2] === '-';
|
||||||
|
if (! $more) {
|
||||||
|
if (! in_array($code, $expectedCodes, true)) {
|
||||||
|
throw new \RuntimeException("Unexpected SMTP code $code during $context: ".implode(' | ', $lines));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($lines) > 50) {
|
||||||
|
throw new \RuntimeException('SMTP response too verbose');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use App\Events\ClientCaseToTerrain;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
|
|
||||||
class AddClientCaseToTerrain
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Create the event listener.
|
|
||||||
*/
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the event.
|
|
||||||
*/
|
|
||||||
public function handle(ClientCaseToTerrain $event): void
|
|
||||||
{
|
|
||||||
$clientCase = $event->clientCase;
|
|
||||||
$segment = \App\Models\Segment::where('name','terrain')->firstOrFail();
|
|
||||||
|
|
||||||
if( $segment ) {
|
|
||||||
$clientCase->segments()->detach($segment->id);
|
|
||||||
$clientCase->segments()->attach(
|
|
||||||
$segment->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
\Log::info("Added contract to terrain", ['contract_id' => $clientCase->id, 'segment' => $segment->name ]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function failed(ClientCaseToTerrain $event, $exception)
|
|
||||||
{
|
|
||||||
\Log::error('Failed to update inventory', ['contract_id' => $event->clientCase->id, 'error' => $exception->getMessage()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use App\Events\ContractToTerrain;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
|
|
||||||
class AddContractToTerrain implements ShouldQueue
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Create the event listener.
|
|
||||||
*/
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the event.
|
|
||||||
*/
|
|
||||||
public function handle(ContractToTerrain $event): void
|
|
||||||
{
|
|
||||||
$contract = $event->contract;
|
|
||||||
$segment = $event->segment->where('name', 'terrain')->firstOrFail();
|
|
||||||
|
|
||||||
if($segment) {
|
|
||||||
$contract->segments()->attach($segment->id);
|
|
||||||
//\Log::info("Added contract to terrain", ['contract_id' => $contract->id, 'segment' => $segment->name ]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function failed(ContractToTerrain $event, $exception)
|
|
||||||
{
|
|
||||||
//\Log::error('Failed to update inventory', ['contract_id' => $event->contract->id, 'error' => $exception->getMessage()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Events\ChangeContractSegment;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ApplyChangeContractSegment implements ShouldQueue
|
||||||
|
{
|
||||||
|
public function handle(ChangeContractSegment $event): void
|
||||||
|
{
|
||||||
|
$contract = $event->contract;
|
||||||
|
$segmentId = (int) $event->segmentId;
|
||||||
|
if ($segmentId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($contract, $segmentId, $event) {
|
||||||
|
if ($event->deactivatePrevious) {
|
||||||
|
DB::table('contract_segment')
|
||||||
|
->where('contract_id', $contract->id)
|
||||||
|
->where('active', 1)
|
||||||
|
->update(['active' => 0, 'updated_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = DB::table('contract_segment')
|
||||||
|
->where('contract_id', $contract->id)
|
||||||
|
->where('segment_id', $segmentId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
DB::table('contract_segment')
|
||||||
|
->where('id', $existing->id)
|
||||||
|
->update(['active' => 1, 'updated_at' => now()]);
|
||||||
|
} else {
|
||||||
|
DB::table('contract_segment')->insert([
|
||||||
|
'contract_id' => $contract->id,
|
||||||
|
'segment_id' => $segmentId,
|
||||||
|
'active' => 1,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Events\ActivityDecisionApplied;
|
||||||
|
use App\Jobs\RunDecisionEvent;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
|
||||||
|
class TriggerDecisionEvents
|
||||||
|
{
|
||||||
|
public function handle(ActivityDecisionApplied $event): void
|
||||||
|
{
|
||||||
|
$activity = $event->activity->loadMissing(['decision.events' => function ($q) {
|
||||||
|
$q->wherePivot('active', true);
|
||||||
|
}, 'contract', 'clientCase.client', 'user']);
|
||||||
|
|
||||||
|
$decision = $activity->decision;
|
||||||
|
if (! $decision) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$events = $decision->events;
|
||||||
|
if ($events->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by run_order when provided; otherwise keep natural order
|
||||||
|
$sorted = $events->sortBy(function ($ev) {
|
||||||
|
return $ev->pivot?->run_order ?? PHP_INT_MAX;
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
$jobs = [];
|
||||||
|
foreach ($sorted as $ev) {
|
||||||
|
$base = is_array($ev->config ?? null) ? $ev->config : [];
|
||||||
|
$pivotCfgRaw = $ev->pivot?->config ?? null;
|
||||||
|
$pivotCfg = is_array($pivotCfgRaw)
|
||||||
|
? $pivotCfgRaw
|
||||||
|
: (is_string($pivotCfgRaw) ? (json_decode($pivotCfgRaw, true) ?: []) : []);
|
||||||
|
$effectiveConfig = array_replace_recursive($base, $pivotCfg);
|
||||||
|
|
||||||
|
$jobs[] = new RunDecisionEvent(
|
||||||
|
activityId: $activity->id,
|
||||||
|
eventId: $ev->id,
|
||||||
|
eventKey: (string) ($ev->key ?? ''),
|
||||||
|
config: $effectiveConfig,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any event has a finite run_order, chain to enforce order; else dispatch in parallel
|
||||||
|
$hasOrder = $sorted->contains(fn ($ev) => $ev->pivot?->run_order !== null);
|
||||||
|
|
||||||
|
// Run synchronously for local/dev/testing (or when debug is on) to ensure immediate effects without a queue worker
|
||||||
|
$shouldRunSync = app()->environment(['local', 'development', 'dev', 'testing'])
|
||||||
|
|| (bool) config('app.debug')
|
||||||
|
|| config('queue.default') === 'sync';
|
||||||
|
|
||||||
|
if ($hasOrder) {
|
||||||
|
if ($shouldRunSync) {
|
||||||
|
foreach ($jobs as $job) {
|
||||||
|
Bus::dispatchSync($job);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Bus::chain($jobs)->dispatch();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($shouldRunSync) {
|
||||||
|
foreach ($jobs as $job) {
|
||||||
|
Bus::dispatchSync($job);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($jobs as $job) {
|
||||||
|
dispatch($job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,15 @@ class Account extends Model
|
|||||||
'balance_amount',
|
'balance_amount',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'initial_amount' => 'decimal:4',
|
||||||
|
'balance_amount' => 'decimal:4',
|
||||||
|
'promise_date' => 'date',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function debtor(): BelongsTo
|
public function debtor(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\Person\Person::class, 'debtor_id');
|
return $this->belongsTo(\App\Models\Person\Person::class, 'debtor_id');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class Action extends Model
|
|||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\ActionFactory> */
|
/** @use HasFactory<\Database\Factories\ActionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use Searchable;
|
use Searchable;
|
||||||
|
|
||||||
protected $fillable = ['name', 'color_tag', 'segment_id'];
|
protected $fillable = ['name', 'color_tag', 'segment_id'];
|
||||||
@@ -31,5 +32,4 @@ public function activities(): HasMany
|
|||||||
{
|
{
|
||||||
return $this->hasMany(\App\Models\Activity::class);
|
return $this->hasMany(\App\Models\Activity::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ protected static function booted()
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static::created(function (Activity $activity) {
|
||||||
|
if (! empty($activity->decision_id)) {
|
||||||
|
event(new \App\Events\ActivityDecisionApplied($activity));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function action(): BelongsTo
|
public function action(): BelongsTo
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ActivityNotificationRead extends Model
|
||||||
|
{
|
||||||
|
//
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id', 'activity_id', 'due_date', 'read_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'due_date' => 'date',
|
||||||
|
'read_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activity(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Activity::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,22 +3,23 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Traits\Uuid;
|
use App\Traits\Uuid;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Laravel\Scout\Searchable;
|
use Laravel\Scout\Searchable;
|
||||||
|
|
||||||
class Client extends Model
|
class Client extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\ClientFactory> */
|
/** @use HasFactory<\Database\Factories\ClientFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use Uuid;
|
|
||||||
use Searchable;
|
use Searchable;
|
||||||
|
use Uuid;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'person_id'
|
'person_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -26,7 +27,6 @@ class Client extends Model
|
|||||||
'person_id',
|
'person_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
protected function makeAllSearchableUsing(Builder $query): Builder
|
protected function makeAllSearchableUsing(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->with('person');
|
return $query->with('person');
|
||||||
@@ -37,11 +37,10 @@ public function toSearchableArray(): array
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'person.full_name' => '',
|
'person.full_name' => '',
|
||||||
'person_addresses.address' => ''
|
'person_addresses.address' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function person(): BelongsTo
|
public function person(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\Person\Person::class);
|
return $this->belongsTo(\App\Models\Person\Person::class);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Traits\Uuid;
|
use App\Traits\Uuid;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@@ -27,6 +28,7 @@ class Contract extends Model
|
|||||||
'client_case_id',
|
'client_case_id',
|
||||||
'type_id',
|
'type_id',
|
||||||
'description',
|
'description',
|
||||||
|
'meta',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -35,6 +37,47 @@ class Contract extends Model
|
|||||||
'type_id',
|
'type_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'meta' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize start_date inputs to Y-m-d (or null) on assignment.
|
||||||
|
*/
|
||||||
|
protected function startDate(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
set: function ($value) {
|
||||||
|
if (is_null($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$str = is_string($value) ? $value : (string) $value;
|
||||||
|
|
||||||
|
return \App\Services\DateNormalizer::toDate($str);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize end_date inputs to Y-m-d (or null) on assignment.
|
||||||
|
*/
|
||||||
|
protected function endDate(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
set: function ($value) {
|
||||||
|
if (is_null($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$str = is_string($value) ? $value : (string) $value;
|
||||||
|
|
||||||
|
return \App\Services\DateNormalizer::toDate($str);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function type(): BelongsTo
|
public function type(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\ContractType::class, 'type_id');
|
return $this->belongsTo(\App\Models\ContractType::class, 'type_id');
|
||||||
@@ -53,8 +96,14 @@ public function segments(): BelongsToMany
|
|||||||
->wherePivot('active', true);
|
->wherePivot('active', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function attachedSegments(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(\App\Models\Segment::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function account(): HasOne
|
public function account(): HasOne
|
||||||
{
|
{
|
||||||
|
// Use latestOfMany to always surface newest account snapshot if multiple exist.
|
||||||
return $this->hasOne(\App\Models\Account::class)
|
return $this->hasOne(\App\Models\Account::class)
|
||||||
->latestOfMany()
|
->latestOfMany()
|
||||||
->with('type');
|
->with('type');
|
||||||
@@ -70,6 +119,18 @@ public function documents(): MorphMany
|
|||||||
return $this->morphMany(\App\Models\Document::class, 'documentable');
|
return $this->morphMany(\App\Models\Document::class, 'documentable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function fieldJobs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(\App\Models\FieldJob::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function latestObject(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(\App\Models\CaseObject::class)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->latest();
|
||||||
|
}
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::created(function (Contract $contract): void {
|
static::created(function (Contract $contract): void {
|
||||||
|
|||||||
+16
-2
@@ -13,7 +13,14 @@ class Decision extends Model
|
|||||||
/** @use HasFactory<\Database\Factories\DecisionFactory> */
|
/** @use HasFactory<\Database\Factories\DecisionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = ['name', 'color_tag'];
|
protected $fillable = ['name', 'color_tag', 'auto_mail', 'email_template_id'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'auto_mail' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function actions(): BelongsToMany
|
public function actions(): BelongsToMany
|
||||||
{
|
{
|
||||||
@@ -22,11 +29,18 @@ public function actions(): BelongsToMany
|
|||||||
|
|
||||||
public function events(): BelongsToMany
|
public function events(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(\App\Models\Event::class);
|
return $this->belongsToMany(\App\Models\Event::class)
|
||||||
|
->withPivot(['run_order', 'active', 'config'])
|
||||||
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function activities(): HasMany
|
public function activities(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Models\Activity::class);
|
return $this->hasMany(\App\Models\Activity::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function emailTemplate(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Models\EmailTemplate::class, 'email_template_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ class DocumentSetting extends Model
|
|||||||
'preview_enabled',
|
'preview_enabled',
|
||||||
'whitelist',
|
'whitelist',
|
||||||
'date_formats',
|
'date_formats',
|
||||||
|
'custom_defaults',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'preview_enabled' => 'boolean',
|
'preview_enabled' => 'boolean',
|
||||||
'whitelist' => 'array',
|
'whitelist' => 'array',
|
||||||
'date_formats' => 'array',
|
'date_formats' => 'array',
|
||||||
|
'custom_defaults' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function instance(): self
|
public static function instance(): self
|
||||||
@@ -30,6 +32,7 @@ public static function instance(): self
|
|||||||
'preview_enabled' => config('documents.preview.enabled', true),
|
'preview_enabled' => config('documents.preview.enabled', true),
|
||||||
'whitelist' => config('documents.whitelist'),
|
'whitelist' => config('documents.whitelist'),
|
||||||
'date_formats' => [],
|
'date_formats' => [],
|
||||||
|
'custom_defaults' => [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class Email extends Model
|
|||||||
'is_primary',
|
'is_primary',
|
||||||
'is_active',
|
'is_active',
|
||||||
'valid',
|
'valid',
|
||||||
|
'receive_auto_mails',
|
||||||
'verified_at',
|
'verified_at',
|
||||||
'preferences',
|
'preferences',
|
||||||
'meta',
|
'meta',
|
||||||
@@ -27,6 +28,7 @@ class Email extends Model
|
|||||||
'is_primary' => 'boolean',
|
'is_primary' => 'boolean',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'valid' => 'boolean',
|
'valid' => 'boolean',
|
||||||
|
'receive_auto_mails' => 'boolean',
|
||||||
'verified_at' => 'datetime',
|
'verified_at' => 'datetime',
|
||||||
'preferences' => 'array',
|
'preferences' => 'array',
|
||||||
'meta' => 'array',
|
'meta' => 'array',
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
|
enum EmailLogStatus: string
|
||||||
|
{
|
||||||
|
case Queued = 'queued';
|
||||||
|
case Sending = 'sending';
|
||||||
|
case Sent = 'sent';
|
||||||
|
case Failed = 'failed';
|
||||||
|
case Bounced = 'bounced';
|
||||||
|
case Deferred = 'deferred';
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmailLog extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid',
|
||||||
|
'template_id',
|
||||||
|
'mail_profile_id',
|
||||||
|
'user_id',
|
||||||
|
'correlation_id',
|
||||||
|
'to_email',
|
||||||
|
'to_recipients',
|
||||||
|
'to_name',
|
||||||
|
'cc',
|
||||||
|
'bcc',
|
||||||
|
'from_email',
|
||||||
|
'from_name',
|
||||||
|
'reply_to',
|
||||||
|
'subject',
|
||||||
|
'body_html_hash',
|
||||||
|
'body_text_preview',
|
||||||
|
'attachments',
|
||||||
|
'embed_mode',
|
||||||
|
'status',
|
||||||
|
'error_code',
|
||||||
|
'error_message',
|
||||||
|
'transport',
|
||||||
|
'headers',
|
||||||
|
'attempt',
|
||||||
|
'duration_ms',
|
||||||
|
'client_id',
|
||||||
|
'client_case_id',
|
||||||
|
'contract_id',
|
||||||
|
'extra_context',
|
||||||
|
'queued_at',
|
||||||
|
'started_at',
|
||||||
|
'sent_at',
|
||||||
|
'failed_at',
|
||||||
|
'ip',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => EmailLogStatus::class,
|
||||||
|
'cc' => 'array',
|
||||||
|
'bcc' => 'array',
|
||||||
|
'to_recipients' => 'array',
|
||||||
|
'attachments' => 'array',
|
||||||
|
'headers' => 'array',
|
||||||
|
'extra_context' => 'array',
|
||||||
|
'attempt' => 'integer',
|
||||||
|
'duration_ms' => 'integer',
|
||||||
|
'queued_at' => 'datetime',
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'sent_at' => 'datetime',
|
||||||
|
'failed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function template(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(EmailTemplate::class, 'template_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function body(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(EmailLogBody::class, 'email_log_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class EmailLogBody extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'email_log_id',
|
||||||
|
'body_html',
|
||||||
|
'body_text',
|
||||||
|
'inline_css',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'inline_css' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(EmailLog::class, 'email_log_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
|
||||||
|
class EmailTemplate extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'key',
|
||||||
|
'subject_template',
|
||||||
|
'html_template',
|
||||||
|
'text_template',
|
||||||
|
'entity_types',
|
||||||
|
'allow_attachments',
|
||||||
|
'active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'active' => 'boolean',
|
||||||
|
'entity_types' => 'array',
|
||||||
|
'allow_attachments' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function documents(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Document::class, 'documentable');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,18 @@ class Event extends Model
|
|||||||
/** @use HasFactory<\Database\Factories\EventFactory> */
|
/** @use HasFactory<\Database\Factories\EventFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name', 'key', 'description', 'active', 'config',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'active' => 'boolean',
|
||||||
|
'config' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function decisions(): BelongsToMany
|
public function decisions(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(\App\Models\Decision::class);
|
return $this->belongsToMany(\App\Models\Decision::class);
|
||||||
|
|||||||
@@ -24,13 +24,17 @@ class FieldJob extends Model
|
|||||||
'priority',
|
'priority',
|
||||||
'notes',
|
'notes',
|
||||||
'address_snapshot ',
|
'address_snapshot ',
|
||||||
|
'last_activity',
|
||||||
|
'added_activity'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'assigned_at' => 'date',
|
'assigned_at' => 'datetime',
|
||||||
'completed_at' => 'date',
|
'completed_at' => 'datetime',
|
||||||
'cancelled_at' => 'date',
|
'cancelled_at' => 'datetime',
|
||||||
'priority' => 'boolean',
|
'priority' => 'boolean',
|
||||||
|
'last_activity' => 'datetime',
|
||||||
|
'added_activity' => 'boolean',
|
||||||
'address_snapshot ' => 'array',
|
'address_snapshot ' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -90,7 +94,8 @@ public function user(): BelongsTo
|
|||||||
|
|
||||||
public function contract(): BelongsTo
|
public function contract(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Contract::class, 'contract_id');
|
return $this->belongsTo(Contract::class, 'contract_id')
|
||||||
|
->where('active', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class Import extends Model
|
|||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'uuid', 'user_id', 'import_template_id', 'client_id', 'source_type', 'file_name', 'original_name', 'disk', 'path', 'size', 'sheet_name', 'status', 'reactivate', 'total_rows', 'valid_rows', 'invalid_rows', 'imported_rows', 'started_at', 'finished_at', 'failed_at', 'error_summary', 'meta',
|
'uuid', 'user_id', 'import_template_id', 'client_id', 'source_type', 'file_name', 'original_name', 'disk', 'path', 'size', 'sheet_name', 'status', 'reactivate', 'show_missing', 'total_rows', 'valid_rows', 'invalid_rows', 'imported_rows', 'started_at', 'finished_at', 'failed_at', 'error_summary', 'meta',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -22,6 +22,7 @@ class Import extends Model
|
|||||||
'finished_at' => 'datetime',
|
'finished_at' => 'datetime',
|
||||||
'failed_at' => 'datetime',
|
'failed_at' => 'datetime',
|
||||||
'reactivate' => 'boolean',
|
'reactivate' => 'boolean',
|
||||||
|
'show_missing' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class ImportEntity extends Model
|
|||||||
'fields',
|
'fields',
|
||||||
'field_aliases',
|
'field_aliases',
|
||||||
'aliases',
|
'aliases',
|
||||||
|
'supports_multiple',
|
||||||
|
'meta',
|
||||||
'rules',
|
'rules',
|
||||||
'ui',
|
'ui',
|
||||||
];
|
];
|
||||||
@@ -21,6 +23,8 @@ class ImportEntity extends Model
|
|||||||
'fields' => 'array',
|
'fields' => 'array',
|
||||||
'field_aliases' => 'array',
|
'field_aliases' => 'array',
|
||||||
'aliases' => 'array',
|
'aliases' => 'array',
|
||||||
|
'supports_multiple' => 'boolean',
|
||||||
|
'meta' => 'boolean',
|
||||||
'rules' => 'array',
|
'rules' => 'array',
|
||||||
'ui' => 'array',
|
'ui' => 'array',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class ImportEvent extends Model
|
|||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'import_id','user_id','event','level','message','context','import_row_id'
|
'import_id', 'user_id', 'event', 'level', 'message', 'context', 'import_row_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class ImportTemplateMapping extends Model
|
|||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'import_template_id', 'entity', 'source_column', 'target_field', 'transform', 'apply_mode', 'options', 'position'
|
'import_template_id', 'entity', 'source_column', 'target_field', 'transform', 'apply_mode', 'options', 'position',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class MailProfile extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
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',
|
||||||
|
'last_success_at', 'last_error_at', 'last_error_message', 'failover_to_id', 'test_status', 'test_checked_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'active' => 'boolean',
|
||||||
|
'last_success_at' => 'datetime',
|
||||||
|
'last_error_at' => 'datetime',
|
||||||
|
'test_checked_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'encrypted_password',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::created(function (MailProfile $profile): void {
|
||||||
|
|
||||||
|
\Log::info('mail_profile.created', [
|
||||||
|
'id' => $profile->id,
|
||||||
|
'name' => $profile->name,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
static::updated(function (MailProfile $profile): void {
|
||||||
|
\Log::info('mail_profile.updated', [
|
||||||
|
'id' => $profile->id,
|
||||||
|
'name' => $profile->name,
|
||||||
|
'dirty' => $profile->getDirty(),
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
static::deleted(function (MailProfile $profile): void {
|
||||||
|
\Log::warning('mail_profile.deleted', [
|
||||||
|
'id' => $profile->id,
|
||||||
|
'name' => $profile->name,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failoverTo()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(self::class, 'failover_to_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write-only password setter
|
||||||
|
public function setPasswordAttribute(string $plain): void
|
||||||
|
{
|
||||||
|
$this->attributes['encrypted_password'] = app(\App\Services\MailSecretEncrypter::class)->encrypt($plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decryptPassword(): ?string
|
||||||
|
{
|
||||||
|
if (! isset($this->attributes['encrypted_password'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(\App\Services\MailSecretEncrypter::class)->decrypt($this->attributes['encrypted_password']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Package extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid', 'type', 'status', 'name', 'description', 'meta',
|
||||||
|
'total_items', 'processing_count', 'sent_count', 'failed_count',
|
||||||
|
'created_by', 'finished_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'meta' => 'array',
|
||||||
|
'finished_at' => 'datetime',
|
||||||
|
'total_items' => 'integer',
|
||||||
|
'processing_count' => 'integer',
|
||||||
|
'sent_count' => 'integer',
|
||||||
|
'failed_count' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function items()
|
||||||
|
{
|
||||||
|
return $this->hasMany(PackageItem::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public const TYPE_SMS = 'sms';
|
||||||
|
|
||||||
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
|
||||||
|
public const STATUS_QUEUED = 'queued';
|
||||||
|
|
||||||
|
public const STATUS_RUNNING = 'running';
|
||||||
|
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
|
||||||
|
public const STATUS_FAILED = 'failed';
|
||||||
|
|
||||||
|
public const STATUS_CANCELED = 'canceled';
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class PackageItem extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'package_id', 'status', 'target_json', 'payload_json', 'attempts', 'last_error', 'result_json', 'provider_message_id', 'cost', 'currency', 'idempotency_key',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'target_json' => 'array',
|
||||||
|
'payload_json' => 'array',
|
||||||
|
'result_json' => 'array',
|
||||||
|
'attempts' => 'integer',
|
||||||
|
'cost' => 'decimal:4',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function package()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Package::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,30 +3,36 @@
|
|||||||
namespace App\Models\Person;
|
namespace App\Models\Person;
|
||||||
|
|
||||||
use App\Traits\Uuid;
|
use App\Traits\Uuid;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
use Laravel\Scout\Attributes\SearchUsingFullText;
|
||||||
use Laravel\Scout\Searchable;
|
use Laravel\Scout\Searchable;
|
||||||
|
|
||||||
class Person extends Model
|
class Person extends Model
|
||||||
{
|
{
|
||||||
use HasApiTokens;
|
use HasApiTokens;
|
||||||
|
|
||||||
/** @use HasFactory<\Database\Factories\Person/PersonFactory> */
|
/** @use HasFactory<\Database\Factories\Person/PersonFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use Uuid;
|
|
||||||
use Searchable;
|
|
||||||
|
|
||||||
/**
|
use Searchable;
|
||||||
|
use SoftDeletes;
|
||||||
|
use Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
* @var array<int, string>
|
* @var array<int, string>
|
||||||
*/
|
*/
|
||||||
protected $table = 'person';
|
protected $table = 'person';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'nu',
|
'nu',
|
||||||
'first_name',
|
'first_name',
|
||||||
@@ -39,18 +45,19 @@ class Person extends Model
|
|||||||
'description',
|
'description',
|
||||||
'group_id',
|
'group_id',
|
||||||
'type_id',
|
'type_id',
|
||||||
'user_id'
|
'user_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'id',
|
'id',
|
||||||
'deleted',
|
'deleted',
|
||||||
'user_id'
|
'user_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted(){
|
protected static function booted()
|
||||||
|
{
|
||||||
static::creating(function (Person $person) {
|
static::creating(function (Person $person) {
|
||||||
if(!isset($person->user_id)){
|
if (! isset($person->user_id)) {
|
||||||
$person->user_id = auth()->id();
|
$person->user_id = auth()->id();
|
||||||
}
|
}
|
||||||
// Ensure a unique 6-character alphanumeric 'nu' is set globally on create
|
// Ensure a unique 6-character alphanumeric 'nu' is set globally on create
|
||||||
@@ -58,30 +65,42 @@ protected static function booted(){
|
|||||||
$person->nu = static::generateUniqueNu();
|
$person->nu = static::generateUniqueNu();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static::saving(function (Person $person) {
|
||||||
|
$person->full_name_search = static::buildFullNameSearchPayload(
|
||||||
|
$person->first_name,
|
||||||
|
$person->last_name,
|
||||||
|
$person->full_name
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function makeAllSearchableUsing(Builder $query): Builder
|
protected function makeAllSearchableUsing(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->with(['addresses', 'phones']);
|
return $query->with(['addresses', 'phones', 'emails']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[SearchUsingFullText(['full_name_search'], ['config' => 'simple'])]
|
||||||
public function toSearchableArray(): array
|
public function toSearchableArray(): array
|
||||||
{
|
{
|
||||||
return [
|
$columns = [
|
||||||
'first_name' => '',
|
'first_name' => (string) $this->first_name,
|
||||||
'last_name' => '',
|
'last_name' => (string) $this->last_name,
|
||||||
'full_name' => '',
|
'full_name' => (string) $this->full_name,
|
||||||
'person_addresses.address' => '',
|
'person_addresses.address' => '',
|
||||||
'person_phones.nu' => ''
|
'person_phones.nu' => '',
|
||||||
|
'emails.value' => '',
|
||||||
|
'full_name_search' => (string) $this->full_name_search,
|
||||||
];
|
];
|
||||||
}
|
|
||||||
|
|
||||||
|
return $columns;
|
||||||
|
}
|
||||||
|
|
||||||
public function phones(): HasMany
|
public function phones(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Models\Person\PersonPhone::class)
|
return $this->hasMany(\App\Models\Person\PersonPhone::class)
|
||||||
->with(['type'])
|
->with(['type'])
|
||||||
->where('active','=',1)
|
->where('active', '=', 1)
|
||||||
->orderBy('id');
|
->orderBy('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +108,7 @@ public function addresses(): HasMany
|
|||||||
{
|
{
|
||||||
return $this->hasMany(\App\Models\Person\PersonAddress::class)
|
return $this->hasMany(\App\Models\Person\PersonAddress::class)
|
||||||
->with(['type'])
|
->with(['type'])
|
||||||
->where('active','=',1)
|
->where('active', '=', 1)
|
||||||
->orderBy('id');
|
->orderBy('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +154,46 @@ protected static function generateUniqueNu(): string
|
|||||||
do {
|
do {
|
||||||
$nu = Str::random(6); // [A-Za-z0-9]
|
$nu = Str::random(6); // [A-Za-z0-9]
|
||||||
} while (static::where('nu', $nu)->exists());
|
} while (static::where('nu', $nu)->exists());
|
||||||
|
|
||||||
return $nu;
|
return $nu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function buildFullNameSearchPayload(?string $firstName, ?string $lastName, ?string $fullName): string
|
||||||
|
{
|
||||||
|
$segments = collect([
|
||||||
|
static::joinNameParts($firstName, $lastName),
|
||||||
|
static::joinNameParts($lastName, $firstName),
|
||||||
|
$fullName,
|
||||||
|
])->filter();
|
||||||
|
|
||||||
|
if ($segments->isEmpty()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $segments
|
||||||
|
->map(fn (string $segment): string => static::normalizeSegment($segment))
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->implode(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function joinNameParts(?string $first, ?string $second): ?string
|
||||||
|
{
|
||||||
|
$parts = collect([$first, $second])->filter(fn ($value) => filled($value));
|
||||||
|
|
||||||
|
if ($parts->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts->implode(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function normalizeSegment(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (empty($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) Str::of($value)->squish()->lower();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user