4 Commits

Author SHA1 Message Date
Simon Pocrnjič 3b284fa4bd Changes to UI and other stuff 2025-11-20 18:11:43 +01:00
Simon Pocrnjič b7fa2d261b changes UI 2025-11-04 18:53:23 +01:00
Simon Pocrnjič fd9f26d82a Changes to post|put|patch|delete 2025-11-02 21:46:02 +01:00
Simon Pocrnjič 63e0958b66 Dev branch 2025-11-02 12:31:01 +01:00
432 changed files with 24880 additions and 14278 deletions
+32 -4
View File
@@ -22,7 +22,7 @@ ## Foundational Context
- pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11
- @inertiajs/vue3 (INERTIA) - v2
- tailwindcss (TAILWINDCSS) - v3
- tailwindcss (TAILWINDCSS) - v4
- vue (VUE) - v3
@@ -359,11 +359,39 @@ ### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v3 rules ===
=== tailwindcss/v4 rules ===
## Tailwind 3
## Tailwind 4
- Always use Tailwind CSS v3 - verify you're using only classes supported by this version.
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
=== tests rules ===
+3 -2
View File
@@ -24,14 +24,15 @@ public function build($options = null)
->get();
$months = $data->pluck('month')->map(
fn ($nu) => \DateTime::createFromFormat('!m', $nu)->format('F'))->toArray();
fn($nu)
=> \DateTime::createFromFormat('!m', $nu)->format('F'))->toArray();
$newCases = $data->pluck('count')->toArray();
return $this->chart->areaChart()
->setTitle('Novi primeri zadnjih šest mesecev.')
->addData('Primeri', $newCases)
// ->addData('Completed', [7, 2, 7, 2, 5, 4])
//->addData('Completed', [7, 2, 7, 2, 5, 4])
->setColors(['#ff6384'])
->setXAxis($months)
->setToolbar(true)
+2 -2
View File
@@ -2,13 +2,12 @@
namespace App\Console\Commands;
use App\Models\Post;
use Illuminate\Console\Command;
use App\Models\Post;
class ImportPosts extends Command
{
protected $signature = 'import:posts';
protected $description = 'Import posts into Algolia without clearing the index';
public function __construct()
@@ -23,3 +22,4 @@ public function handle()
$this->info('Posts have been imported into Algolia.');
}
}
@@ -10,15 +10,12 @@
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 $description = 'Deletes generated document preview files older than N days and clears their metadata.';
public function handle(): int
{
$days = (int) $this->option('days');
if ($days < 1) {
$days = 90;
}
if ($days < 1) { $days = 90; }
$cutoff = Carbon::now()->subDays($days);
$previewDisk = config('files.preview_disk', 'public');
@@ -30,7 +27,6 @@ public function handle(): int
$count = $query->count();
if ($count === 0) {
$this->info('No stale previews found.');
return self::SUCCESS;
}
@@ -40,12 +36,9 @@ public function handle(): int
$query->chunkById(200, function ($docs) use ($previewDisk, $dry) {
foreach ($docs as $doc) {
$path = $doc->preview_path;
if (! $path) {
continue;
}
if (!$path) { continue; }
if ($dry) {
$this->line("Would delete: {$previewDisk}://{$path} (document #{$doc->id})");
continue;
}
try {
@@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RefreshMaterializedViews extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reports:refresh-mviews {--concurrently : Use CONCURRENTLY (Postgres 9.4+; requires indexes)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Refresh configured Postgres materialized views for reporting';
/**
* Execute the console command.
*/
public function handle(): int
{
$views = (array) config('reports.materialized_views', []);
if (empty($views)) {
$this->info('No materialized views configured.');
return self::SUCCESS;
}
$concurrently = $this->option('concurrently') ? ' CONCURRENTLY' : '';
foreach ($views as $view) {
$name = trim((string) $view);
if ($name === '') {
continue;
}
$sql = 'REFRESH MATERIALIZED VIEW'.$concurrently.' '.DB::getPdo()->quote($name);
// PDO::quote wraps with single quotes; for identifiers we need double quotes or no quotes.
// Use a safe fallback: wrap with " if not already quoted
$safe = 'REFRESH MATERIALIZED VIEW'.$concurrently.' "'.str_replace('"', '""', $name).'"';
try {
DB::statement($safe);
$this->info("Refreshed: {$name}");
} catch (\Throwable $e) {
$this->error("Failed to refresh {$name}: ".$e->getMessage());
}
}
return self::SUCCESS;
}
}
+9
View File
@@ -22,6 +22,15 @@ protected function schedule(Schedule $schedule): void
'--days' => $days,
])->dailyAt('02:00');
}
// Optional: refresh configured materialized views for reporting
$views = (array) config('reports.materialized_views', []);
if (! empty($views)) {
$time = (string) (config('reports.refresh_time', '03:00') ?: '03:00');
$schedule->command('reports:refresh-mviews', [
'--concurrently' => true,
])->dailyAt($time);
}
}
/**
-160
View File
@@ -1,160 +0,0 @@
<?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 ClientContractsExport 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' => 'Referenca'],
'customer' => ['label' => 'Stranka'],
'address' => ['label' => 'Naslov'],
'start' => ['label' => 'Začetek'],
'segment' => ['label' => 'Segment'],
'balance' => ['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 ($column === 'start') {
$formats[$letter] = self::DATE_EXCEL_FORMAT;
}
}
return $formats;
}
private function resolveValue(Contract $contract, string $column): mixed
{
return match ($column) {
'reference' => $contract->reference,
'customer' => optional($contract->clientCase?->person)->full_name,
'address' => optional($contract->clientCase?->person?->address)->address,
'start' => $this->formatDate($contract->start_date),
'segment' => $contract->segments?->first()?->name,
'balance' => optional($contract->account)->balance_amount,
default => null,
};
}
private function formatDate(?string $date): mixed
{
if (empty($date)) {
return null;
}
try {
$carbon = Carbon::parse($date);
return ExcelDate::dateTimeToExcel($carbon);
} catch (\Exception $e) {
return null;
}
}
/**
* @return array<string, string>
*/
private function getColumnLetterMap(): array
{
if ($this->columnLetterMap !== []) {
return $this->columnLetterMap;
}
$letter = 'A';
foreach ($this->columns as $column) {
$this->columnLetterMap[$letter] = $column;
$letter++;
}
return $this->columnLetterMap;
}
public function bindValue(Cell $cell, $value): bool
{
if (is_numeric($value)) {
$cell->setValueExplicit($value, DataType::TYPE_NUMERIC);
return true;
}
return parent::bindValue($cell, $value);
}
}
-172
View File
@@ -1,172 +0,0 @@
<?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'],
'address' => ['label' => 'Naslov'],
'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,
'address' => optional($contract->clientCase?->person?->address)->address,
'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;
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
namespace App\Helpers;
class LZStringHelper
{
/**
* Decompresses a string compressed with LZ-String's compressToEncodedURIComponent method.
* This is a PHP port of the JavaScript LZ-String library.
*
* @param string $compressed
* @return string|null
*/
public static function decompressFromEncodedURIComponent($compressed)
{
if ($compressed === null || $compressed === '') {
return '';
}
// Replace URL-safe characters back
$compressed = str_replace(' ', '+', $compressed);
return self::decompress(strlen($compressed), 32, function ($index) use ($compressed) {
return self::getBaseValue(self::$keyStrUriSafe, $compressed[$index]);
});
}
private static $keyStrUriSafe = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$';
private static function getBaseValue($alphabet, $character)
{
$pos = strpos($alphabet, $character);
return $pos !== false ? $pos : -1;
}
private static function decompress($length, $resetValue, $getNextValue)
{
$dictionary = [];
$enlargeIn = 4;
$dictSize = 4;
$numBits = 3;
$entry = '';
$result = [];
$data = ['val' => $getNextValue(0), 'position' => $resetValue, 'index' => 1];
for ($i = 0; $i < 3; $i++) {
$dictionary[$i] = chr($i);
}
$bits = 0;
$maxpower = pow(2, 2);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$next = $bits;
switch ($next) {
case 0:
$bits = 0;
$maxpower = pow(2, 8);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$c = chr($bits);
break;
case 1:
$bits = 0;
$maxpower = pow(2, 16);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$c = chr($bits);
break;
case 2:
return '';
}
$dictionary[$dictSize++] = $c;
$w = $c;
$result[] = $c;
while (true) {
if ($data['index'] > $length) {
return '';
}
$bits = 0;
$maxpower = pow(2, $numBits);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$c = $bits;
switch ($c) {
case 0:
$bits = 0;
$maxpower = pow(2, 8);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$dictionary[$dictSize++] = chr($bits);
$c = $dictSize - 1;
$enlargeIn--;
break;
case 1:
$bits = 0;
$maxpower = pow(2, 16);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$dictionary[$dictSize++] = chr($bits);
$c = $dictSize - 1;
$enlargeIn--;
break;
case 2:
return implode('', $result);
}
if ($enlargeIn == 0) {
$enlargeIn = pow(2, $numBits);
$numBits++;
}
if (isset($dictionary[$c])) {
$entry = $dictionary[$c];
} else {
if ($c === $dictSize) {
$entry = $w.$w[0];
} else {
return null;
}
}
$result[] = $entry;
$dictionary[$dictSize++] = $w.$entry[0];
$enlargeIn--;
$w = $entry;
if ($enlargeIn == 0) {
$enlargeIn = pow(2, $numBits);
$numBits++;
}
}
}
}
@@ -2,6 +2,10 @@
namespace App\Http\Controllers;
use App\Models\Account;
use Illuminate\Http\Request;
use Inertia\Inertia;
class AccountController extends Controller
{
//
@@ -13,10 +13,8 @@ class ActivityNotificationController extends Controller
*/
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'],
$request->validate([
'activity_id' => ['required', 'integer', 'exists:activities,id'],
]);
$userId = optional($request->user())->id;
@@ -24,18 +22,9 @@ public function __invoke(Request $request)
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) {
$activity = Activity::query()->select(['id', 'due_date'])->findOrFail($request->integer('activity_id'));
$due = optional($activity->due_date) ? date('Y-m-d', strtotime($activity->due_date)) : now()->toDateString();
ActivityNotificationRead::query()->updateOrCreate(
[
'user_id' => $userId,
@@ -46,8 +35,7 @@ public function __invoke(Request $request)
'read_at' => now(),
]
);
}
return back();
return response()->json(['status' => 'ok']);
}
}
+29 -103
View File
@@ -37,7 +37,7 @@ public function index(Request $request): Response
->get(['id', 'profile_id', 'sname', 'phone_number']);
$templates = \App\Models\SmsTemplate::query()
->orderBy('name')
->get(['id', 'name', 'content']);
->get(['id', 'name']);
$segments = \App\Models\Segment::query()
->where('active', true)
->orderBy('name')
@@ -98,10 +98,6 @@ public function show(Package $package, SmsService $sms): Response
'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;
@@ -125,7 +121,7 @@ public function show(Package $package, SmsService $sms): Response
if (! $rendered) {
$body = isset($payload['body']) ? trim((string) $payload['body']) : '';
if ($body !== '') {
$rendered = $sms->renderContent($body, $vars);
$rendered = $body;
} elseif (! empty($payload['template_id'])) {
$tpl = \App\Models\SmsTemplate::find((int) $payload['template_id']);
if ($tpl) {
@@ -161,10 +157,6 @@ public function show(Package $package, SmsService $sms): Response
'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;
@@ -183,7 +175,7 @@ public function show(Package $package, SmsService $sms): Response
if ($body !== '') {
$preview = [
'source' => 'body',
'content' => $sms->renderContent($body, $vars),
'content' => $body,
];
} elseif (! empty($payload['template_id'])) {
/** @var SmsTemplate|null $tpl */
@@ -223,8 +215,6 @@ public function store(StorePackageRequest $request): RedirectResponse
'created_by' => optional($request->user())->id,
]);
dd($data['items']);
$items = collect($data['items'])
->map(function (array $row) {
return new PackageItem([
@@ -290,44 +280,49 @@ public function cancel(Package $package): RedirectResponse
return back()->with('success', 'Package canceled');
}
public function destroy(Package $package): RedirectResponse
{
// Allow deletion only for drafts (not yet dispatched)
if ($package->status !== Package::STATUS_DRAFT) {
return back()->with('error', 'Package not in a deletable state.');
}
// Remove items first to avoid FK issues
$package->items()->delete();
$package->delete();
return back()->with('success', 'Package deleted');
}
/**
* 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'],
'segment_id' => ['required', '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;
$segmentId = (int) $request->input('segment_id');
$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) {
->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);
});
}
})
->with([
'clientCase.person.phones',
'clientCase.client.person',
])
->select('contracts.*')
->latest('contracts.id');
if ($q = trim((string) $request->input('q'))) {
$query->where(function ($w) use ($q) {
@@ -340,30 +335,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
->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) {
@@ -388,8 +359,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
'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,
@@ -459,12 +428,12 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
continue;
}
$key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu;
/*if ($seen->contains($key)) {
if ($seen->contains($key)) {
// skip duplicates across multiple contracts/persons
$skipped++;
continue;
}*/
}
$seen->push($key);
$items[] = [
'number' => (string) $phone->nu,
@@ -512,47 +481,4 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
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;
}
}
@@ -3,14 +3,12 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreUserRequest;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Inertia\Inertia;
use Inertia\Response;
@@ -20,7 +18,7 @@ public function index(Request $request): Response
{
Gate::authorize('manage-settings');
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email', 'active']);
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email']);
$roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']);
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
@@ -31,23 +29,6 @@ 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
{
Gate::authorize('manage-settings');
@@ -61,16 +42,4 @@ public function update(Request $request, User $user): RedirectResponse
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,6 +5,7 @@
use App\Models\CaseObject;
use App\Models\ClientCase;
use App\Models\Contract;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
class CaseObjectController extends Controller
@@ -27,7 +28,7 @@ public function store(ClientCase $clientCase, string $uuid, Request $request)
public function update(ClientCase $clientCase, int $id, Request $request)
{
$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();
$validated = $request->validate([
@@ -45,7 +46,7 @@ public function update(ClientCase $clientCase, int $id, Request $request)
public function destroy(ClientCase $clientCase, int $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();
$object->delete();
File diff suppressed because it is too large Load Diff
+86 -184
View File
@@ -2,62 +2,52 @@
namespace App\Http\Controllers;
use App\Exports\ClientContractsExport;
use App\Http\Requests\ExportClientContractsRequest;
use App\Models\Client;
use App\Services\ReferenceDataCache;
use DB;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
class ClientController extends Controller
{
public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Client $client, Request $request)
{
$search = $request->input('search');
$query = $client::query()
->with('person')
->when($request->input('search'), function ($que, $search) {
$que->whereHas('person', function ($q) use ($search) {
$q->where('full_name', 'ilike', '%'.$search.'%');
});
->select('clients.*')
->when($search, function ($que) use ($search) {
$que->join('person', 'person.id', '=', 'clients.person_id')
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('clients.id');
})
->where('active', 1)
->where('clients.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('contracts', function ($join) {
$join->on('contracts.client_case_id', '=', 'client_cases.id')
->whereNull('contracts.deleted_at');
})
->leftJoin('contract_segment', function ($join) {
$join->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true);
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('clients.id')
->addSelect([
// Number of client cases for this client that have at least one active contract
'cases_with_active_contracts_count' => DB::query()
->from('client_cases')
->join('contracts', 'contracts.client_case_id', '=', 'client_cases.id')
->selectRaw('COUNT(DISTINCT client_cases.id)')
->whereColumn('client_cases.client_id', 'clients.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
// Sum of account balances for active contracts that belong to this client's cases
'active_contracts_balance_sum' => DB::query()
->from('contracts')
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
->whereExists(function ($q) {
$q->from('client_cases')
->whereColumn('client_cases.id', 'contracts.client_case_id')
->whereColumn('client_cases.client_id', 'clients.id');
})
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
// Sum of account balances for active contracts
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->orderByDesc('created_at');
->with('person')
->orderByDesc('clients.created_at');
return Inertia::render('Client/Index', [
'clients' => $query
->paginate($request->integer('perPage', 15))
->paginate($request->integer('per_page', 15))
->withQueryString(),
'filters' => $request->only(['search']),
]);
@@ -71,44 +61,37 @@ public function show(Client $client, Request $request)
->findOrFail($client->id);
$types = [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
return Inertia::render('Client/Show', [
'client' => $data,
'client_cases' => $data->clientCases()
->with(['person', 'client.person'])
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
'person',
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
))
->select('client_cases.*')
->when($request->input('search'), function ($que, $search) {
$que->join('person', 'person.id', '=', 'client_cases.person_id')
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('client_cases.id');
})
->leftJoin('contracts', function ($join) {
$join->on('contracts.client_case_id', '=', 'client_cases.id')
->whereNull('contracts.deleted_at');
})
->leftJoin('contract_segment', function ($join) {
$join->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true);
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('client_cases.id')
->addSelect([
'active_contracts_count' => \DB::query()
->from('contracts')
->selectRaw('COUNT(*)')
->whereColumn('contracts.client_case_id', 'client_cases.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
'active_contracts_balance_sum' => \DB::query()
->from('contracts')
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
->whereColumn('contracts.client_case_id', 'client_cases.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->where('active', 1)
->orderByDesc('created_at')
->paginate($request->integer('perPage', 15))
->with(['person', 'client.person'])
->where('client_cases.active', 1)
->orderByDesc('client_cases.created_at')
->paginate($request->integer('per_page', 15))
->withQueryString(),
'types' => $types,
'filters' => $request->only(['search']),
@@ -122,142 +105,61 @@ public function contracts(Client $client, Request $request)
$from = $request->input('from');
$to = $request->input('to');
$search = $request->input('search');
$segmentsParam = $request->input('segments');
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
$segmentId = $request->input('segment');
$contractsQuery = \App\Models\Contract::query()
->whereHas('clientCase', function ($q) use ($client) {
$q->where('client_id', $client->id);
->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.start_date', 'contracts.client_case_id'])
->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
->where('client_cases.client_id', $client->id)
->whereNull('contracts.deleted_at')
->when($from || $to, function ($q) use ($from, $to) {
if (! empty($from)) {
$q->whereDate('contracts.start_date', '>=', $from);
}
if (! empty($to)) {
$q->whereDate('contracts.start_date', '<=', $to);
}
})
->when($search, function ($q) use ($search) {
$q->leftJoin('person', 'person.id', '=', 'client_cases.person_id')
->where(function ($inner) use ($search) {
$inner->where('contracts.reference', 'ilike', '%'.$search.'%')
->orWhere('person.full_name', 'ilike', '%'.$search.'%');
});
})
->when($segmentId, function ($q) use ($segmentId) {
$q->join('contract_segment', function ($join) use ($segmentId) {
$join->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.segment_id', $segmentId)
->where('contract_segment.active', true);
});
})
->with([
'clientCase:id,uuid,person_id',
'clientCase.person:id,full_name',
'clientCase.person.address',
'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');
->orderByDesc('contracts.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(),
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
return Inertia::render('Client/Contracts', [
'client' => $data,
'contracts' => $contractsQuery->paginate($request->integer('perPage', 20))->withQueryString(),
'filters' => $request->only(['from', 'to', 'search', 'segments']),
'contracts' => $contractsQuery->paginate($request->integer('per_page', 20))->withQueryString(),
'filters' => $request->only(['from', 'to', 'search', 'segment']),
'segments' => $segments,
'types' => $types,
]);
}
public function exportContracts(ExportClientContractsRequest $request, Client $client)
{
$data = $request->validated();
$columns = array_values(array_unique($data['columns']));
$from = $data['from'] ?? null;
$to = $data['to'] ?? null;
$search = $data['search'] ?? null;
$segmentsParam = $data['segments'] ?? null;
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
$query = \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',
'clientCase.person.address',
'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');
if (($data['scope'] ?? ExportClientContractsRequest::SCOPE_ALL) === ExportClientContractsRequest::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($client);
return Excel::download(new ClientContractsExport($query, $columns), $filename);
}
private function buildExportFilename(Client $client): string
{
$datePrefix = now()->format('dmy');
$clientName = $this->slugify($client->person?->full_name ?? 'stranka');
return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName);
}
private function slugify(?string $value): string
{
if (empty($value)) {
return 'data';
}
return Str::slug($value, '-') ?: 'data';
}
public function store(Request $request)
{
@@ -295,14 +197,14 @@ public function store(Request $request)
// \App\Models\Person\PersonAddress::create($address);
return to_route('client');
return back()->with('success', 'Client created')->with('flash_method', 'POST');
}
public function update(Client $client, Request $request)
{
return to_route('client.show', $client);
return back()->with('success', 'Client updated')->with('flash_method', 'PUT');
}
/**
@@ -14,8 +14,8 @@ public function index()
{
return Inertia::render('Settings/ContractConfigs/Index', [
'configs' => ContractConfig::with(['type:id,name', 'segment:id,name'])->get(),
'types' => ContractType::query()->get(['id', 'name']),
'segments' => Segment::query()->where('active', true)->get(['id', 'name']),
'types' => ContractType::query()->get(['id','name']),
'segments' => Segment::query()->where('active', true)->get(['id','name']),
]);
}
@@ -40,8 +40,8 @@ public function store(Request $request)
ContractConfig::create([
'contract_type_id' => $data['contract_type_id'],
'segment_id' => $data['segment_id'],
'is_initial' => (bool) ($data['is_initial'] ?? false),
'active' => (bool) ($data['active'] ?? true),
'is_initial' => (bool)($data['is_initial'] ?? false),
'active' => (bool)($data['active'] ?? true),
]);
return back()->with('success', 'Configuration created');
@@ -57,8 +57,8 @@ public function update(ContractConfig $config, Request $request)
$config->update([
'segment_id' => $data['segment_id'],
'is_initial' => (bool) ($data['is_initial'] ?? $config->is_initial),
'active' => (bool) ($data['active'] ?? $config->active),
'is_initial' => (bool)($data['is_initial'] ?? $config->is_initial),
'active' => (bool)($data['active'] ?? $config->active),
]);
return back()->with('success', 'Configuration updated');
@@ -67,7 +67,6 @@ public function update(ContractConfig $config, Request $request)
public function destroy(ContractConfig $config)
{
$config->delete();
return back()->with('success', 'Configuration deleted');
}
}
+14 -83
View File
@@ -4,28 +4,26 @@
use App\Models\Contract;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
class ContractController extends Controller
{
public function index(Contract $contract)
{
public function index(Contract $contract) {
return Inertia::render('Contract/Index', [
'contracts' => $contract::with(['type', 'debtor'])
->where('active', 1)
->orderByDesc('created_at')
->paginate(10),
'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', [
'contract' => $contract::with(['type', 'client', 'debtor'])->findOrFail($contract->id),
'contract' => $contract::with(['type', 'client', 'debtor'])->findOrFail($contract->id)
]);
}
@@ -35,96 +33,29 @@ public function store(Request $request)
$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([
'reference' => $request->input('reference'),
'start_date' => date('Y-m-d', strtotime($request->input('start_date'))),
'type_id' => $request->input('type_id'),
'type_id' => $request->input('type_id')
]);
});
}
return to_route('clientCase.show', $clientCase);
return back()->with('success', 'Contract created')->with('flash_method', 'POST');
}
public function update(Contract $contract, Request $request)
{
public function update(Contract $contract, Request $request){
$contract->update([
'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.'));
return back()->with('success', 'Contract updated')->with('flash_method', 'PUT');
}
}
+2
View File
@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class DebtController extends Controller
{
//
@@ -58,13 +58,6 @@ public function index()
'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
'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 {
// Ensure fields are arrays for frontend consumption
@@ -111,10 +111,10 @@ public function store(Request $request)
'is_active' => 'boolean',
'reactivate' => 'boolean',
'entities' => 'nullable|array',
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
'mappings' => 'array',
'mappings.*.source_column' => 'required|string',
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
'mappings.*.target_field' => 'nullable|string',
'mappings.*.transform' => 'nullable|string|max:50',
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
@@ -124,11 +124,7 @@ public function store(Request $request)
'meta.segment_id' => 'nullable|integer|exists:segments,id',
'meta.decision_id' => 'nullable|integer|exists:decisions,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.history_import' => 'nullable|boolean',
'meta.contract_key_mode' => 'nullable|string|in:reference',
])->validate();
@@ -146,28 +142,7 @@ public function store(Request $request)
$template = null;
DB::transaction(function () use (&$template, $request, $data) {
$paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false);
$historyImport = (bool) (data_get($data, 'meta.history_import') ?? false);
$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) {
$entities = ['contracts', 'accounts', 'payments'];
}
@@ -187,11 +162,7 @@ public function store(Request $request)
'segment_id' => data_get($data, 'meta.segment_id'),
'decision_id' => data_get($data, 'meta.decision_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,
'history_import' => $historyImport ?: null,
'contract_key_mode' => data_get($data, 'meta.contract_key_mode'),
], fn ($v) => ! is_null($v) && $v !== ''),
]);
@@ -273,7 +244,7 @@ public function addMapping(Request $request, ImportTemplate $template)
}
$data = validator($raw, [
'source_column' => 'required|string',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
'target_field' => 'nullable|string',
'transform' => 'nullable|string|in:trim,upper,lower',
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
@@ -343,11 +314,7 @@ public function update(Request $request, ImportTemplate $template)
'meta.segment_id' => 'nullable|integer|exists:segments,id',
'meta.decision_id' => 'nullable|integer|exists:decisions,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.history_import' => 'nullable|boolean',
'meta.contract_key_mode' => 'nullable|string|in:reference',
])->validate();
@@ -375,11 +342,6 @@ public function update(Request $request, ImportTemplate $template)
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)
@@ -387,20 +349,6 @@ public function update(Request $request, ImportTemplate $template)
if (! empty($finalMeta['payments_import'])) {
$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 = [
'name' => $data['name'],
@@ -433,7 +381,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
}
$data = validator($raw, [
'sources' => 'required|string', // comma and/or newline separated
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
'default_field' => 'nullable|string', // if provided, used as the field name for all entries
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
'transform' => 'nullable|string|in:trim,upper,lower',
@@ -540,7 +488,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
}
$data = validator($raw, [
'source_column' => 'required|string',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments',
'target_field' => 'nullable|string',
'transform' => 'nullable|string|in:trim,upper,lower',
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
@@ -635,9 +583,6 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
'segment_id' => $tplMeta['segment_id'] ?? null,
'decision_id' => $tplMeta['decision_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,
], fn ($v) => ! is_null($v) && $v !== ''));
@@ -35,15 +35,7 @@ public function unread(Request $request)
->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');
})
// Removed per-user unread filter: show notifications regardless of individual reads
->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) {
@@ -116,15 +108,7 @@ public function unread(Request $request)
->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');
})
// Removed per-user unread filter for client list base
->when($clientCaseIdsForFilter->isNotEmpty(), function ($q) use ($clientCaseIdsForFilter) {
$q->where(function ($qq) use ($clientCaseIdsForFilter) {
$qq->whereIn('activities.client_case_id', $clientCaseIdsForFilter)
@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class PaymentController extends Controller
{
//
+18 -49
View File
@@ -26,18 +26,10 @@ public function update(Person $person, Request $request)
$person->update($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'Person updated');
}
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
return response()->json([
'person' => [
'full_name' => $person->full_name,
'tax_number' => $person->tax_number,
'social_security_number' => $person->social_security_number,
'description' => $person->description,
],
]);
}
public function createAddress(Person $person, Request $request)
@@ -60,13 +52,8 @@ public function createAddress(Person $person, Request $request)
], $attributes);
// Support Inertia form submissions (redirect back) and JSON (for API/axios)
if ($request->header('X-Inertia')) {
return back()->with('success', 'Address created');
}
return back()->with('success', 'Address created')->with('flash_method', 'POST');
return response()->json([
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id),
]);
}
public function updateAddress(Person $person, int $address_id, Request $request)
@@ -84,13 +71,8 @@ public function updateAddress(Person $person, int $address_id, Request $request)
$address->update($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'Address updated');
}
return back()->with('success', 'Address updated')->with('flash_method', 'PUT');
return response()->json([
'address' => $address,
]);
}
public function deleteAddress(Person $person, int $address_id, Request $request)
@@ -98,11 +80,8 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
$address = $person->addresses()->findOrFail($address_id);
$address->delete(); // soft delete
if ($request->header('X-Inertia')) {
return back()->with('success', 'Address deleted');
}
return response()->json(['status' => 'ok']);
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
}
public function createPhone(Person $person, Request $request)
@@ -122,7 +101,7 @@ public function createPhone(Person $person, Request $request)
'country_code' => $attributes['country_code'] ?? null,
], $attributes);
return back()->with('success', 'Phone added successfully');
return back()->with('success', 'Phone added successfully')->with('flash_method', 'POST');
}
public function updatePhone(Person $person, int $phone_id, Request $request)
@@ -140,7 +119,7 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
$phone->update($attributes);
return back()->with('success', 'Phone updated successfully');
return back()->with('success', 'Phone updated successfully')->with('flash_method', 'PUT');
}
public function deletePhone(Person $person, int $phone_id, Request $request)
@@ -148,7 +127,7 @@ public function deletePhone(Person $person, int $phone_id, Request $request)
$phone = $person->phones()->findOrFail($phone_id);
$phone->delete(); // soft delete
return back()->with('success', 'Phone deleted');
return back()->with('success', 'Phone deleted')->with('flash_method', 'DELETE');
}
public function createEmail(Person $person, Request $request)
@@ -170,7 +149,7 @@ public function createEmail(Person $person, Request $request)
'value' => $attributes['value'],
], $attributes);
return back()->with('success', 'Email added successfully');
return back()->with('success', 'Email added successfully')->with('flash_method', 'POST');
}
public function updateEmail(Person $person, int $email_id, Request $request)
@@ -191,7 +170,7 @@ public function updateEmail(Person $person, int $email_id, Request $request)
$email->update($attributes);
return back()->with('success', 'Email updated successfully');
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
}
public function deleteEmail(Person $person, int $email_id, Request $request)
@@ -203,7 +182,7 @@ public function deleteEmail(Person $person, int $email_id, Request $request)
return back()->with('success', 'Email deleted');
}
return response()->json(['status' => 'ok']);
return back()->with('success', 'Email deleted')->with('flash_method', 'DELETE');
}
// TRR (bank account) CRUD
@@ -225,13 +204,10 @@ public function createTrr(Person $person, Request $request)
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
$trr = $person->bankAccounts()->create($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'TRR added successfully');
}
return response()->json([
'trr' => BankAccount::findOrFail($trr->id),
]);
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
}
public function updateTrr(Person $person, int $trr_id, Request $request)
@@ -253,13 +229,8 @@ public function updateTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->update($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'TRR updated successfully');
}
return back()->with('success', 'TRR updated successfully')->with('flash_method', 'PUT');
return response()->json([
'trr' => $trr,
]);
}
public function deleteTrr(Person $person, int $trr_id, Request $request)
@@ -267,10 +238,8 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->delete();
if ($request->header('X-Inertia')) {
return back()->with('success', 'TRR deleted');
}
return response()->json(['status' => 'ok']);
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
}
}
+144 -54
View File
@@ -3,11 +3,13 @@
namespace App\Http\Controllers;
use App\Models\FieldJob;
use App\Services\ReferenceDataCache;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PhoneViewController extends Controller
{
public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Request $request)
{
$userId = $request->user()->id;
@@ -76,81 +78,169 @@ public function completedToday(Request $request)
public function showCase(\App\Models\ClientCase $clientCase, Request $request)
{
$userId = $request->user()->id;
$completedMode = $request->boolean('completed');
$completedMode = (bool) $request->boolean('completed');
// Eager load case with person details
$case = $clientCase->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts');
// Eager load client case with person details
$case = \App\Models\ClientCase::query()
->with(['person' => fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts'])])
->findOrFail($clientCase->id);
// Query contracts based on field jobs
$contractsQuery = FieldJob::query()
// Determine contracts of this case relevant to the current user
// - Normal mode: contracts assigned to me and still active (not completed/cancelled)
// - Completed mode (?completed=1): contracts where my field job was completed today
if ($completedMode) {
$start = now()->startOfDay();
$end = now()->endOfDay();
$contractIds = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('cancelled_at')
->whereBetween('completed_at', [$start, $end])
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->when($completedMode,
fn ($q) => $q->whereNull('cancelled_at')->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]),
fn ($q) => $q->whereNull('completed_at')->whereNull('cancelled_at')
);
->pluck('contract_id')
->unique()
->values();
} else {
$contractIds = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->pluck('contract_id')
->unique()
->values();
}
// Get contracts with relationships
$contracts = \App\Models\Contract::query()
->where('client_case_id', $case->id)
->whereIn('id', $contractsQuery->pluck('contract_id')->unique())
->with(['type:id,name', 'account', 'latestObject'])
->whereIn('id', $contractIds)
->with(['type:id,name', 'account'])
->orderByDesc('created_at')
->get();
// Build merged documents
$documents = $case->documents()
// Attach latest object (if any) to each contract as last_object for display
if ($contracts->isNotEmpty()) {
$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()
->map(fn ($d) => array_merge($d->toArray(), [
'documentable_type' => \App\Models\ClientCase::class,
'client_case_uuid' => $case->uuid,
]))
->concat(
\App\Models\Document::query()
->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', $contracts->pluck('id'))
->with('documentable:id,uuid,reference')
->whereIn('documentable_id', $contractIds)
->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();
->map(function ($d) use ($contractRefMap) {
$arr = $d->toArray();
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
$arr['documentable_type'] = \App\Models\Contract::class;
$arr['contract_uuid'] = optional(\App\Models\Contract::withTrashed()->find($d->documentable_id))->uuid;
// Get segment IDs for filtering actions
$segmentIds = \App\Models\FieldJobSetting::query()
->whereIn('id', $contractsQuery->pluck('field_job_setting_id')->filter()->unique())
->pluck('segment_id')
->filter()
->unique();
return $arr;
});
return Inertia::render('Phone/Case/Index', [
'client' => $case->client->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts'),
'client_case' => $case,
'contracts' => $contracts,
'documents' => $documents,
'types' => [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
],
'account_types' => \App\Models\AccountType::all(),
'actions' => \App\Models\Action::query()
->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()
$caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) {
$arr = $d->toArray();
$arr['documentable_type'] = \App\Models\ClientCase::class;
$arr['client_case_uuid'] = $case->uuid;
return $arr;
});
$documents = $caseDocs->concat($contractDocs)->sortByDesc('created_at')->values();
// Provide minimal types for PersonInfoGrid
$types = [
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
// 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(fn ($a) => $a->setAttribute('user_name', $a->user?->name)),
->map(function ($a) {
$a->setAttribute('user_name', optional($a->user)->name);
return $a;
});
// Determine segment filters from FieldJobSettings for this case/user context
$settingIds = FieldJob::query()
->where('assigned_user_id', $userId)
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->when(
$completedMode,
function ($q) {
$q->whereNull('cancelled_at')
->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]);
},
function ($q) {
$q->whereNull('completed_at')->whereNull('cancelled_at');
}
)
->pluck('field_job_setting_id')
->filter()
->unique()
->values();
$segmentIds = collect();
if ($settingIds->isNotEmpty()) {
$segmentIds = \App\Models\FieldJobSetting::query()
->whereIn('id', $settingIds)
->pluck('segment_id')
->filter()
->unique()
->values();
}
// Filter actions and their decisions by the derived segment ids (decisions.segment_id)
$actions = \App\Models\Action::query()
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
// Filter actions by their segment_id matching the FieldJobSetting segment(s)
$q->whereIn('segment_id', $segmentIds);
})
->with([
'decisions' => function ($q) {
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
},
'decisions.emailTemplate' => function ($q) {
$q->select('id', 'name', 'entity_types', 'allow_attachments');
},
])
->get(['id', 'name', 'color_tag', 'segment_id']);
return Inertia::render('Phone/Case/Index', [
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(),
'client_case' => $case,
'contracts' => $contracts,
'documents' => $documents,
'types' => $types,
'account_types' => $this->referenceCache->getAccountTypes(),
// Provide decisions (filtered by segment) with linked email template metadata (entity_types, allow_attachments)
'actions' => $actions,
'activities' => $activities,
'completed_mode' => $completedMode,
]);
}
+1 -1
View File
@@ -2,9 +2,9 @@
namespace App\Http\Controllers;
use App\Models\Post;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Models\Post;
class PostController extends Controller
{
+379
View File
@@ -0,0 +1,379 @@
<?php
namespace App\Http\Controllers;
use App\Reports\ReportRegistry;
use Illuminate\Http\Request;
use Inertia\Inertia;
// facades referenced with fully-qualified names below to satisfy static analysis
class ReportController extends Controller
{
public function __construct(protected ReportRegistry $registry) {}
public function index(Request $request)
{
$reports = collect($this->registry->all())
->map(fn ($r) => [
'slug' => $r->slug(),
'name' => $r->name(),
'description' => $r->description(),
])
->values();
return Inertia::render('Reports/Index', [
'reports' => $reports,
]);
}
public function show(string $slug, Request $request)
{
$report = $this->registry->findBySlug($slug);
abort_if(! $report, 404);
$report->authorize($request);
// Accept filters & pagination from query and return initial data for server-driven table
$filters = $this->validateFilters($report->inputs(), $request);
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
$perPage = (int) ($request->integer('per_page') ?: 25);
$paginator = $report->paginate($filters, $perPage);
$rows = collect($paginator->items())
->map(fn ($row) => $this->normalizeRow($row))
->values();
return Inertia::render('Reports/Show', [
'slug' => $report->slug(),
'name' => $report->name(),
'description' => $report->description(),
'inputs' => $report->inputs(),
'columns' => $report->columns(),
'rows' => $rows,
'meta' => [
'total' => $paginator->total(),
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'last_page' => $paginator->lastPage(),
],
'query' => array_filter($filters, fn ($v) => $v !== null && $v !== ''),
]);
}
public function data(string $slug, Request $request)
{
$report = $this->registry->findBySlug($slug);
abort_if(! $report, 404);
$report->authorize($request);
$filters = $this->validateFilters($report->inputs(), $request);
$perPage = (int) ($request->integer('per_page') ?: 25);
$paginator = $report->paginate($filters, $perPage);
$rows = collect($paginator->items())
->map(fn ($row) => $this->normalizeRow($row))
->values();
return response()->json([
'data' => $rows,
'total' => $paginator->total(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
]);
}
public function export(string $slug, Request $request)
{
$report = $this->registry->findBySlug($slug);
abort_if(! $report, 404);
$report->authorize($request);
$filters = $this->validateFilters($report->inputs(), $request);
$format = strtolower((string) $request->get('format', 'csv'));
$rows = $report->query($filters)->get()->map(fn ($row) => $this->normalizeRow($row));
$columns = $report->columns();
$filename = $report->slug().'-'.now()->format('Ymd_His');
if ($format === 'pdf') {
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [
'name' => $report->name(),
'columns' => $columns,
'rows' => $rows,
]);
return $pdf->download($filename.'.pdf');
}
if ($format === 'xlsx') {
$keys = array_map(fn ($c) => $c['key'], $columns);
$headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns);
// Convert values for correct Excel rendering (dates, numbers, text)
$array = $this->prepareXlsxArray($rows, $keys);
// Build base column formats: text for contracts, EU datetime for *_at; numbers are formatted per-cell in AfterSheet
$columnFormats = [];
$textColumns = [];
$dateColumns = [];
foreach ($keys as $i => $key) {
$letter = $this->excelColumnLetter($i + 1);
if ($key === 'contract_reference') {
$columnFormats[$letter] = '@';
$textColumns[] = $letter;
continue;
}
if (str_ends_with($key, '_at')) {
$columnFormats[$letter] = 'dd.mm.yyyy hh:mm';
$dateColumns[] = $letter;
continue;
}
}
// Anonymous export with custom value binder to force text where needed
$export = new class($array, $headings, $columnFormats, $textColumns, $dateColumns) extends \Maatwebsite\Excel\DefaultValueBinder implements \Maatwebsite\Excel\Concerns\FromArray, \Maatwebsite\Excel\Concerns\ShouldAutoSize, \Maatwebsite\Excel\Concerns\WithColumnFormatting, \Maatwebsite\Excel\Concerns\WithCustomValueBinder, \Maatwebsite\Excel\Concerns\WithEvents, \Maatwebsite\Excel\Concerns\WithHeadings
{
public function __construct(private array $array, private array $headings, private array $formats, private array $textColumns, private array $dateColumns) {}
public function array(): array
{
return $this->array;
}
public function headings(): array
{
return $this->headings;
}
public function columnFormats(): array
{
return $this->formats;
}
public function bindValue(\PhpOffice\PhpSpreadsheet\Cell\Cell $cell, $value): bool
{
$col = preg_replace('/\d+/', '', $cell->getCoordinate()); // e.g., B from B2
// Force text for configured columns or very long digit-only strings (>15)
if (in_array($col, $this->textColumns, true) || (is_string($value) && ctype_digit($value) && strlen($value) > 15)) {
$cell->setValueExplicit((string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);
return true;
}
return parent::bindValue($cell, $value);
}
public function registerEvents(): array
{
return [
\Maatwebsite\Excel\Events\AfterSheet::class => function (\Maatwebsite\Excel\Events\AfterSheet $event) {
$sheet = $event->sheet->getDelegate();
// Data starts at row 2 (row 1 is headings)
$rowIndex = 2;
foreach ($this->array as $row) {
foreach (array_values($row) as $i => $val) {
$colLetter = $this->colLetter($i + 1);
if (in_array($colLetter, $this->textColumns, true) || in_array($colLetter, $this->dateColumns, true)) {
continue; // already handled via columnFormats or binder
}
$coord = $colLetter.$rowIndex;
$fmt = null;
if (is_int($val)) {
// Integer: thousands separator, no decimals
$fmt = '#,##0';
} elseif (is_float($val)) {
// Float: show decimals only if fractional part exists
$fmt = (floor($val) != $val) ? '#,##0.00' : '#,##0';
}
if ($fmt) {
$sheet->getStyle($coord)->getNumberFormat()->setFormatCode($fmt);
}
}
$rowIndex++;
}
},
];
}
private function colLetter(int $index): string
{
$letter = '';
while ($index > 0) {
$mod = ($index - 1) % 26;
$letter = chr(65 + $mod).$letter;
$index = intdiv($index - $mod, 26) - 1;
}
return $letter;
}
};
return \Maatwebsite\Excel\Facades\Excel::download($export, $filename.'.xlsx');
}
// Default CSV export
$keys = array_map(fn ($c) => $c['key'], $columns);
$headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns);
$csv = fopen('php://temp', 'r+');
fputcsv($csv, $headings);
foreach ($rows as $r) {
$line = collect($keys)->map(fn ($k) => data_get($r, $k))->toArray();
fputcsv($csv, $line);
}
rewind($csv);
$content = stream_get_contents($csv) ?: '';
fclose($csv);
return response($content, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="'.$filename.'.csv"',
]);
}
/**
* Lightweight users lookup for filters: id + name, optional search and limit.
*/
public function users(Request $request)
{
$search = trim((string) $request->get('search', ''));
$limit = (int) ($request->integer('limit') ?: 10);
$q = \App\Models\User::query()->orderBy('name');
if ($search !== '') {
$like = '%'.mb_strtolower($search).'%';
$q->where(function ($qq) use ($like) {
$qq->whereRaw('LOWER(name) LIKE ?', [$like])
->orWhereRaw('LOWER(email) LIKE ?', [$like]);
});
}
$users = $q->limit(max(1, min(50, $limit)))->get(['id', 'name']);
return response()->json($users);
}
/**
* Lightweight clients lookup for filters: uuid + name (person full_name), optional search and limit.
*/
public function clients(Request $request)
{
$clients = \App\Models\Client::query()
->with('person:id,full_name')
->get()
->map(fn($c) => [
'id' => $c->uuid,
'name' => $c->person->full_name ?? 'Unknown'
])
->sortBy('name')
->values();
return response()->json($clients);
}
/**
* Build validation rules based on inputs descriptor and validate.
*
* @param array<int, array<string, mixed>> $inputs
* @return array<string, mixed>
*/
protected function validateFilters(array $inputs, Request $request): array
{
$rules = [];
foreach ($inputs as $inp) {
$key = $inp['key'];
$type = $inp['type'] ?? 'string';
$nullable = ($inp['nullable'] ?? true) ? 'nullable' : 'required';
$rules[$key] = match ($type) {
'date' => [$nullable, 'date'],
'integer' => [$nullable, 'integer'],
'select:user' => [$nullable, 'integer', 'exists:users,id'],
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
default => [$nullable, 'string'],
};
}
return $request->validate($rules);
}
/**
* Ensure derived export/display fields exist on row objects.
*/
protected function normalizeRow(object $row): object
{
if (isset($row->contract) && ! isset($row->contract_reference)) {
$row->contract_reference = $row->contract->reference ?? null;
}
if (isset($row->assignedUser) && ! isset($row->assigned_user_name)) {
$row->assigned_user_name = $row->assignedUser->name ?? null;
}
return $row;
}
/**
* Convert rows for XLSX export: dates to Excel serial numbers, numbers to numeric, contract refs to text.
*
* @param iterable<int, object|array> $rows
* @param array<int, string> $keys
* @return array<int, array<int, mixed>>
*/
protected function prepareXlsxArray(iterable $rows, array $keys): array
{
$out = [];
foreach ($rows as $r) {
$line = [];
foreach ($keys as $k) {
$v = data_get($r, $k);
if ($k === 'contract_reference') {
$line[] = (string) $v;
continue;
}
if (str_ends_with($k, '_at')) {
if (empty($v)) {
$line[] = null;
} else {
try {
$dt = \Carbon\Carbon::parse($v);
$line[] = \PhpOffice\PhpSpreadsheet\Shared\Date::dateTimeToExcel($dt);
} catch (\Throwable $e) {
$line[] = (string) $v;
}
}
continue;
}
if (is_int($v) || is_float($v)) {
$line[] = $v;
} elseif (is_numeric($v) && is_string($v)) {
// cast numeric-like strings unless they are identifiers that we want as text
$line[] = (strpos($k, 'id') !== false) ? (int) $v : ($v + 0);
} else {
$line[] = $v;
}
}
$out[] = $line;
}
return $out;
}
/**
* Convert 1-based index to Excel column letter.
*/
protected function excelColumnLetter(int $index): string
{
$letter = '';
while ($index > 0) {
$mod = ($index - 1) % 26;
$letter = chr(65 + $mod).$letter;
$index = intdiv($index - $mod, 26) - 1;
}
return $letter;
}
}
+51 -139
View File
@@ -2,20 +2,12 @@
namespace App\Http\Controllers;
use App\Exports\SegmentContractsExport;
use App\Http\Requests\ExportSegmentContractsRequest;
use App\Http\Requests\StoreSegmentRequest;
use App\Http\Requests\UpdateSegmentRequest;
use App\Models\Client;
use App\Models\Contract;
use App\Models\Segment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
class SegmentController extends Controller
{
@@ -52,26 +44,64 @@ public function index()
]);
}
public function show(Segment $segment)
public function show(\App\Models\Segment $segment)
{
// Retrieve contracts that are active in this segment, eager-loading required relations
$search = request('search');
$clientFilter = request('client') ?? request('client_id');
$perPage = request()->integer('perPage', request()->integer('per_page', 15));
$perPage = max(1, min(200, $perPage));
$clientFilter = request('client') ?? request('client_id'); // support either ?client=<uuid|id> or ?client_id=<id>
$contractsQuery = \App\Models\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');
$contracts = $this->buildContractsQuery($segment, $search, $clientFilter)
->paginate($perPage)
// Optional filter by client (accepts numeric id or client uuid)
if (! empty($clientFilter)) {
$contractsQuery->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)) {
$contractsQuery->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.'%');
});
});
}
$contracts = $contractsQuery
->paginate(15)
->withQueryString();
$contracts = $this->hydrateClientShortcut($contracts);
// Hide addresses array since we're using the singular address relationship
$contracts->getCollection()->each(function ($contract) {
$contract->clientCase?->person?->makeHidden('addresses');
$contract->clientCase?->client?->person?->makeHidden('addresses');
// 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);
}
$clients = Client::query()
// Build a full client list for this segment (not limited to current page) for the dropdown
$clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
$q->where('segments.id', $segment->id)
->where('contract_segment.active', '=', 1);
@@ -94,69 +124,6 @@ public function show(Segment $segment)
]);
}
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)
{
return Inertia::render('Settings/Segments/Index', [
@@ -188,59 +155,4 @@ public function update(UpdateSegmentRequest $request, Segment $segment)
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.address',
'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;
}
}
+1 -2
View File
@@ -9,8 +9,7 @@ class SettingController extends Controller
{
//
public function index(Request $request)
{
public function index(Request $request){
return Inertia::render('Settings/Index');
}
@@ -1,42 +0,0 @@
<?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);
}
}
@@ -57,6 +57,7 @@ public function share(Request $request): array
'error' => fn () => $request->session()->get('error'),
'warning' => fn () => $request->session()->get('warning'),
'info' => fn () => $request->session()->get('info'),
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
],
'notifications' => function () use ($request) {
try {
@@ -71,15 +72,7 @@ public function share(Request $request): array
$activities = \App\Models\Activity::query()
->select(['id', 'due_date', 'amount', 'contract_id', 'client_case_id', 'created_at'])
->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');
})
// Removed per-user unread filter: show notifications regardless of individual reads
->orderBy('created_at')
->limit(20)
->get();
@@ -1,52 +0,0 @@
<?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.',
];
}
}
@@ -1,43 +0,0 @@
<?php
namespace App\Http\Requests;
use App\Exports\ClientContractsExport;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ExportClientContractsRequest 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(ClientContractsExport::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'],
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'],
'segments' => ['nullable', 'string'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'per_page' => $this->input('per_page') ?? $this->input('perPage'),
]);
}
}
@@ -1,42 +0,0 @@
<?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'),
]);
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ public function rules(): array
'name' => ['required', 'string', 'max:50'],
'description' => ['nullable', 'string', 'max:255'],
'active' => ['boolean'],
'exclude' => ['boolean'],
'exclude' => ['boolean']
];
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ActivityCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
// Transform data to add user_name attribute
$this->collection->transform(function ($activity) {
$activity->setAttribute('user_name', optional($activity->user)->name);
return $activity;
});
return $this->resource->toArray();
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ContractCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
return $this->resource->toArray();
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class DocumentCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
];
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ class PersonCollection extends ResourceCollection
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'data' => $this->collection
];
}
}
+3 -70
View File
@@ -9,7 +9,6 @@
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;
@@ -19,7 +18,7 @@
class PackageItemSmsJob implements ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $packageItemId)
{
@@ -70,10 +69,6 @@ public function handle(SmsService $sms): void
'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;
@@ -102,7 +97,7 @@ public function handle(SmsService $sms): void
/** @var SmsSender|null $sender */
$sender = $senderId ? SmsSender::find($senderId) : null;
/** @var SmsTemplate|null $template */
$template = $templateId ? SmsTemplate::with(['action', 'decision'])->find($templateId) : null;
$template = $templateId ? SmsTemplate::find($templateId) : null;
$to = $target['number'] ?? null;
if (! is_string($to) || $to === '') {
@@ -122,7 +117,7 @@ public function handle(SmsService $sms): void
$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) {
$sendClosure = function () use ($sms, $item, $package, $profile, $sender, $template, $to, $variables, $deliveryReport, $bodyOverride) {
// Idempotency key (optional external use)
if (empty($item->idempotency_key)) {
$hash = sha1(implode('|', [
@@ -193,25 +188,6 @@ public function handle(SmsService $sms): void
$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');
@@ -238,47 +214,4 @@ public function handle(SmsService $sms): void
$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;
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ public function handle(SmsService $sms): void
}
// 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) {
if (!$this->activityId && $this->templateId && $this->clientCaseId && $log) {
try {
/** @var SmsTemplate|null $template */
$template = SmsTemplate::find($this->templateId);
+2 -7
View File
@@ -75,8 +75,7 @@ protected function performSmtpAuthTest(MailProfile $profile): void
}
$remote = ($encryption === 'ssl') ? 'ssl://'.$host : $host;
$errno = 0;
$errstr = '';
$errno = 0; $errstr = '';
$socket = @fsockopen($remote, $port, $errno, $errstr, 15);
if (! $socket) {
throw new \RuntimeException("Connect failed: $errstr ($errno)");
@@ -105,9 +104,7 @@ protected function performSmtpAuthTest(MailProfile $profile): void
// Cleanly quit
$this->command($socket, "QUIT\r\n", [221], 'QUIT');
} finally {
try {
fclose($socket);
} catch (\Throwable) {
try { fclose($socket); } catch (\Throwable) {
// ignore
}
}
@@ -119,7 +116,6 @@ protected function performSmtpAuthTest(MailProfile $profile): void
protected function command($socket, string $cmd, array $expect, string $context): string
{
fwrite($socket, $cmd);
return $this->expect($socket, $expect, $context);
}
@@ -142,7 +138,6 @@ protected function expect($socket, array $expectedCodes, string $context): strin
if (! in_array($code, $expectedCodes, true)) {
throw new \RuntimeException("Unexpected SMTP code $code during $context: ".implode(' | ', $lines));
}
return $line;
}
}
-2
View File
@@ -6,12 +6,10 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Account extends Model
{
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
use SoftDeletes;
use HasFactory;
protected $fillable = [
+1 -1
View File
@@ -13,7 +13,6 @@ class Action extends Model
{
/** @use HasFactory<\Database\Factories\ActionFactory> */
use HasFactory;
use Searchable;
protected $fillable = ['name', 'color_tag', 'segment_id'];
@@ -32,4 +31,5 @@ public function activities(): HasMany
{
return $this->hasMany(\App\Models\Activity::class);
}
}
+65
View File
@@ -2,6 +2,8 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -57,6 +59,69 @@ protected static function booted()
});
}
/**
* Scope activities to those linked to contracts within a specific segment.
*/
#[Scope]
public function scopeForSegment(Builder $query, int $segmentId, array $contractIds): Builder
{
return $query->where(function ($q) use ($contractIds) {
$q->whereNull('contract_id');
if (! empty($contractIds)) {
$q->orWhereIn('contract_id', $contractIds);
}
});
}
/**
* Scope activities with decoded base64 filters.
*/
#[Scope]
public function scopeWithFilters(Builder $query, ?string $encodedFilters, \App\Models\ClientCase $clientCase): Builder
{
if (empty($encodedFilters)) {
return $query;
}
try {
$decompressed = base64_decode($encodedFilters);
$filters = json_decode($decompressed, true);
if (! is_array($filters)) {
return $query;
}
if (! empty($filters['action_id'])) {
$query->where('action_id', $filters['action_id']);
}
if (! empty($filters['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $filters['contract_uuid'])->first(['id']);
if ($contract) {
$query->where('contract_id', $contract->id);
}
}
if (! empty($filters['user_id'])) {
$query->where('user_id', $filters['user_id']);
}
if (! empty($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
} catch (\Throwable $e) {
\Log::error('Invalid activity filter format', [
'error' => $e->getMessage(),
]);
}
return $query;
}
public function action(): BelongsTo
{
return $this->belongsTo(\App\Models\Action::class);
+6 -5
View File
@@ -3,23 +3,22 @@
namespace App\Models;
use App\Traits\Uuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Scout\Searchable;
class Client extends Model
{
/** @use HasFactory<\Database\Factories\ClientFactory> */
use HasFactory;
use Searchable;
use Uuid;
use Searchable;
protected $fillable = [
'person_id',
'person_id'
];
protected $hidden = [
@@ -27,6 +26,7 @@ class Client extends Model
'person_id',
];
protected function makeAllSearchableUsing(Builder $query): Builder
{
return $query->with('person');
@@ -37,10 +37,11 @@ public function toSearchableArray(): array
return [
'person.full_name' => '',
'person_addresses.address' => '',
'person_addresses.address' => ''
];
}
public function person(): BelongsTo
{
return $this->belongsTo(\App\Models\Person\Person::class);
+16 -17
View File
@@ -3,6 +3,8 @@
namespace App\Models;
use App\Traits\Uuid;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -78,6 +80,20 @@ protected function endDate(): Attribute
);
}
/**
* Scope contracts to those in a specific segment with active pivot.
*/
#[Scope]
public function scopeForSegment(Builder $query, int $segmentId): Builder
{
return $query->whereExists(function ($q) use ($segmentId) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.segment_id', $segmentId)
->where('contract_segment.active', true);
});
}
public function type(): BelongsTo
{
return $this->belongsTo(\App\Models\ContractType::class, 'type_id');
@@ -96,11 +112,6 @@ public function segments(): BelongsToMany
->wherePivot('active', true);
}
public function attachedSegments(): BelongsToMany
{
return $this->belongsToMany(\App\Models\Segment::class);
}
public function account(): HasOne
{
// Use latestOfMany to always surface newest account snapshot if multiple exist.
@@ -119,18 +130,6 @@ public function documents(): MorphMany
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
{
static::created(function (Contract $contract): void {
+1 -6
View File
@@ -24,8 +24,6 @@ class FieldJob extends Model
'priority',
'notes',
'address_snapshot ',
'last_activity',
'added_activity'
];
protected $casts = [
@@ -33,8 +31,6 @@ class FieldJob extends Model
'completed_at' => 'datetime',
'cancelled_at' => 'datetime',
'priority' => 'boolean',
'last_activity' => 'datetime',
'added_activity' => 'boolean',
'address_snapshot ' => 'array',
];
@@ -94,8 +90,7 @@ public function user(): BelongsTo
public function contract(): BelongsTo
{
return $this->belongsTo(Contract::class, 'contract_id')
->where('active', true);
return $this->belongsTo(Contract::class, 'contract_id');
}
/**
+1 -1
View File
@@ -11,7 +11,7 @@ class ImportEvent extends Model
use HasFactory;
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 = [
+1 -1
View File
@@ -11,7 +11,7 @@ class ImportTemplateMapping extends Model
use HasFactory;
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 = [
+4 -65
View File
@@ -12,7 +12,6 @@
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Scout\Attributes\SearchUsingFullText;
use Laravel\Scout\Searchable;
class Person extends Model
@@ -46,7 +45,6 @@ class Person extends Model
'group_id',
'type_id',
'user_id',
'employer'
];
protected $hidden = [
@@ -66,14 +64,6 @@ protected static function booted()
$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
@@ -81,20 +71,16 @@ protected function makeAllSearchableUsing(Builder $query): Builder
return $query->with(['addresses', 'phones', 'emails']);
}
#[SearchUsingFullText(['full_name_search'], ['config' => 'simple'])]
public function toSearchableArray(): array
{
$columns = [
'first_name' => (string) $this->first_name,
'last_name' => (string) $this->last_name,
'full_name' => (string) $this->full_name,
return [
'first_name' => '',
'last_name' => '',
'full_name' => '',
'person_addresses.address' => '',
'person_phones.nu' => '',
'emails.value' => '',
'full_name_search' => (string) $this->full_name_search,
];
return $columns;
}
public function phones(): HasMany
@@ -113,14 +99,6 @@ public function addresses(): HasMany
->orderBy('id');
}
public function address(): HasOne
{
return $this->hasOne(\App\Models\Person\PersonAddress::class)
->with(['type'])
->where('active', '=', 1)
->oldestOfMany('id');
}
public function emails(): HasMany
{
return $this->hasMany(\App\Models\Email::class, 'person_id')
@@ -166,43 +144,4 @@ protected static function generateUniqueNu(): string
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();
}
}
+1
View File
@@ -15,4 +15,5 @@ public function persons(): HasMany
{
return $this->hasMany(\App\Models\Person\Person::class);
}
}
+3 -1
View File
@@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PersonType extends Model
@@ -13,11 +14,12 @@ class PersonType extends Model
protected $fillable = [
'name',
'description',
'description'
];
public function persons(): HasMany
{
return $this->hasMany(\App\Models\Person\Person::class);
}
}
-1
View File
@@ -13,7 +13,6 @@ class Post extends Model
public function toSearchableArray()
{
$array = $this->toArray();
return $array;
}
}
+4 -6
View File
@@ -15,24 +15,22 @@ class Segment extends Model
'name',
'description',
'active',
'exclude',
'exclude'
];
protected function casts(): array
{
return [
'active' => 'boolean',
'exclude' => 'boolean',
'exclude' => 'boolean'
];
}
public function contracts(): BelongsToMany
{
public function contracts(): BelongsToMany {
return $this->belongsToMany(\App\Models\Contract::class);
}
public function clientCase(): BelongsToMany
{
public function clientCase(): BelongsToMany {
return $this->belongsToMany(\App\Models\ClientCase::class)->withTimestamps();
}
}
-2
View File
@@ -30,7 +30,6 @@ class User extends Authenticatable
'name',
'email',
'password',
'active',
];
/**
@@ -64,7 +63,6 @@ protected function casts(): array
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'active' => 'boolean',
];
}
-1
View File
@@ -12,7 +12,6 @@ protected function isAdmin(User $user): bool
if (app()->environment('testing')) {
return true; // simplify for tests
}
return method_exists($user, 'isAdmin') ? $user->isAdmin() : $user->id === 1; // fallback heuristic
}
+1
View File
@@ -4,6 +4,7 @@
use App\Models\Post;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class PostPolicy
{
-19
View File
@@ -6,14 +6,11 @@
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
@@ -36,22 +33,6 @@ public function boot(): void
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::authenticateUsing(function (Request $request) {
$user = User::where('email', $request->email)->first();
if ($user && Hash::check($request->password, $user->password)) {
if (! $user->active) {
throw ValidationException::withMessages([
Fortify::username() => ['Uporabnik je onemogočen.'],
]);
}
return $user;
}
return null;
});
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Providers;
use App\Reports\ActionsDecisionsCountReport;
use App\Reports\ActivitiesPerPeriodReport;
use App\Reports\ActiveContractsReport;
use App\Reports\FieldJobsCompletedReport;
use App\Reports\DecisionsCountReport;
use App\Reports\ReportRegistry;
use App\Reports\SegmentActivityCountsReport;
use Illuminate\Support\ServiceProvider;
class ReportServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ReportRegistry::class, function () {
$registry = new ReportRegistry;
// Register built-in reports here
$registry->register(new FieldJobsCompletedReport);
$registry->register(new SegmentActivityCountsReport);
$registry->register(new ActionsDecisionsCountReport);
$registry->register(new ActivitiesPerPeriodReport);
$registry->register(new DecisionsCountReport);
$registry->register(new ActiveContractsReport);
return $registry;
});
}
public function boot(): void
{
//
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Reports;
use App\Models\Activity;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
class ActionsDecisionsCountReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'actions-decisions-counts';
}
public function name(): string
{
return 'Dejanja / Odločitve štetje';
}
public function description(): ?string
{
return 'Število aktivnosti po dejanjih in odločitvah v obdobju.';
}
public function inputs(): array
{
return [
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
];
}
public function columns(): array
{
return [
['key' => 'action_name', 'label' => 'Dejanje'],
['key' => 'decision_name', 'label' => 'Odločitev'],
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
];
}
public function query(array $filters): Builder
{
return Activity::query()
->leftJoin('actions', 'activities.action_id', '=', 'actions.id')
->leftJoin('decisions', 'activities.decision_id', '=', 'decisions.id')
->when(! empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
->when(! empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
->groupBy('actions.name', 'decisions.name')
->selectRaw("COALESCE(actions.name, '—') as action_name, COALESCE(decisions.name, '—') as decision_name, COUNT(*) as activities_count");
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Reports;
use App\Models\Contract;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
class ActiveContractsReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'active-contracts';
}
public function name(): string
{
return 'Aktivne pogodbe';
}
public function description(): ?string
{
return 'Pogodbe, ki so aktivne na izbrani dan, z možnostjo filtriranja po stranki.';
}
public function inputs(): array
{
return [
['key' => 'client_uuid', 'type' => 'select:client', 'label' => 'Stranka', 'nullable' => true],
];
}
public function columns(): array
{
return [
['key' => 'contract_reference', 'label' => 'Pogodba'],
['key' => 'client_name', 'label' => 'Stranka'],
['key' => 'person_name', 'label' => 'Zadeva (oseba)'],
['key' => 'start_date', 'label' => 'Začetek'],
['key' => 'end_date', 'label' => 'Konec'],
['key' => 'balance_amount', 'label' => 'Saldo'],
];
}
public function query(array $filters): Builder
{
$asOf = now()->toDateString();
return Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->leftJoin('clients', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('person as client_people', 'clients.person_id', '=', 'client_people.id')
->leftJoin('person as subject_people', 'client_cases.person_id', '=', 'subject_people.id')
->leftJoin('accounts', 'contracts.id', '=', 'accounts.contract_id')
->when(! empty($filters['client_uuid']), fn ($q) => $q->where('clients.uuid', $filters['client_uuid']))
// Active as of date: start_date <= as_of (or null) AND (end_date is null OR end_date >= as_of)
->where(function ($q) use ($asOf) {
$q->whereNull('contracts.start_date')
->orWhereDate('contracts.start_date', '<=', $asOf);
})
->where(function ($q) use ($asOf) {
$q->whereNull('contracts.end_date')
->orWhereDate('contracts.end_date', '>=', $asOf);
})
->select([
'contracts.id',
'contracts.start_date',
'contracts.end_date',
])
->addSelect([
\DB::raw('contracts.reference as contract_reference'),
\DB::raw('client_people.full_name as client_name'),
\DB::raw('subject_people.full_name as person_name'),
\DB::raw('CAST(accounts.balance_amount AS FLOAT) as balance_amount'),
])
->orderBy('contracts.start_date', 'asc');
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace App\Reports;
use App\Models\Activity;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class ActivitiesPerPeriodReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'activities-per-period';
}
public function name(): string
{
return 'Aktivnosti po obdobjih';
}
public function description(): ?string
{
return 'Seštevek aktivnosti po dneh/tednih/mesecih v obdobju.';
}
public function inputs(): array
{
return [
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
['key' => 'period', 'type' => 'string', 'label' => 'Obdobje (day|week|month)', 'default' => 'day'],
];
}
public function columns(): array
{
return [
['key' => 'period', 'label' => 'Obdobje'],
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
];
}
public function query(array $filters): Builder
{
$periodRaw = $filters['period'] ?? 'day';
$period = in_array($periodRaw, ['day', 'week', 'month'], true) ? $periodRaw : 'day';
$driver = DB::getDriverName();
// Build database-compatible period expressions
if ($driver === 'sqlite') {
if ($period === 'day') {
// Use string slice to avoid timezone conversion differences in SQLite
$selectExpr = DB::raw('SUBSTR(activities.created_at, 1, 10) as period');
$groupExpr = DB::raw('SUBSTR(activities.created_at, 1, 10)');
$orderExpr = DB::raw('SUBSTR(activities.created_at, 1, 10)');
} elseif ($period === 'month') {
$selectExpr = DB::raw("strftime('%Y-%m-01', activities.created_at) as period");
$groupExpr = DB::raw("strftime('%Y-%m-01', activities.created_at)");
$orderExpr = DB::raw("strftime('%Y-%m-01', activities.created_at)");
} else { // week
$selectExpr = DB::raw("strftime('%Y-%W', activities.created_at) as period");
$groupExpr = DB::raw("strftime('%Y-%W', activities.created_at)");
$orderExpr = DB::raw("strftime('%Y-%W', activities.created_at)");
}
} elseif ($driver === 'mysql') {
if ($period === 'day') {
$selectExpr = DB::raw('DATE(activities.created_at) as period');
$groupExpr = DB::raw('DATE(activities.created_at)');
$orderExpr = DB::raw('DATE(activities.created_at)');
} elseif ($period === 'month') {
$selectExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01') as period");
$groupExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01')");
$orderExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01')");
} else { // week
// ISO week-year-week number for grouping; adequate for summary grouping
$selectExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v') as period");
$groupExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v')");
$orderExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v')");
}
} else { // postgres and others supporting date_trunc
$selectExpr = DB::raw("date_trunc('".$period."', activities.created_at) as period");
$groupExpr = DB::raw("date_trunc('".$period."', activities.created_at)");
$orderExpr = DB::raw("date_trunc('".$period."', activities.created_at)");
}
return Activity::query()
->when(! empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
->when(! empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
->groupBy($groupExpr)
->orderBy($orderExpr)
->select($selectExpr)
->selectRaw('COUNT(*) as activities_count');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Reports;
use App\Reports\Contracts\Report;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request;
abstract class BaseEloquentReport implements Report
{
public function description(): ?string
{
return null;
}
public function authorize(Request $request): void
{
// Default: no extra checks. Controllers can gate via middleware.
}
/**
* @param array<string, mixed> $filters
*/
public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator
{
/** @var EloquentBuilder|QueryBuilder $query */
$query = $this->query($filters);
return $query->paginate($perPage);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Reports\Contracts;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request;
interface Report
{
public function slug(): string;
public function name(): string;
public function description(): ?string;
/**
* Return an array describing input filters (type, label, default, options) for UI.
* Example item: ['key' => 'from', 'type' => 'date', 'label' => 'Od', 'default' => today()]
*
* @return array<int, array<string, mixed>>
*/
public function inputs(): array;
/**
* Return column definitions for the table and exports.
* Example: [ ['key' => 'id', 'label' => '#'], ['key' => 'user', 'label' => 'Uporabnik'] ]
*
* @return array<int, array<string, mixed>>
*/
public function columns(): array;
/**
* Build the data source query for the report based on validated filters.
* Should return an Eloquent or Query builder.
*
* @param array<string, mixed> $filters
* @return EloquentBuilder|QueryBuilder
*/
public function query(array $filters);
/**
* Optional per-report authorization logic.
*/
public function authorize(Request $request): void;
/**
* Execute the report and return a paginator for UI.
*
* @param array<string, mixed> $filters
*/
public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator;
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Reports;
use App\Models\Activity;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
class DecisionsCountReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'decisions-counts';
}
public function name(): string
{
return 'Odločitve štetje';
}
public function description(): ?string
{
return 'Število aktivnosti po odločitvah v izbranem obdobju.';
}
public function inputs(): array
{
return [
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
];
}
public function columns(): array
{
return [
['key' => 'decision_name', 'label' => 'Odločitev'],
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
];
}
public function query(array $filters): Builder
{
return Activity::query()
->leftJoin('decisions', 'activities.decision_id', '=', 'decisions.id')
->when(!empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
->when(!empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
->groupBy('decisions.name')
->selectRaw("COALESCE(decisions.name, '—') as decision_name, COUNT(*) as activities_count");
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Reports;
use App\Models\FieldJob;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
class FieldJobsCompletedReport extends BaseEloquentReport
{
public function slug(): string
{
return 'field-jobs-completed';
}
public function name(): string
{
return 'Zaključeni tereni';
}
public function description(): ?string
{
return 'Pregled zaključenih terenov po datumu in uporabniku.';
}
public function inputs(): array
{
return [
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'default' => now()->startOfMonth()->toDateString()],
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'default' => now()->toDateString()],
['key' => 'user_id', 'type' => 'select:user', 'label' => 'Uporabnik', 'default' => null],
];
}
public function columns(): array
{
return [
['key' => 'id', 'label' => '#'],
['key' => 'contract_reference', 'label' => 'Pogodba'],
['key' => 'assigned_user_name', 'label' => 'Terenski'],
['key' => 'completed_at', 'label' => 'Zaključeno'],
['key' => 'notes', 'label' => 'Opombe'],
];
}
/**
* @param array<string, mixed> $filters
*/
public function query(array $filters): EloquentBuilder
{
$from = isset($filters['from']) ? now()->parse($filters['from'])->startOfDay() : now()->startOfMonth();
$to = isset($filters['to']) ? now()->parse($filters['to'])->endOfDay() : now()->endOfDay();
return FieldJob::query()
->whereNull('cancelled_at')
->whereBetween('completed_at', [$from, $to])
->when(! empty($filters['user_id']), fn ($q) => $q->where('assigned_user_id', $filters['user_id']))
->with(['assignedUser:id,name', 'contract:id,reference'])
->select(['id', 'assigned_user_id', 'contract_id', 'completed_at', 'notes']);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Reports;
use App\Reports\Contracts\Report;
class ReportRegistry
{
/** @var array<string, Report> */
protected array $reports = [];
public function register(Report $report): void
{
$this->reports[$report->slug()] = $report;
}
/**
* @return array<string, Report>
*/
public function all(): array
{
return $this->reports;
}
public function findBySlug(string $slug): ?Report
{
return $this->reports[$slug] ?? null;
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Reports;
use App\Models\Activity;
use App\Reports\Contracts\Report;
use Illuminate\Database\Eloquent\Builder;
class SegmentActivityCountsReport extends BaseEloquentReport implements Report
{
public function slug(): string
{
return 'segment-activity-counts';
}
public function name(): string
{
return 'Aktivnosti po segmentih';
}
public function description(): ?string
{
return 'Število aktivnosti po segmentih v izbranem obdobju (glede na segment dejanja).';
}
public function inputs(): array
{
return [
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
];
}
public function columns(): array
{
return [
['key' => 'segment_name', 'label' => 'Segment'],
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
];
}
public function query(array $filters): Builder
{
$q = Activity::query()
->join('actions', 'activities.action_id', '=', 'actions.id')
->leftJoin('segments', 'actions.segment_id', '=', 'segments.id')
->when(! empty($filters['from']), fn ($qq) => $qq->whereDate('activities.created_at', '>=', $filters['from']))
->when(! empty($filters['to']), fn ($qq) => $qq->whereDate('activities.created_at', '<=', $filters['to']))
->groupBy('segments.name')
->selectRaw("COALESCE(segments.name, 'Brez segmenta') as segment_name, COUNT(*) as activities_count");
return $q;
}
}
+1 -3
View File
@@ -69,8 +69,6 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null,
$entities = $flat;
}
// dd($entities);
foreach ($entities as $entityDef) {
$rawTable = $entityDef['table'] ?? null;
if (! $rawTable) {
@@ -99,7 +97,7 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null,
// Process in batches to avoid locking large tables
while (true) {
$query = DB::table($table)->whereNull('deleted_at');
if (Schema::hasColumn($table, 'active') && ! $reactivate) {
if (Schema::hasColumn($table, 'active')) {
$query->where('active', 1);
}
// Apply context filters or chain derived filters
+181
View File
@@ -0,0 +1,181 @@
<?php
namespace App\Services;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class ClientCaseDataService
{
/**
* Get paginated contracts for a client case with optional segment filtering.
*/
public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int $perPage = 50): LengthAwarePaginator
{
$query = $clientCase->contracts()
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
->with([
'type:id,name',
'account' => function ($q) {
$q->select([
'accounts.id',
'accounts.contract_id',
'accounts.type_id',
'accounts.initial_amount',
'accounts.balance_amount',
'accounts.promise_date',
'accounts.created_at',
'accounts.updated_at',
])->orderByDesc('accounts.id');
},
'segments:id,name',
'objects:id,contract_id,reference,name,description,type,created_at',
])
->orderByDesc('created_at');
if (! empty($segmentId)) {
$query->forSegment($segmentId);
}
$perPage = max(1, min(100, $perPage));
return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
}
/**
* Get paginated activities for a client case with optional segment and filter constraints.
*/
public function getActivities(
ClientCase $clientCase,
?int $segmentId = null,
?string $encodedFilters = null,
array $contractIds = [],
int $perPage = 20
): LengthAwarePaginator {
$query = $clientCase->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->orderByDesc('created_at');
if (! empty($segmentId)) {
$query->forSegment($segmentId, $contractIds);
}
if (! empty($encodedFilters)) {
$query->withFilters($encodedFilters, $clientCase);
}
$perPage = max(1, min(100, $perPage));
return $query->paginate($perPage, ['*'], 'activities_page')->withQueryString();
}
/**
* Get merged documents from case and its contracts.
*/
public function getDocuments(ClientCase $clientCase, array $contractIds = [], int $perPage = 15): LengthAwarePaginator
{
$query = null;
$caseDocsQuery = Document::query()
->select([
'documents.id',
'documents.uuid',
'documents.documentable_id',
'documents.documentable_type',
'documents.name',
'documents.file_name',
'documents.original_name',
'documents.extension',
'documents.mime_type',
'documents.size',
'documents.created_at',
'documents.is_public',
\DB::raw('NULL as contract_reference'),
\DB::raw('NULL as contract_uuid'),
\DB::raw("'{$clientCase->uuid}' as client_case_uuid"),
\DB::raw('users.name as created_by'),
])
->join('users', 'documents.user_id', '=', 'users.id')
->where('documents.documentable_type', ClientCase::class)
->where('documents.documentable_id', $clientCase->id);
if (! empty($contractIds)) {
// Get contract references for mapping
$contracts = Contract::query()
->whereIn('id', $contractIds)
->get(['id', 'uuid', 'reference'])
->keyBy('id');
$contractDocsQuery = Document::query()
->select([
'documents.id',
'documents.uuid',
'documents.documentable_id',
'documents.documentable_type',
'documents.name',
'documents.file_name',
'documents.original_name',
'documents.extension',
'documents.mime_type',
'documents.size',
'documents.created_at',
'documents.is_public',
'contracts.reference as contract_reference',
'contracts.uuid as contract_uuid',
\DB::raw('NULL as client_case_uuid'),
\DB::raw('users.name as created_by'),
])
->join('users', 'documents.user_id', '=', 'users.id')
->join('contracts', 'documents.documentable_id', '=', 'contracts.id')
->where('documents.documentable_type', Contract::class)
->whereIn('documents.documentable_id', $contractIds);
// Union the queries
$query = $caseDocsQuery->union($contractDocsQuery);
} else {
$query = $caseDocsQuery;
}
return \DB::table(\DB::raw("({$query->toSql()}) as documents"))
->mergeBindings($query->getQuery())
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'documentsPage')
->withQueryString();
}
/**
* Get archive metadata from latest non-reactivate archive setting.
*/
public function getArchiveMeta(): array
{
$latestArchiveSetting = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->where(function ($q) {
$q->whereNull('reactivate')->orWhere('reactivate', false);
})
->orderByDesc('id')
->first();
$archiveSegmentId = optional($latestArchiveSetting)->segment_id;
$relatedArchiveTables = [];
if ($latestArchiveSetting) {
$entities = (array) $latestArchiveSetting->entities;
foreach ($entities as $edef) {
if (isset($edef['related']) && is_array($edef['related'])) {
foreach ($edef['related'] as $rel) {
$relatedArchiveTables[] = $rel;
}
}
}
$relatedArchiveTables = array_values(array_unique($relatedArchiveTables));
}
return [
'archive_segment_id' => $archiveSegmentId,
'related_tables' => $relatedArchiveTables,
];
}
}
-2
View File
@@ -38,10 +38,8 @@ public static function toDate(?string $raw): ?string
// Rebuild date with corrected year
$month = (int) $dt->format('m');
$day = (int) $dt->format('d');
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
return $dt->format('Y-m-d');
}
}
@@ -0,0 +1,221 @@
<?php
namespace App\Services\Documents;
use App\Models\Document;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DocumentStreamService
{
/**
* Stream a document either inline or as attachment with all Windows/public fallbacks.
*/
public function stream(Document $document, bool $inline = true): StreamedResponse|Response
{
$disk = $document->disk ?: 'public';
$relPath = $this->normalizePath($document->path ?? '');
// Handle DOC/DOCX previews for inline viewing
if ($inline) {
$previewResponse = $this->tryPreview($document);
if ($previewResponse) {
return $previewResponse;
}
}
// Try to find the file using multiple path candidates
$found = $this->findFile($disk, $relPath);
if (! $found) {
// Try public/ fallback
$found = $this->tryPublicFallback($relPath);
if (! $found) {
abort(404, 'Document file not found');
}
}
$headers = $this->buildHeaders($document, $inline);
// Try streaming first
$stream = Storage::disk($disk)->readStream($found);
if ($stream !== false) {
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, $headers);
}
// Fallbacks on readStream failure
return $this->fallbackStream($disk, $found, $document, $relPath, $headers);
}
/**
* Normalize path for Windows and legacy prefixes.
*/
protected function normalizePath(string $path): string
{
$path = str_replace('\\', '/', $path);
$path = ltrim($path, '/');
if (str_starts_with($path, 'public/')) {
$path = substr($path, 7);
}
return $path;
}
/**
* Build path candidates to try.
*/
protected function buildPathCandidates(string $relPath, ?string $documentPath): array
{
$candidates = [$relPath];
$raw = $documentPath ? ltrim(str_replace('\\', '/', $documentPath), '/') : null;
if ($raw && $raw !== $relPath) {
$candidates[] = $raw;
}
if (str_starts_with($relPath, 'storage/')) {
$candidates[] = substr($relPath, 8);
}
if ($raw && str_starts_with($raw, 'storage/')) {
$candidates[] = substr($raw, 8);
}
return array_unique($candidates);
}
/**
* Try to find file using path candidates.
*/
protected function findFile(string $disk, string $relPath, ?string $documentPath = null): ?string
{
$candidates = $this->buildPathCandidates($relPath, $documentPath);
foreach ($candidates as $cand) {
if (Storage::disk($disk)->exists($cand)) {
return $cand;
}
}
return null;
}
/**
* Try public/ fallback path.
*/
protected function tryPublicFallback(string $relPath): ?string
{
$publicFull = public_path($relPath);
$real = @realpath($publicFull);
$publicRoot = @realpath(public_path());
$realN = $real ? str_replace('\\\\', '/', $real) : null;
$rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null;
if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) {
return $real;
}
return null;
}
/**
* Try to stream preview for DOC/DOCX files.
*/
protected function tryPreview(Document $document): StreamedResponse|Response|null
{
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
if (! in_array($ext, ['doc', 'docx'])) {
return null;
}
$previewDisk = config('files.preview_disk', 'public');
if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) {
$stream = Storage::disk($previewDisk)->readStream($document->preview_path);
if ($stream !== false) {
$previewNameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, [
'Content-Type' => $document->preview_mime ?: 'application/pdf',
'Content-Disposition' => 'inline; filename="'.addslashes($previewNameBase.'.pdf').'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
}
}
// Queue preview generation if not available
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
return response('Preview is being generated. Please try again shortly.', 202);
}
/**
* Build response headers.
*/
protected function buildHeaders(Document $document, bool $inline): array
{
$nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
$name = $ext ? ($nameBase.'.'.$ext) : $nameBase;
return [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($name).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
];
}
/**
* Fallback streaming methods when readStream fails.
*/
protected function fallbackStream(string $disk, string $found, Document $document, string $relPath, array $headers): StreamedResponse|Response
{
// Fallback 1: get() the bytes directly
try {
$bytes = Storage::disk($disk)->get($found);
if (! is_null($bytes) && $bytes !== false) {
return response($bytes, 200, $headers);
}
} catch (\Throwable $e) {
// Continue to next fallback
}
// Fallback 2: open via absolute storage path
$abs = null;
try {
if (method_exists(Storage::disk($disk), 'path')) {
$abs = Storage::disk($disk)->path($found);
}
} catch (\Throwable $e) {
$abs = null;
}
if ($abs && is_file($abs)) {
$fp = @fopen($abs, 'rb');
if ($fp !== false) {
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
// Fallback 3: serve from public path if available
$publicFull = public_path($found);
$real = @realpath($publicFull);
if ($real && is_file($real)) {
$fp = @fopen($real, 'rb');
if ($fp !== false) {
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
abort(404, 'Document file could not be streamed');
}
}
File diff suppressed because it is too large Load Diff
+8 -140
View File
@@ -25,11 +25,6 @@ class ImportSimulationService
*/
private ?int $clientId = null;
/**
* History import mode flag (from template meta).
*/
private bool $historyImport = false;
/**
* Public entry: simulate import applying mappings to first $limit rows.
* Keeps existing machine keys for backward compatibility, but adds Slovenian
@@ -84,7 +79,6 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
$simRows = [];
// Determine keyref behavior for contract.reference from mappings/template
$tplMeta = optional($import->template)->meta ?? [];
$this->historyImport = (bool) ($tplMeta['history_import'] ?? false);
$contractKeyModeTpl = $tplMeta['contract_key_mode'] ?? null; // e.g. 'reference'
$contractRefMode = $this->mappingModeForImport($import, 'contract.reference'); // e.g. 'keyref'
foreach ($rows as $idx => $rawValues) {
@@ -495,38 +489,6 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
}
}
// History import: auto-ensure account placeholder when contract exists but no account mapping
if ($this->historyImport && $existingContract && isset($rowEntities['contract']['id']) && ! isset($rowEntities['account'])) {
if (! isset($summaries['account'])) {
$summaries['account'] = [
'root' => 'account',
'total_rows' => 0,
'create' => 0,
'update' => 0,
'missing_ref' => 0,
'invalid' => 0,
'duplicate' => 0,
'duplicate_db' => 0,
];
}
$summaries['account']['total_rows']++;
$summaries['account']['update']++;
$ref = $rowEntities['contract']['reference'] ?? null;
if ($ref === null || $ref === '') {
$ref = 'HIST-'.$rowEntities['contract']['id'];
}
$rowEntities['account'] = [
'reference' => $ref,
'exists' => true,
'id' => null,
'balance_before' => 0,
'balance_after' => 0,
'action' => 'implicit_history',
'action_label' => $translatedActions['implicit'] ?? 'posredno',
'history_zeroed' => true,
];
}
// Payment (affects account balance; may create implicit account)
if (isset($entityRoots['payment'])) {
// Inject inferred account if none mapped explicitly
@@ -929,7 +891,7 @@ private function simulateContract(callable $val, array $summaries, array $cache,
'client_case_id' => $contract?->client_case_id,
'active' => $contract?->active,
'deleted_at' => $contract?->deleted_at,
'action' => $contract ? ($this->historyImport ? 'skipped_history' : 'update') : ($reference ? 'create' : 'skip'),
'action' => $contract ? 'update' : ($reference ? 'create' : 'skip'),
];
$summaries['contract']['total_rows']++;
if (! $reference) {
@@ -940,11 +902,6 @@ private function simulateContract(callable $val, array $summaries, array $cache,
$summaries['contract']['create']++;
}
if ($this->historyImport && $contract) {
$entity['history_reuse'] = true;
$entity['message'] = 'Existing contract reused (history import)';
}
return [$entity, $summaries, $cache];
}
@@ -974,7 +931,7 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
'exists' => (bool) $account,
'balance_before' => $account?->balance_amount,
'balance_after' => $account?->balance_amount,
'action' => $account ? ($this->historyImport ? 'skipped_history' : 'update') : ($reference ? 'create' : 'skip'),
'action' => $account ? 'update' : ($reference ? 'create' : 'skip'),
];
// Direct balance override support.
@@ -983,7 +940,7 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
$rawIncoming = $val('account.balance_amount')
?? $val('accounts.balance_amount')
?? $val('account.balance');
if (! $this->historyImport && $rawIncoming !== null && $rawIncoming !== '') {
if ($rawIncoming !== null && $rawIncoming !== '') {
$rawStr = (string) $rawIncoming;
// Remove currency symbols and non numeric punctuation except , . -
$clean = preg_replace('/[^0-9,\.\-]+/', '', $rawStr) ?? '';
@@ -1017,19 +974,6 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
$summaries['account']['create']++;
}
if ($this->historyImport) {
// History imports keep balances unchanged and do not update accounts
$entity['balance_after'] = $account?->balance_amount ?? 0;
$entity['balance_before'] = $account?->balance_amount ?? 0;
if ($account) {
$entity['message'] = 'Existing account left unchanged (history import)';
} else {
$entity['balance_after'] = 0;
$entity['balance_before'] = 0;
$entity['history_zeroed'] = true;
}
}
return [$entity, $summaries, $cache];
}
@@ -1266,10 +1210,6 @@ private function simulateGenericRoot(
$reference = $val('phone.nu');
} elseif ($root === 'email') {
$reference = $val('email.value');
} elseif ($root === 'activity') {
$noteRef = $val('activity.note');
$dueRef = $val('activity.due_date');
$reference = $noteRef || $dueRef ? trim((string) ($dueRef ?? '')).($noteRef ? ' | '.$noteRef : '') : null;
}
}
@@ -1297,9 +1237,7 @@ private function simulateGenericRoot(
$entity['country'] = $val('address.country') ?? null;
break;
case 'phone':
$rawNu = $val('phone.nu') ?? null;
// Strip all non-numeric characters from phone number
$entity['nu'] = $rawNu !== null ? preg_replace('/[^0-9]/', '', (string) $rawNu) : null;
$entity['nu'] = $val('phone.nu') ?? null;
break;
case 'email':
$entity['value'] = $val('email.value') ?? null;
@@ -1308,18 +1246,6 @@ private function simulateGenericRoot(
$entity['title'] = $val('client_case.title') ?? null;
$entity['status'] = $val('client_case.status') ?? null;
break;
case 'case_object':
$entity['name'] = $val('case_object.name') ?? null;
$entity['description'] = $val('case_object.description') ?? null;
$entity['type'] = $val('case_object.type') ?? null;
break;
case 'activity':
$entity['note'] = $val('activity.note') ?? null;
$entity['due_date'] = $val('activity.due_date') ?? null;
$entity['amount'] = $val('activity.amount') ?? null;
$entity['action_id'] = $val('activity.action_id') ?? null;
$entity['decision_id'] = $val('activity.decision_id') ?? null;
break;
}
if ($verbose) {
@@ -1387,8 +1313,7 @@ private function genericIdentityCandidates(string $root, callable $val): array
case 'phone':
$nu = $val('phone.nu');
if ($nu) {
// Strip all non-numeric characters from phone number
$norm = preg_replace('/[^0-9]/', '', (string) $nu) ?? '';
$norm = preg_replace('/\D+/', '', (string) $nu) ?? '';
return $norm ? ['nu:'.$norm] : [];
}
@@ -1421,30 +1346,6 @@ private function genericIdentityCandidates(string $root, callable $val): array
}
return [];
case 'case_object':
$ref = $val('case_object.reference');
$name = $val('case_object.name');
$ids = [];
if ($ref) {
// Normalize reference (remove spaces)
$normRef = preg_replace('/\s+/', '', trim((string) $ref));
$ids[] = 'ref:'.$normRef;
}
if ($name) {
$ids[] = 'name:'.mb_strtolower(trim((string) $name));
}
return $ids;
case 'activity':
$note = $val('activity.note');
$due = $val('activity.due_date');
$contractRef = $val('contract.reference');
$ids = [];
if ($note || $due) {
$ids[] = 'activity:'.mb_strtolower(trim((string) ($note ?? ''))).'|'.mb_strtolower(trim((string) ($due ?? ''))).'|'.mb_strtolower(trim((string) ($contractRef ?? '')));
}
return $ids;
default:
return [];
}
@@ -1465,8 +1366,7 @@ private function loadExistingGenericIdentities(string $root): array
case 'phone':
foreach (\App\Models\Person\PersonPhone::query()->pluck('nu') as $p) {
if ($p) {
// Strip all non-numeric characters from phone number
$set['nu:'.preg_replace('/[^0-9]/', '', (string) $p)] = true;
$set['nu:'.preg_replace('/\D+/', '', (string) $p)] = true;
}
}
break;
@@ -1491,32 +1391,6 @@ private function loadExistingGenericIdentities(string $root): array
}
}
break;
case 'case_object':
foreach (\App\Models\CaseObject::query()->get(['reference', 'name']) as $rec) {
if ($rec->reference) {
// Normalize reference (remove spaces)
$normRef = preg_replace('/\s+/', '', trim((string) $rec->reference));
$set['ref:'.$normRef] = true;
}
if ($rec->name) {
$set['name:'.mb_strtolower(trim((string) $rec->name))] = true;
}
}
break;
case 'activity':
foreach (\App\Models\Activity::query()->get(['note', 'due_date', 'contract_id', 'client_case_id']) as $rec) {
$note = mb_strtolower(trim((string) ($rec->note ?? '')));
$due = $rec->due_date ? mb_strtolower(trim((string) $rec->due_date)) : '';
$contractRef = null;
if ($rec->contract_id) {
$contractRef = Contract::where('id', $rec->contract_id)->value('reference');
}
$key = 'activity:'.$note.'|'.$due.'|'.mb_strtolower(trim((string) ($contractRef ?? '')));
if (trim($key, 'activity:|') !== '') {
$set[$key] = true;
}
}
break;
}
} catch (\Throwable) {
// swallow and return what we have
@@ -1537,7 +1411,6 @@ private function modelClassForGeneric(string $root): ?string
'activity' => \App\Models\Activity::class,
'client' => \App\Models\Client::class,
'client_case' => \App\Models\ClientCase::class,
'case_object' => \App\Models\CaseObject::class,
][$root] ?? null;
}
@@ -1690,8 +1563,7 @@ private function simulateGenericRootMulti(
} elseif ($root === 'phone') {
$nu = $groupVals('phone', 'nu')[$g] ?? null;
if ($nu) {
// Strip all non-numeric characters from phone number
$norm = preg_replace('/[^0-9]/', '', (string) $nu) ?? '';
$norm = preg_replace('/\D+/', '', (string) $nu) ?? '';
if ($norm) {
$identityCandidates = ['nu:'.$norm];
}
@@ -1743,9 +1615,7 @@ private function simulateGenericRootMulti(
if ($root === 'email') {
$entity['value'] = $groupVals('email', 'value')[$g] ?? null;
} elseif ($root === 'phone') {
$rawNu = $groupVals('phone', 'nu')[$g] ?? null;
// Strip all non-numeric characters from phone number
$entity['nu'] = $rawNu !== null ? preg_replace('/[^0-9]/', '', (string) $rawNu) : null;
$entity['nu'] = $groupVals('phone', 'nu')[$g] ?? null;
} elseif ($root === 'address') {
$entity['address'] = $groupVals('address', 'address')[$g] ?? null;
$entity['country'] = $groupVals('address', 'country')[$g] ?? null;
@@ -1821,8 +1691,6 @@ private function actionTranslations(): array
'skip' => 'preskoči',
'implicit' => 'posredno',
'reactivate' => 'reaktiviraj',
'skipped_history' => 'preskoči (zgodovina)',
'implicit_history' => 'posredno (zgodovina)',
];
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Services;
use App\Models\AccountType;
use App\Models\ContractType;
use App\Models\Person\AddressType;
use App\Models\Person\PhoneType;
use Illuminate\Support\Facades\Cache;
class ReferenceDataCache
{
private const TTL = 3600; // 1 hour
public function getAddressTypes()
{
return Cache::remember('reference_data:address_types', self::TTL, fn () => AddressType::all());
}
public function getPhoneTypes()
{
return Cache::remember('reference_data:phone_types', self::TTL, fn () => PhoneType::all());
}
public function getAccountTypes()
{
return Cache::remember('reference_data:account_types', self::TTL, fn () => AccountType::all());
}
public function getContractTypes()
{
return Cache::remember('reference_data:contract_types', self::TTL, fn () => ContractType::whereNull('deleted_at')->get());
}
/**
* Clear all reference data cache.
*/
public function clearAll(): void
{
Cache::forget('reference_data:address_types');
Cache::forget('reference_data:phone_types');
Cache::forget('reference_data:account_types');
Cache::forget('reference_data:contract_types');
}
/**
* Clear specific reference data cache.
*/
public function clear(string $type): void
{
Cache::forget("reference_data:{$type}");
}
/**
* Get all types as an array for convenience.
*/
public function getAllTypes(): array
{
return [
'address_types' => $this->getAddressTypes(),
'phone_types' => $this->getPhoneTypes(),
'account_types' => $this->getAccountTypes(),
'contract_types' => $this->getContractTypes(),
];
}
}
-1
View File
@@ -24,7 +24,6 @@ protected function normalizeForSms(string $text): string
{
// Replace NBSP (\xC2\xA0 in UTF-8) and tabs with regular space
$text = str_replace(["\u{00A0}", "\t"], ' ', $text);
// Optionally collapse CRLF to LF (providers typically accept both); keep as-is otherwise
return $text;
}
+1 -3
View File
@@ -3,11 +3,9 @@
namespace App\Traits;
use Illuminate\Support\Str;
trait Uuid
{
protected static function boot()
{
protected static function boot(){
parent::boot();
static::creating(function ($model) {
$model->uuid = (string) Str::uuid();
-1
View File
@@ -15,7 +15,6 @@
$middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class,
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
\App\Http\Middleware\EnsureUserIsActive::class,
]);
$middleware->alias([
+1
View File
@@ -5,4 +5,5 @@
App\Providers\AuthServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
App\Providers\ReportServiceProvider::class,
];
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "resources/css/app.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}
+1
View File
@@ -7,6 +7,7 @@
"require": {
"php": "^8.2",
"arielmejiadev/larapex-charts": "^2.1",
"barryvdh/laravel-dompdf": "^3.1",
"diglactic/laravel-breadcrumbs": "^10.0",
"http-interop/http-factory-guzzle": "^1.2",
"inertiajs/inertia-laravel": "^2.0",
Generated
+374 -9
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d29c47a4d6813ee8e80a7c8112c2f17e",
"content-hash": "d28e6760b713feea1c4ad6058f96287a",
"packages": [
{
"name": "arielmejiadev/larapex-charts",
@@ -113,6 +113,83 @@
},
"time": "2024-10-01T13:55:55+00:00"
},
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-dompdf.git",
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
"shasum": ""
},
"require": {
"dompdf/dompdf": "^3.0",
"illuminate/support": "^9|^10|^11|^12",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.7|^3.0",
"orchestra/testbench": "^7|^8|^9|^10",
"phpro/grumphp": "^2.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
},
"providers": [
"Barryvdh\\DomPDF\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\DomPDF\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "A DOMPDF Wrapper for Laravel",
"keywords": [
"dompdf",
"laravel",
"pdf"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2025-02-13T15:07:54+00:00"
},
{
"name": "brick/math",
"version": "0.12.3",
@@ -761,6 +838,161 @@
],
"time": "2024-02-05T11:56:58+00:00"
},
{
"name": "dompdf/dompdf",
"version": "v3.1.4",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "db712c90c5b9868df3600e64e68da62e78a34623"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623",
"reference": "db712c90c5b9868df3600e64e68da62e78a34623",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.4"
},
"time": "2025-10-29T12:43:30+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
"reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.1"
},
"time": "2024-12-02T14:37:59+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
"reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0"
},
"time": "2024-04-29T13:26:35+00:00"
},
{
"name": "dragonmantank/cron-expression",
"version": "v3.4.0",
@@ -2995,16 +3227,16 @@
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.1",
"version": "3.2.0",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
"shasum": ""
},
"require": {
@@ -3015,7 +3247,7 @@
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"friendsofphp/php-cs-fixer": "^3.16",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
@@ -3061,7 +3293,7 @@
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0"
},
"funding": [
{
@@ -3069,7 +3301,7 @@
"type": "github"
}
],
"time": "2025-12-10T09:58:31+00:00"
"time": "2025-07-17T11:15:13+00:00"
},
{
"name": "markbaker/complex",
@@ -3178,6 +3410,73 @@
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
},
"time": "2025-07-25T09:04:22+00:00"
},
{
"name": "meilisearch/meilisearch-php",
"version": "v1.13.0",
@@ -5031,6 +5330,72 @@
],
"time": "2025-02-28T15:16:05+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v8.9.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9",
"reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
},
"require-dev": {
"phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41",
"rawr/cross-data-providers": "^2.0.0"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0"
},
"time": "2025-07-11T13:20:48+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.92.0",
@@ -10924,6 +11289,6 @@
"platform": {
"php": "^8.2"
},
"platform-dev": [],
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
+1 -1
View File
@@ -98,7 +98,7 @@
'options' => [
'LC_COLLATE' => env('PGSQL_LC_COLLATE', 'en_US.UTF-8'),
'LC_CTYPE' => env('PGSQL_LC_CTYPE', 'en_US.UTF-8'),
],
]
],
'sqlsrv' => [
+2 -2
View File
@@ -14,6 +14,6 @@
'colors' => [
'#008FFB', '#00E396', '#feb019', '#ff455f', '#775dd0', '#80effe',
'#0077B5', '#ff6384', '#c9cbcf', '#0057ff', '00a9f4', '#2ccdc9', '#5e72e4',
],
'#0077B5', '#ff6384', '#c9cbcf', '#0057ff', '00a9f4', '#2ccdc9', '#5e72e4'
]
];
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
// Optionally list Postgres materialized view names to refresh on schedule
'materialized_views' => [
// e.g., 'mv_activities_daily', 'mv_segment_activity_counts'
],
// Time for scheduled refresh (24h format HH:MM)
'refresh_time' => '03:00',
];
+1 -1
View File
@@ -2,8 +2,8 @@
namespace Database\Factories;
use App\Models\Segment;
use Illuminate\Database\Eloquent\Factories\Factory;
use App\Models\Segment;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Action>
@@ -11,38 +11,38 @@
*/
public function up(): void
{
Schema::create('person_types', function (Blueprint $table) {
Schema::create('person_types', function(Blueprint $table){
$table->id();
$table->string('name', 50);
$table->string('description', 125)->nullable();
$table->string('name',50);
$table->string('description',125)->nullable();
$table->softDeletes();
$table->timestamps();
});
Schema::create('person_groups', function (Blueprint $table) {
Schema::create('person_groups', function(Blueprint $table){
$table->id();
$table->string('name', 50);
$table->string('description', 125)->nullable();
$table->string('name',50);
$table->string('description',125)->nullable();
$table->string('color_tag', 50)->nullable();
$table->softDeletes();
$table->timestamps();
});
Schema::create('phone_types', function (Blueprint $table) {
Schema::create('phone_types', function(Blueprint $table){
$table->id();
$table->string('name', 50);
$table->string('description', 125)->nullable();
$table->string('name',50);
$table->string('description',125)->nullable();
$table->softDeletes();
$table->timestamps();
});
Schema::create('address_types', function (Blueprint $table) {
Schema::create('address_types', function(Blueprint $table){
$table->id();
$table->string('name', 50);
$table->string('description', 125)->nullable();
$table->string('name',50);
$table->string('description',125)->nullable();
$table->softDeletes();
$table->timestamps();
@@ -55,11 +55,11 @@ public function up(): void
$table->string('first_name', 255)->nullable();
$table->string('last_name', 255)->nullable();
$table->string('full_name', 255)->nullable();
$table->enum('gender', ['m', 'w'])->nullable();
$table->enum('gender', ['m','w'])->nullable();
$table->date('birthday')->nullable();
$table->string('tax_number', 99)->nullable();
$table->string('social_security_number', 99)->nullable();
$table->string('description', 500)->nullable();
$table->string('social_security_number',99)->nullable();
$table->string('description',500)->nullable();
$table->foreignId('group_id')->references('id')->on('person_groups');
$table->foreignId('type_id')->references('id')->on('person_types');
$table->unsignedTinyInteger('active')->default(1);
@@ -68,12 +68,12 @@ public function up(): void
$table->timestamps();
});
Schema::create('person_phones', function (Blueprint $table) {
Schema::create('person_phones', function(Blueprint $table){
$table->id();
$table->string('nu', 50);
$table->string('nu',50);
$table->unsignedInteger('country_code')->nullable();
$table->foreignId('type_id')->references('id')->on('phone_types');
$table->string('description', 125)->nullable();
$table->string('description',125)->nullable();
$table->foreignIdFor(\App\Models\Person\Person::class);
$table->unsignedTinyInteger('active')->default(1);
$table->softDeletes();
@@ -82,12 +82,12 @@ public function up(): void
});
Schema::create('person_addresses', function (Blueprint $table) {
Schema::create('person_addresses', function(Blueprint $table){
$table->id();
$table->string('address', 150);
$table->string('address',150);
$table->string('country')->nullable();
$table->foreignId('type_id')->references('id')->on('address_types');
$table->string('description', 125)->nullable();
$table->string('description',125)->nullable();
$table->foreignIdFor(\App\Models\Person\Person::class);
$table->unsignedTinyInteger('active')->default(1);
$table->softDeletes();
@@ -11,10 +11,10 @@
*/
public function up(): void
{
Schema::create('account_types', function (Blueprint $table) {
Schema::create('account_types', function(Blueprint $table){
$table->id();
$table->string('name', 50);
$table->string('description', 125)->nullable();
$table->string('name',50);
$table->string('description',125)->nullable();
$table->softDeletes();
$table->timestamps();
@@ -11,25 +11,26 @@
*/
public function up(): void
{
Schema::create('debt_types', function (Blueprint $table) {
Schema::create('debt_types', function(Blueprint $table){
$table->id();
$table->string('name', 50)->unique();
$table->string('description', 125)->nullable();
$table->string('name',50)->unique();
$table->string('description',125)->nullable();
$table->softDeletes();
$table->timestamps();
});
Schema::create('debts', function (Blueprint $table) {
$table->id();
$table->string('reference', 125)->nullable();
$table->string('invoice_nu', 125)->nullable();
$table->string('reference',125)->nullable();
$table->string('invoice_nu',125)->nullable();
$table->date('issue_date')->nullable();
$table->date('due_date')->nullable();
$table->decimal('amount', 11, 4)->nullable();
$table->decimal('interest', 11, 8)->nullable();
$table->date('interest_start_date')->nullable();
$table->string('description', 125)->nullable();
$table->string('description',125)->nullable();
$table->foreignId('account_id')->references('id')->on('accounts');
$table->foreignId('type_id')->references('id')->on('debt_types');
$table->unsignedTinyInteger('active')->default(1);
@@ -9,12 +9,13 @@
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('payment_types', function (Blueprint $table) {
Schema::create('payment_types', function(Blueprint $table){
$table->id();
$table->string('name', 50);
$table->string('description', 125)->nullable();
$table->string('name',50);
$table->string('description',125)->nullable();
$table->softDeletes();
$table->timestamps();
@@ -25,7 +26,7 @@ public function up(): void
$table->string('reference', 125)->nullable();
$table->string('payment_nu', 125)->nullable();
$table->date('payment_date')->nullable();
$table->decimal('amount', 11, 4)->nullable();
$table->decimal('amount',11,4)->nullable();
$table->foreignId('debt_id')->references('id')->on('debts');
$table->foreignId('type_id')->references('id')->on('payment_types');
$table->unsignedTinyInteger('active')->default(1);
@@ -11,10 +11,10 @@
*/
public function up(): void
{
Schema::create('contract_types', function (Blueprint $table) {
Schema::create('contract_types', function(Blueprint $table){
$table->id();
$table->string('name', 50);
$table->string('description', 125)->nullable();
$table->string('name',50);
$table->string('description',125)->nullable();
$table->softDeletes();
$table->timestamps();
@@ -0,0 +1,143 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Contracts table indexes
Schema::table('contracts', function (Blueprint $table) {
if (! $this->indexExists('contracts', 'contracts_client_case_id_active_deleted_at_index')) {
$table->index(['client_case_id', 'active', 'deleted_at'], 'contracts_client_case_id_active_deleted_at_index');
}
if (! $this->indexExists('contracts', 'contracts_start_date_end_date_index')) {
$table->index(['start_date', 'end_date'], 'contracts_start_date_end_date_index');
}
});
// Contract segment pivot table indexes
Schema::table('contract_segment', function (Blueprint $table) {
if (! $this->indexExists('contract_segment', 'contract_segment_contract_id_active_index')) {
$table->index(['contract_id', 'active'], 'contract_segment_contract_id_active_index');
}
if (! $this->indexExists('contract_segment', 'contract_segment_segment_id_active_index')) {
$table->index(['segment_id', 'active'], 'contract_segment_segment_id_active_index');
}
});
// Activities table indexes
Schema::table('activities', function (Blueprint $table) {
if (! $this->indexExists('activities', 'activities_client_case_id_created_at_index')) {
$table->index(['client_case_id', 'created_at'], 'activities_client_case_id_created_at_index');
}
if (! $this->indexExists('activities', 'activities_contract_id_created_at_index')) {
$table->index(['contract_id', 'created_at'], 'activities_contract_id_created_at_index');
}
});
// Client cases table indexes
Schema::table('client_cases', function (Blueprint $table) {
if (! $this->indexExists('client_cases', 'client_cases_client_id_active_index')) {
$table->index(['client_id', 'active'], 'client_cases_client_id_active_index');
}
if (! $this->indexExists('client_cases', 'client_cases_person_id_active_index')) {
$table->index(['person_id', 'active'], 'client_cases_person_id_active_index');
}
});
// Documents table indexes for polymorphic relations
Schema::table('documents', function (Blueprint $table) {
if (! $this->indexExists('documents', 'documents_documentable_type_documentable_id_index')) {
$table->index(['documentable_type', 'documentable_id'], 'documents_documentable_type_documentable_id_index');
}
if (! $this->indexExists('documents', 'documents_created_at_index')) {
$table->index(['created_at'], 'documents_created_at_index');
}
});
// Field jobs indexes
Schema::table('field_jobs', function (Blueprint $table) {
if (! $this->indexExists('field_jobs', 'field_jobs_assigned_user_id_index')) {
$table->index(['assigned_user_id'], 'field_jobs_assigned_user_id_index');
}
if (! $this->indexExists('field_jobs', 'field_jobs_contract_id_index')) {
$table->index(['contract_id'], 'field_jobs_contract_id_index');
}
if (! $this->indexExists('field_jobs', 'field_jobs_completed_at_index')) {
$table->index(['completed_at'], 'field_jobs_completed_at_index');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('contracts', function (Blueprint $table) {
$table->dropIndex('contracts_client_case_id_active_deleted_at_index');
$table->dropIndex('contracts_start_date_end_date_index');
});
Schema::table('contract_segment', function (Blueprint $table) {
$table->dropIndex('contract_segment_contract_id_active_index');
$table->dropIndex('contract_segment_segment_id_active_index');
});
Schema::table('activities', function (Blueprint $table) {
$table->dropIndex('activities_client_case_id_created_at_index');
$table->dropIndex('activities_contract_id_created_at_index');
});
Schema::table('client_cases', function (Blueprint $table) {
$table->dropIndex('client_cases_client_id_active_index');
$table->dropIndex('client_cases_person_id_active_index');
});
Schema::table('documents', function (Blueprint $table) {
$table->dropIndex('documents_documentable_type_documentable_id_index');
$table->dropIndex('documents_created_at_index');
});
Schema::table('field_jobs', function (Blueprint $table) {
$table->dropIndex('field_jobs_assigned_user_id_index');
$table->dropIndex('field_jobs_contract_id_index');
$table->dropIndex('field_jobs_completed_at_index');
});
}
/**
* Check if an index exists on a table.
*/
protected function indexExists(string $table, string $index): bool
{
$connection = Schema::getConnection();
$driver = $connection->getDriverName();
if ($driver === 'pgsql') {
// PostgreSQL uses pg_indexes system catalog
$result = $connection->select(
"SELECT COUNT(*) as count FROM pg_indexes
WHERE schemaname = 'public' AND tablename = ? AND indexname = ?",
[$table, $index]
);
} else {
// MySQL/MariaDB uses information_schema.statistics
$databaseName = $connection->getDatabaseName();
$result = $connection->select(
"SELECT COUNT(*) as count FROM information_schema.statistics
WHERE table_schema = ? AND table_name = ? AND index_name = ?",
[$databaseName, $table, $index]
);
}
return $result[0]->count > 0;
}
};
@@ -12,9 +12,9 @@
public function up(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->decimal('initial_amount', 20, 4)->default(0);
$table->decimal('balance_amount', 20, 4)->default(0);
$table->date('promise_date')->nullable();
$table->decimal("initial_amount", 20, 4)->default(0);
$table->decimal("balance_amount", 20, 4)->default(0);
$table->date("promise_date")->nullable();
$table->index('balance_amount');
$table->index('promise_date');
});
@@ -4,8 +4,7 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
return new class extends Migration {
/**
* Run the migrations.
*/
@@ -10,35 +10,35 @@ public function up(): void
{
// People: unique by (tax_number, social_security_number, deleted_at)
Schema::table('person', function (Blueprint $table) {
if (! self::hasIndex('person', 'person_identity_unique')) {
if (!self::hasIndex('person', 'person_identity_unique')) {
$table->unique(['tax_number', 'social_security_number', 'deleted_at'], 'person_identity_unique');
}
});
// Phones: unique by (person_id, nu, country_code, deleted_at)
Schema::table('person_phones', function (Blueprint $table) {
if (! self::hasIndex('person_phones', 'person_phones_unique')) {
if (!self::hasIndex('person_phones', 'person_phones_unique')) {
$table->unique(['person_id', 'nu', 'country_code', 'deleted_at'], 'person_phones_unique');
}
});
// Addresses: unique by (person_id, address, country, deleted_at)
Schema::table('person_addresses', function (Blueprint $table) {
if (! self::hasIndex('person_addresses', 'person_addresses_unique')) {
if (!self::hasIndex('person_addresses', 'person_addresses_unique')) {
$table->unique(['person_id', 'address', 'country', 'deleted_at'], 'person_addresses_unique');
}
});
// Contracts: unique by (client_case_id, reference, deleted_at)
Schema::table('contracts', function (Blueprint $table) {
if (! self::hasIndex('contracts', 'contracts_reference_unique')) {
if (!self::hasIndex('contracts', 'contracts_reference_unique')) {
$table->unique(['client_case_id', 'reference', 'deleted_at'], 'contracts_reference_unique');
}
});
// Accounts: unique by (contract_id, reference, deleted_at)
Schema::table('accounts', function (Blueprint $table) {
if (! self::hasIndex('accounts', 'accounts_reference_unique')) {
if (!self::hasIndex('accounts', 'accounts_reference_unique')) {
$table->unique(['contract_id', 'reference', 'deleted_at'], 'accounts_reference_unique');
}
});
@@ -70,7 +70,6 @@ private static function hasIndex(string $table, string $index): bool
$connection = Schema::getConnection();
$schemaManager = $connection->getDoctrineSchemaManager();
$doctrineTable = $schemaManager->listTableDetails($table);
return $doctrineTable->hasIndex($index);
} catch (\Throwable $e) {
return false;
@@ -10,7 +10,7 @@ public function up(): void
{
Schema::table('accounts', function (Blueprint $table) {
if (! Schema::hasColumn('accounts', 'balance_amount')) {
if (!Schema::hasColumn('accounts', 'balance_amount')) {
$table->decimal('balance_amount', 18, 4)->nullable()->after('description');
$table->index('balance_amount');
@@ -9,7 +9,7 @@
public function up(): void
{
Schema::table('imports', function (Blueprint $table) {
if (! Schema::hasColumn('imports', 'import_template_id')) {
if (!Schema::hasColumn('imports', 'import_template_id')) {
$table->foreignId('import_template_id')->nullable();
}
// Add foreign key if not exists (Postgres will error if duplicate, so wrap in try/catch in runtime, but Schema builder doesn't support conditional FKs)
@@ -29,9 +29,8 @@ public function up(): void
$used = [];
foreach ($rows as $row) {
if (is_string($row->nu) && preg_match('/^[A-Za-z0-9]{6}$/', $row->nu)) {
if (! isset($used[$row->nu])) {
if (!isset($used[$row->nu])) {
$used[$row->nu] = true;
continue;
}
// duplicate will be regenerated below
@@ -9,7 +9,7 @@
public function up(): void
{
Schema::table('import_mappings', function (Blueprint $table) {
if (! Schema::hasColumn('import_mappings', 'position')) {
if (!Schema::hasColumn('import_mappings', 'position')) {
$table->unsignedInteger('position')->nullable()->after('options');
}
$table->index(['import_id', 'position']);

Some files were not shown because too many files have changed in this diff Show More