Teren-app/app/Http/Controllers/ClientCaseContoller.php
Simon Pocrnjič 63e0958b66 Dev branch
2025-11-02 12:31:01 +01:00

1469 lines
64 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreContractRequest;
use App\Http\Requests\UpdateContractRequest;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
use App\Services\Documents\DocumentStreamService;
use App\Services\ReferenceDataCache;
use App\Services\Sms\SmsService;
use Exception;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
class ClientCaseContoller extends Controller
{
public function __construct(
protected ReferenceDataCache $referenceCache,
protected DocumentStreamService $documentStream
) {}
/**
* Display a listing of the resource.
*/
public function index(ClientCase $clientCase, Request $request)
{
$search = $request->input('search');
$query = $clientCase::query()
->select('client_cases.*')
->when($search, function ($que) use ($search) {
$que->join('person', 'person.id', '=', 'client_cases.person_id')
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('client_cases.id');
})
->where('client_cases.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->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([
// Count of active contracts (a contract is considered active if it has an active pivot in contract_segment)
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
// Sum of balances for accounts of 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'),
])
->with(['person.client', 'client.person'])
->orderByDesc('client_cases.created_at');
return Inertia::render('Cases/Index', [
'client_cases' => $query
->paginate($request->integer('perPage', 15), ['*'], 'client-cases-page')
->withQueryString(),
'filters' => $request->only(['search']),
]);
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
//
$cuuid = $request->input('client_uuid');
$client = \App\Models\Client::where('uuid', $cuuid)->firstOrFail();
if (isset($client->id)) {
\DB::transaction(function () use ($request, $client) {
$pq = $request->input('person');
$person = $client->person()->create([
'nu' => rand(100000, 200000),
'first_name' => $pq['first_name'],
'last_name' => $pq['last_name'],
'full_name' => $pq['full_name'],
'gender' => null,
'birthday' => null,
'tax_number' => $pq['tax_number'],
'social_security_number' => $pq['social_security_number'],
'description' => $pq['description'],
'group_id' => 2,
'type_id' => 1,
]);
$person->addresses()->create([
'address' => $pq['address']['address'],
'country' => $pq['address']['country'],
'type_id' => $pq['address']['type_id'],
]);
$person->phones()->create([
'nu' => $pq['phone']['nu'],
'country_code' => $pq['phone']['country_code'],
'type_id' => $pq['phone']['type_id'],
]);
$person->clientCase()->create([
'client_id' => $client->id,
]);
});
}
return to_route('client.show', $client);
}
public function storeContract(ClientCase $clientCase, StoreContractRequest $request)
{
\DB::transaction(function () use ($request, $clientCase) {
// Create contract
$contract = $clientCase->contracts()->create([
'reference' => $request->input('reference'),
'start_date' => \App\Services\DateNormalizer::toDate($request->input('start_date')),
'type_id' => $request->input('type_id'),
'description' => $request->input('description'),
]);
// Note: Contract config auto-application is handled in Contract model created hook.
// Optionally create related account with amounts and/or type
$initial = $request->input('initial_amount');
$balance = $request->input('balance_amount');
$accountTypeId = $request->input('account_type_id');
if (! is_null($initial) || ! is_null($balance) || ! is_null($accountTypeId)) {
$contract->account()->create([
'type_id' => $accountTypeId,
'initial_amount' => $initial ?? 0,
'balance_amount' => $balance ?? 0,
]);
}
});
// Preserve segment filter if present
$segment = request('segment');
return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]);
}
public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request)
{
$contract = Contract::where('uuid', $uuid)->firstOrFail();
if (! $contract->active) {
return back()->with('warning', __('contracts.edit_not_allowed_archived'));
}
\DB::transaction(function () use ($request, $contract) {
$contract->update([
'reference' => $request->input('reference'),
'type_id' => $request->input('type_id'),
'description' => $request->input('description'),
'start_date' => $request->filled('start_date') ? \App\Services\DateNormalizer::toDate($request->input('start_date')) : $contract->start_date,
]);
$initial = $request->input('initial_amount');
// Use has() to distinguish between an omitted field and an explicit 0 / null intent
$balanceFieldPresent = $request->has('balance_amount');
$balance = $balanceFieldPresent ? $request->input('balance_amount') : null;
// Always allow updating existing account even if only balance set to 0 (or unchanged) so user can correct it.
$hasType = $request->has('account_type_id');
$shouldUpsertAccount = ($contract->account()->exists()) || (! is_null($initial)) || $balanceFieldPresent || $hasType;
if ($shouldUpsertAccount) {
$accountData = [];
// Track old balance before applying changes
$currentAccount = $contract->account; // newest (latestOfMany)
if (! is_null($initial)) {
$accountData['initial_amount'] = $initial;
}
// If the balance field was present in the request payload we always apply it (allow setting to 0)
if ($balanceFieldPresent) {
// Allow explicitly setting to 0, fallback to 0 if null provided
$accountData['balance_amount'] = $balance ?? 0;
}
if ($request->has('account_type_id')) {
$accountData['type_id'] = $request->input('account_type_id');
}
if ($currentAccount) {
$currentAccount->update($accountData);
if (array_key_exists('balance_amount', $accountData)) {
$currentAccount->forceFill(['balance_amount' => $accountData['balance_amount']])->save();
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
if ((float) $freshBal !== (float) $accountData['balance_amount']) {
\DB::table('accounts')
->where('id', $currentAccount->id)
->update(['balance_amount' => $accountData['balance_amount'], 'updated_at' => now()]);
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
}
} else {
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
}
} else {
$accountData = array_merge(['initial_amount' => 0, 'balance_amount' => 0], $accountData);
$created = $contract->account()->create($accountData);
$freshBal = (float) optional($created->fresh())->balance_amount;
}
}
});
// Preserve segment filter if present
$segment = request('segment');
return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]);
}
/**
* Debug endpoint: list all account rows for a contract (only in debug mode).
*/
public function debugContractAccounts(ClientCase $clientCase, string $uuid, Request $request)
{
abort_unless(config('app.debug'), 404);
$contract = $clientCase->contracts()->where('uuid', $uuid)->firstOrFail(['id', 'uuid', 'reference']);
$accounts = \DB::table('accounts')
->where('contract_id', $contract->id)
->orderBy('id')
->get(['id', 'contract_id', 'initial_amount', 'balance_amount', 'type_id', 'created_at', 'updated_at']);
return response()->json([
'contract' => $contract,
'accounts' => $accounts,
'count' => $accounts->count(),
]);
}
public function storeActivity(ClientCase $clientCase, Request $request)
{
try {
$attributes = $request->validate([
'due_date' => 'nullable|date',
'amount' => 'nullable|decimal:0,4',
'note' => 'nullable|string',
'action_id' => 'exists:\App\Models\Action,id',
'decision_id' => 'exists:\App\Models\Decision,id',
'contract_uuid' => 'nullable|uuid',
'send_auto_mail' => 'sometimes|boolean',
'attachment_document_ids' => 'sometimes|array',
'attachment_document_ids.*' => 'integer',
]);
// Map contract_uuid to contract_id within the same client case, if provided
$contractId = null;
if (! empty($attributes['contract_uuid'])) {
$contract = Contract::withTrashed()
->where('uuid', $attributes['contract_uuid'])
->where('client_case_id', $clientCase->id)
->first();
if ($contract) {
// Archived contracts are allowed: link activity regardless of active flag
$contractId = $contract->id;
}
}
// Create activity
$row = $clientCase->activities()->create([
'due_date' => $attributes['due_date'] ?? null,
'amount' => $attributes['amount'] ?? null,
'note' => $attributes['note'] ?? null,
'action_id' => $attributes['action_id'],
'decision_id' => $attributes['decision_id'],
'contract_id' => $contractId,
]);
/*foreach ($activity->decision->events as $e) {
$class = '\\App\\Events\\' . $e->name;
event(new $class($clientCase));
}*/
logger()->info('Activity successfully inserted', $attributes);
// Auto mail dispatch (best-effort)
try {
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
// Filter attachments to those belonging to the selected contract
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
->filter()
->map(fn ($v) => (int) $v)
->values();
$validAttachmentIds = collect();
if ($attachmentIds->isNotEmpty() && $contractId) {
$validAttachmentIds = \App\Models\Document::query()
->where('documentable_type', \App\Models\Contract::class)
->where('documentable_id', $contractId)
->whereIn('id', $attachmentIds)
->pluck('id');
}
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
'attachment_ids' => $validAttachmentIds->all(),
]);
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
// If template requires contract and user attempted to send, surface a validation message
return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
}
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.');
}
} catch (\Throwable $e) {
// Do not fail activity creation due to mailing issues
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
}
// Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route.
// Use 303 to align with Inertia's recommended POST/Redirect/GET behavior.
return back(303)->with('success', 'Successful created!');
} catch (QueryException $e) {
logger()->error('Database error occurred:', ['error' => $e->getMessage()]);
return back()->with('error', 'Failed to insert activity. '.$e->getMessage());
} catch (Exception $e) {
logger()->error('An unexpected error occurred:', ['error' => $e->getMessage()]);
// Return a generic error response
return back()->with('error', 'An unexpected error occurred. Please try again later.');
}
}
public function deleteActivity(ClientCase $clientCase, \App\Models\Activity $activity, Request $request)
{
// Ensure activity belongs to this case
if ($activity->client_case_id !== $clientCase->id) {
abort(404);
}
\DB::transaction(function () use ($activity) {
$activity->delete();
});
return back()->with('success', 'Activity deleted.');
}
public function deleteContract(ClientCase $clientCase, string $uuid, Request $request)
{
$contract = Contract::where('uuid', $uuid)->firstOrFail();
// Preserve segment filter if present
$segment = request('segment');
return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]);
}
public function updateContractSegment(ClientCase $clientCase, string $uuid, Request $request)
{
$validated = $request->validate([
'segment_id' => ['required', 'integer', 'exists:segments,id'],
]);
$contract = $clientCase->contracts()->where('uuid', $uuid)->firstOrFail();
// Safety: Disallow segment change if contract archived (inactive)
if (! $contract->active) {
return back()->with('warning', __('contracts.segment_change_not_allowed_archived'));
}
\DB::transaction(function () use ($contract, $validated) {
// Deactivate current active relation(s)
\DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('active', true)
->update(['active' => false]);
// Attach or update the selected segment as active
$existing = \DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('segment_id', $validated['segment_id'])
->first();
if ($existing) {
\DB::table('contract_segment')
->where('id', $existing->id)
->update(['active' => true, 'updated_at' => now()]);
} else {
$contract->segments()->attach($validated['segment_id'], ['active' => true, 'created_at' => now(), 'updated_at' => now()]);
}
});
return back()->with('success', 'Contract segment updated.');
}
public function attachSegment(ClientCase $clientCase, Request $request)
{
$validated = $request->validate([
'segment_id' => ['required', 'integer', 'exists:segments,id'],
'contract_uuid' => ['nullable', 'uuid'],
'make_active_for_contract' => ['sometimes', 'boolean'],
]);
\DB::transaction(function () use ($clientCase, $validated) {
// Attach segment to client case if not already attached
$attached = \DB::table('client_case_segment')
->where('client_case_id', $clientCase->id)
->where('segment_id', $validated['segment_id'])
->first();
if (! $attached) {
$clientCase->segments()->attach($validated['segment_id'], ['active' => true]);
} elseif (! $attached->active) {
\DB::table('client_case_segment')
->where('id', $attached->id)
->update(['active' => true, 'updated_at' => now()]);
}
// Optionally make it active for a specific contract
if (! empty($validated['contract_uuid']) && ($validated['make_active_for_contract'] ?? false)) {
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->firstOrFail();
if (! $contract->active) {
// Prevent segment activation for archived contract
return; // Silent; we still attach to case but do not alter archived contract
}
\DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('active', true)
->update(['active' => false]);
$existing = \DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('segment_id', $validated['segment_id'])
->first();
if ($existing) {
\DB::table('contract_segment')
->where('id', $existing->id)
->update(['active' => true, 'updated_at' => now()]);
} else {
$contract->segments()->attach($validated['segment_id'], ['active' => true, 'created_at' => now(), 'updated_at' => now()]);
}
}
});
return back()->with('success', 'Segment attached to case.');
}
public function storeDocument(ClientCase $clientCase, Request $request)
{
$validated = $request->validate([
'file' => 'required|file|max:25600|mimes:doc,docx,pdf,txt,csv,xls,xlsx,jpeg,png', // 25MB and allowed types
'name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'is_public' => 'sometimes|boolean',
'contract_uuid' => 'nullable|uuid',
]);
$file = $validated['file'];
$disk = 'public';
$contract = null;
if (! empty($validated['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
if ($contract && ! $contract->active) {
return back()->with('warning', __('contracts.document_not_allowed_archived'));
}
}
$directory = $contract
? ('contracts/'.$contract->uuid.'/documents')
: ('cases/'.$clientCase->uuid.'/documents');
$path = $file->store($directory, $disk);
$doc = new Document([
'name' => $validated['name'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME),
'description' => $validated['description'] ?? null,
'user_id' => optional($request->user())->id,
'disk' => $disk,
'path' => $path,
'file_name' => basename($path),
'original_name' => $file->getClientOriginalName(),
'extension' => $file->getClientOriginalExtension(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'checksum' => null,
'is_public' => (bool) ($validated['is_public'] ?? false),
]);
if ($contract) {
$contract->documents()->save($doc);
} else {
$clientCase->documents()->save($doc);
}
// Generate preview immediately for Office docs to avoid first-view delay
$ext = strtolower($doc->extension ?? pathinfo($doc->original_name ?? $doc->file_name, PATHINFO_EXTENSION));
if (in_array($ext, ['doc', 'docx'])) {
\App\Jobs\GenerateDocumentPreview::dispatch($doc->id);
}
return back()->with('success', 'Document uploaded.');
}
public function updateDocument(ClientCase $clientCase, Document $document, Request $request)
{
// Validate that the document being updated is scoped to this case (or one of its contracts).
// Ensure the document belongs to this case or its contracts
$belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id;
$belongsToContractOfCase = false;
if ($document->documentable_type === Contract::class) {
$belongsToContractOfCase = Contract::withTrashed()
->where('id', $document->documentable_id)
->where('client_case_id', $clientCase->id)
->exists();
}
if (! ($belongsToCase || $belongsToContractOfCase)) {
logger()->warning('Document update 404: document not in scope of client case or its contracts', [
'doc_id' => $document->id,
'doc_uuid' => $document->uuid,
'doc_type' => $document->documentable_type,
'doc_doc_id' => $document->documentable_id,
'route_case_id' => $clientCase->id,
'route_case_uuid' => $clientCase->uuid,
]);
abort(404);
}
// Strictly validate that provided contract_uuid (when present) belongs to THIS client case.
// If a different case's contract UUID is provided, return a validation error (422) instead of falling back.
$validated = $request->validate([
'name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'is_public' => 'sometimes|boolean',
// Optional reassignment to a contract within the same case
// Note: empty string explicitly means "move back to case" when the key exists in the request.
'contract_uuid' => [
'nullable',
'uuid',
\Illuminate\Validation\Rule::exists('contracts', 'uuid')->where(function ($q) use ($clientCase, $request) {
// Allow empty string if key exists (handled later) by skipping exists check when empty
$incoming = $request->input('contract_uuid');
if (is_null($incoming) || $incoming === '') {
// Return a condition that always matches something harmless; exists rule is ignored in this case
return $q; // no-op, DBAL will still run but empty will be caught by nullable
}
return $q->where('client_case_id', $clientCase->id);
}),
],
]);
// Basic attribute updates
$document->name = $validated['name'] ?? $document->name;
if (array_key_exists('description', $validated)) {
$document->description = $validated['description'];
}
if (array_key_exists('is_public', $validated)) {
$document->is_public = (bool) $validated['is_public'];
}
// Reassign to contract or back to case IF the key is present in the payload (explicit intent).
if ($request->exists('contract_uuid')) {
$incoming = $request->input('contract_uuid');
if ($incoming === '' || is_null($incoming)) {
// Explicitly move relation back to the case
$document->documentable_type = ClientCase::class;
$document->documentable_id = $clientCase->id;
} else {
// Safe to resolve within this case due to the validation rule above
$target = $clientCase->contracts()->where('uuid', $incoming)->firstOrFail(['id', 'uuid', 'active']);
if (! $target->active) {
return back()->with('warning', __('contracts.document_not_allowed_archived'));
}
$document->documentable_type = Contract::class;
$document->documentable_id = $target->id;
}
}
$document->save();
// Refresh documents list on page
return back()->with('success', __('Document updated.'));
}
public function viewDocument(ClientCase $clientCase, Document $document, Request $request)
{
// Ensure the document belongs to this client case or its contracts
$belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id;
$belongsToContractOfCase = false;
if ($document->documentable_type === Contract::class) {
// Include soft-deleted contracts when verifying ownership to this case
$belongsToContractOfCase = Contract::withTrashed()
->where('id', $document->documentable_id)
->where('client_case_id', $clientCase->id)
->exists();
}
if (! ($belongsToCase || $belongsToContractOfCase)) {
logger()->warning('Document view 404: document does not belong to case or its contracts', [
'document_id' => $document->id,
'document_uuid' => $document->uuid,
'documentable_type' => $document->documentable_type,
'documentable_id' => $document->documentable_id,
'client_case_id' => $clientCase->id,
'client_case_uuid' => $clientCase->uuid,
]);
abort(404);
}
return $this->documentStream->stream($document, inline: true);
}
public function downloadDocument(ClientCase $clientCase, Document $document, Request $request)
{
$belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id;
$belongsToContractOfCase = false;
if ($document->documentable_type === Contract::class) {
$belongsToContractOfCase = Contract::withTrashed()
->where('id', $document->documentable_id)
->where('client_case_id', $clientCase->id)
->exists();
}
if (! ($belongsToCase || $belongsToContractOfCase)) {
logger()->warning('Document download 404: document does not belong to case or its contracts', [
'document_id' => $document->id,
'document_uuid' => $document->uuid,
'documentable_type' => $document->documentable_type,
'documentable_id' => $document->documentable_id,
'client_case_id' => $clientCase->id,
'client_case_uuid' => $clientCase->uuid,
]);
abort(404);
}
return $this->documentStream->stream($document, inline: false);
}
/**
* View a contract document using contract route binding.
*/
public function viewContractDocument(Contract $contract, Document $document, Request $request)
{
// Ensure the document belongs to this contract (including trashed docs)
$belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id;
if (! $belongs) {
abort(404);
}
return $this->documentStream->stream($document, inline: true);
}
/**
* Download a contract document using contract route binding.
*/
public function downloadContractDocument(Contract $contract, Document $document, Request $request)
{
$belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id;
if (! $belongs) {
abort(404);
}
return $this->documentStream->stream($document, inline: false);
}
/**
* Display the specified resource.
*/
public function show(ClientCase $clientCase)
{
$case = $clientCase::with([
'person' => fn ($que) => $que->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']),
])->where('active', 1)->findOrFail($clientCase->id);
$types = [
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
// $active = false;
// Optional segment filter from query string
$segmentId = request()->integer('segment');
// Determine latest archive (non-reactivate) setting for this context to infer archive segment and related tables
$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; // may be null
$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));
}
// Prepare contracts and a reference map.
// Only apply active/inactive filtering IF a segment filter is provided.
$contractsQuery = $case->contracts()
// Only select lean columns to avoid oversize JSON / headers (include description for UI display)
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
->with([
'type:id,name',
// Use closure for account to avoid ambiguous column names with latestOfMany join
'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', // include updated_at so FE can detect changes & for debugging
])->orderByDesc('accounts.id');
},
'segments:id,name',
// Eager load objects so newly created objects appear without full reload logic issues
'objects:id,contract_id,reference,name,description,type,created_at',
]);
$contractsQuery->orderByDesc('created_at');
if (! empty($segmentId)) {
// Filter to contracts that are in the provided segment and active on pivot
$contractsQuery->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);
});
}
// Use pagination for contracts to avoid loading too many at once
// Default to 50 per page, but allow frontend to request more
$perPage = request()->integer('contracts_per_page', 50);
$contracts = $contractsQuery->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
// TEMP DEBUG: log what balances are being sent to Inertia (remove once issue resolved)
try {
logger()->info('Show contracts balances', [
'case_id' => $case->id,
'contract_count' => $contracts->count(),
'contracts' => $contracts->map(fn ($c) => [
'id' => $c->id,
'uuid' => $c->uuid,
'reference' => $c->reference,
'account_id' => optional($c->account)->id,
'initial_amount' => optional($c->account)->initial_amount,
'balance_amount' => optional($c->account)->balance_amount,
'account_updated_at' => optional($c->account)->updated_at,
])->toArray(),
]);
} catch (\Throwable $e) {
// swallow
}
// Prepare contract reference and UUID maps from paginated contracts
$contractItems = $contracts instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator
? $contracts->items()
: $contracts->all();
$contractRefMap = [];
$contractUuidMap = [];
foreach ($contractItems as $c) {
$contractRefMap[$c->id] = $c->reference;
$contractUuidMap[$c->id] = $c->uuid;
}
$contractIds = collect($contractItems)->pluck('id');
// Resolve current segment for display when filtered
$currentSegment = null;
if (! empty($segmentId)) {
$currentSegment = \App\Models\Segment::query()->select('id', 'name')->find($segmentId);
}
// Load initial batch of documents (limit to reduce payload size)
$contractDocs = collect();
if ($contractIds->isNotEmpty()) {
// Build UUID map for all contracts (including trashed) to avoid N+1 queries
$allContractUuids = Contract::withTrashed()
->whereIn('id', $contractIds->all())
->pluck('uuid', 'id')
->toArray();
// Merge with contracts already loaded
$contractUuidMap = array_merge($contractUuidMap, $allContractUuids);
$contractDocs = Document::query()
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
->where('documentable_type', Contract::class)
->whereIn('documentable_id', $contractIds->all())
->orderByDesc('created_at')
->limit(50) // Initial batch - frontend can request more via separate endpoint if needed
->get()
->map(function ($d) use ($contractRefMap, $contractUuidMap) {
$arr = $d->toArray();
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
$arr['contract_uuid'] = $contractUuidMap[$d->documentable_id] ?? null;
return $arr;
});
}
$caseDocs = $case->documents()
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
->orderByDesc('created_at')
->limit(50) // Initial batch
->get()
->map(function ($d) use ($case) {
$arr = $d->toArray();
$arr['client_case_uuid'] = $case->uuid;
return $arr;
});
$mergedDocs = $caseDocs
->concat($contractDocs)
->sortByDesc('created_at')
->values();
return Inertia::render('Cases/Show', [
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(),
'client_case' => $case,
'contracts' => $contracts, // Now paginated
'documents' => $mergedDocs,
])->with([
// Active document templates for contracts (latest version per slug)
'contract_doc_templates' => \App\Models\DocumentTemplate::query()
->where('active', true)
->where('core_entity', 'contract')
->orderBy('slug')
->get(['id', 'name', 'slug', 'version', 'tokens', 'meta'])
->groupBy('slug')
->map(fn ($g) => $g->sortByDesc('version')->first())
->values(),
'archive_meta' => [
'archive_segment_id' => $archiveSegmentId,
'related_tables' => $relatedArchiveTables,
],
'activities' => tap(
(function () use ($case, $segmentId, $contractIds) {
$q = $case->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->orderByDesc('created_at');
if (! empty($segmentId)) {
// Only activities for filtered contracts or unlinked (contract_id null)
$q->where(function ($qq) use ($contractIds) {
$qq->whereNull('contract_id');
if ($contractIds->isNotEmpty()) {
$qq->orWhereIn('contract_id', $contractIds);
}
});
}
return $q->paginate(20, ['*'], 'activities')->withQueryString();
})(),
function ($p) {
$p->getCollection()->transform(function ($a) {
$a->setAttribute('user_name', optional($a->user)->name);
return $a;
});
}
),
'contract_types' => $this->referenceCache->getContractTypes(),
'account_types' => $this->referenceCache->getAccountTypes(),
// Include decisions with auto-mail metadata and the linked email template entity_types for UI logic
'actions' => \App\Models\Action::query()
->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']),
'types' => $types,
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
'current_segment' => $currentSegment,
// SMS helpers for per-case sending UI
'sms_profiles' => \App\Models\SmsProfile::query()
->select(['id', 'name', 'default_sender_id'])
->where('active', true)
->orderBy('name')
->get(),
'sms_senders' => \App\Models\SmsSender::query()
->select(['id', 'profile_id'])
->addSelect(\DB::raw('sname as name'))
->addSelect(\DB::raw('phone_number as phone'))
->orderBy('sname')
->get(),
'sms_templates' => \App\Models\SmsTemplate::query()
->select(['id', 'name', 'content', 'allow_custom_body'])
->orderBy('name')
->get(),
]);
}
/**
* Show the form for editing the specified resource.
*/
public function edit(string $id)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id)
{
//
}
/**
* Delete a document that belongs either directly to the client case or to one of its (even soft deleted) contracts.
*/
public function deleteDocument(ClientCase $clientCase, Document $document, Request $request)
{
// Ownership check: direct case document?
$belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id;
// Or document of a contract that belongs to this case (include trashed contracts)
$belongsToContractOfCase = false;
if ($document->documentable_type === Contract::class) {
$belongsToContractOfCase = Contract::withTrashed()
->where('id', $document->documentable_id)
->where('client_case_id', $clientCase->id)
->exists();
}
if (! ($belongsToCase || $belongsToContractOfCase)) {
abort(404);
}
// (Optional future) $this->authorize('delete', $document);
$document->delete(); // soft delete
return $request->wantsJson()
? response()->json(['status' => 'ok'])
: back()->with('success', 'Document deleted.');
}
/**
* Delete a document accessed through a contract route binding.
*/
public function deleteContractDocument(Contract $contract, Document $document, Request $request)
{
$belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id;
if (! $belongs) {
abort(404);
}
// (Optional future) $this->authorize('delete', $document);
$document->delete();
return $request->wantsJson()
? response()->json(['status' => 'ok'])
: back()->with('success', 'Document deleted.');
}
/**
* Manually archive a contract (flag active=0) and optionally its immediate financial relations.
*/
public function archiveContract(ClientCase $clientCase, string $uuid, Request $request)
{
$contract = Contract::query()->where('uuid', $uuid)->firstOrFail();
if ($contract->client_case_id !== $clientCase->id) {
abort(404);
}
$reactivateRequested = (bool) $request->boolean('reactivate');
// Determine applicable settings based on intent (archive vs reactivate)
if ($reactivateRequested) {
$latestReactivate = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->where('reactivate', true)
->whereIn('strategy', ['immediate', 'manual'])
->orderByDesc('id')
->first();
if (! $latestReactivate) {
return back()->with('warning', __('contracts.reactivate_not_allowed'));
}
$settings = collect([$latestReactivate]);
$hasReactivateRule = true;
} else {
$settings = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->whereIn('strategy', ['immediate', 'manual'])
->where(function ($q) { // exclude reactivate-only rules from archive run
$q->whereNull('reactivate')->orWhere('reactivate', false);
})
->get();
if ($settings->isEmpty()) {
return back()->with('warning', __('contracts.no_archive_settings'));
}
$hasReactivateRule = false;
}
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
$context = [
'contract_id' => $contract->id,
'client_case_id' => $clientCase->id,
];
if ($contract->account) {
$context['account_id'] = $contract->account->id;
}
$overall = [];
$hadAnyEffect = false;
foreach ($settings as $setting) {
$res = $executor->executeSetting($setting, $context, optional($request->user())->id);
foreach ($res as $table => $count) {
$overall[$table] = ($overall[$table] ?? 0) + $count;
if ($count > 0) {
$hadAnyEffect = true;
}
}
}
if ($reactivateRequested && $hasReactivateRule) {
// Reactivation path: ensure contract becomes active and soft-delete cleared.
if ($contract->active == 0 || $contract->deleted_at) {
$contract->forceFill(['active' => 1, 'deleted_at' => null])->save();
$overall['contracts_reactivated'] = ($overall['contracts_reactivated'] ?? 0) + 1;
$hadAnyEffect = true;
}
} else {
// Ensure the contract itself is archived even if rule conditions would have excluded it
if (! empty($contract->getAttributes()) && $contract->active) {
if (! array_key_exists('contracts', $overall)) {
$contract->update(['active' => 0]);
$overall['contracts'] = ($overall['contracts'] ?? 0) + 1;
} else {
$contract->refresh();
}
$hadAnyEffect = true;
}
}
// Create an Activity record logging this archive if an action or decision is tied to any setting
if ($hadAnyEffect) {
$activitySetting = $settings->first(fn ($s) => ! is_null($s->action_id) || ! is_null($s->decision_id));
if ($activitySetting) {
try {
if ($reactivateRequested) {
$note = 'Ponovna aktivacija pogodba '.$contract->reference;
} else {
$noteKey = 'contracts.archived_activity_note';
$note = __($noteKey, ['reference' => $contract->reference]);
if ($note === $noteKey) {
$note = \Illuminate\Support\Facades\Lang::get($noteKey, ['reference' => $contract->reference], 'sl');
}
}
$activityData = [
'client_case_id' => $clientCase->id,
'action_id' => $activitySetting->action_id,
'decision_id' => $activitySetting->decision_id,
'note' => $note,
'active' => 1,
'user_id' => optional($request->user())->id,
];
if ($reactivateRequested) {
// Attach the contract_id when reactivated as per requirement
$activityData['contract_id'] = $contract->id;
}
\App\Models\Activity::create($activityData);
} catch (\Throwable $e) {
logger()->warning('Failed to create archive/reactivate activity', [
'error' => $e->getMessage(),
'contract_id' => $contract->id,
'setting_id' => optional($activitySetting)->id,
'reactivate' => $reactivateRequested,
]);
}
}
}
// If any archive setting specifies a segment_id, move the contract to that segment (archive bucket)
$segmentSetting = $settings->first(fn ($s) => ! is_null($s->segment_id)); // for reactivation this is the single reactivation setting if segment specified
if ($segmentSetting && $segmentSetting->segment_id) {
try {
$segmentId = $segmentSetting->segment_id;
\DB::transaction(function () use ($contract, $segmentId, $clientCase) {
// Ensure the segment is attached to the client case (activate if previously inactive)
$casePivot = \DB::table('client_case_segment')
->where('client_case_id', $clientCase->id)
->where('segment_id', $segmentId)
->first();
if (! $casePivot) {
\DB::table('client_case_segment')->insert([
'client_case_id' => $clientCase->id,
'segment_id' => $segmentId,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
} elseif (! $casePivot->active) {
\DB::table('client_case_segment')
->where('id', $casePivot->id)
->update(['active' => true, 'updated_at' => now()]);
}
// Deactivate all current active contract segments
\DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('active', true)
->update(['active' => false, 'updated_at' => now()]);
// Attach or activate the archive segment for this contract
$existing = \DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('segment_id', $segmentId)
->first();
if ($existing) {
\DB::table('contract_segment')
->where('id', $existing->id)
->update(['active' => 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(),
]);
}
});
} catch (\Throwable $e) {
logger()->warning('Failed to move contract to archive segment', [
'error' => $e->getMessage(),
'contract_id' => $contract->id,
'segment_id' => $segmentSetting->segment_id,
'setting_id' => $segmentSetting->id,
]);
}
}
$message = $reactivateRequested ? __('contracts.reactivated') : __('contracts.archived');
return back()->with('success', $message);
}
/**
* Emergency: recreate a missing / soft-deleted person for a client case and re-link related data.
*/
public function emergencyCreatePerson(ClientCase $clientCase, Request $request)
{
$oldPersonId = $clientCase->person_id;
/** @var \App\Models\Person\Person|null $existing */
$existing = \App\Models\Person\Person::withTrashed()->find($oldPersonId);
if ($existing && ! $existing->trashed()) {
return back()->with('flash', [
'type' => 'info',
'message' => 'Person already exists emergency creation not needed.',
]);
}
$data = $request->validate([
'full_name' => ['nullable', 'string', 'max:255'],
'first_name' => ['nullable', 'string', 'max:255'],
'last_name' => ['nullable', 'string', 'max:255'],
'tax_number' => ['nullable', 'string', 'max:99'],
'social_security_number' => ['nullable', 'string', 'max:99'],
'description' => ['nullable', 'string', 'max:500'],
]);
$fullName = $data['full_name'] ?? trim(($data['first_name'] ?? '').' '.($data['last_name'] ?? ''));
if ($fullName === '') {
$fullName = 'Unknown Person';
}
$newPerson = null;
\DB::transaction(function () use ($oldPersonId, $clientCase, $fullName, $data, &$newPerson) {
$newPerson = \App\Models\Person\Person::create([
'nu' => null,
'first_name' => $data['first_name'] ?? null,
'last_name' => $data['last_name'] ?? null,
'full_name' => $fullName,
'gender' => null,
'birthday' => null,
'tax_number' => $data['tax_number'] ?? null,
'social_security_number' => $data['social_security_number'] ?? null,
'description' => $data['description'] ?? 'Emergency recreated person (case)',
'group_id' => 2,
'type_id' => 1,
]);
// Re-point related data referencing old person
$tables = [
'emails', 'person_phones', 'person_addresses', 'bank_accounts',
];
foreach ($tables as $table) {
\DB::table($table)->where('person_id', $oldPersonId)->update(['person_id' => $newPerson->id]);
}
// Update the client case
$clientCase->person_id = $newPerson->id;
$clientCase->save();
});
return back()->with('flash', [
'type' => 'success',
'message' => 'New person created and case re-linked.',
'person_uuid' => $newPerson?->uuid,
]);
}
/**
* Send an SMS to a specific phone that belongs to the client case person.
*/
public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $phone_id)
{
$validated = $request->validate([
'message' => ['required', 'string', 'max:1000'],
'delivery_report' => ['sometimes', 'boolean'],
'template_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_templates,id'],
'profile_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_profiles,id'],
'sender_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_senders,id'],
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
]);
// Ensure the phone belongs to the person of this case
/** @var \App\Models\Person\PersonPhone|null $phone */
$phone = \App\Models\Person\PersonPhone::query()
->where('id', $phone_id)
->where('person_id', $clientCase->person_id)
->first();
if (! $phone) {
abort(404);
}
// Resolve explicit profile/sender if provided; otherwise fallback to first active profile and its default sender
/** @var \App\Models\SmsProfile|null $profile */
$profile = null;
/** @var \App\Models\SmsSender|null $sender */
$sender = null;
if (! empty($validated['sender_id']) && empty($validated['profile_id'])) {
// Infer profile from sender if not explicitly provided
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
if ($sender) {
$profile = \App\Models\SmsProfile::query()->find($sender->profile_id);
}
}
if (! empty($validated['profile_id'])) {
$profile = \App\Models\SmsProfile::query()->where('id', $validated['profile_id'])->first();
if (! $profile) {
return back()->with('error', 'Izbran SMS profil ne obstaja.');
}
if (property_exists($profile, 'active') && ! $profile->active) {
return back()->with('error', 'Izbran SMS profil ni aktiven.');
}
}
if (! empty($validated['sender_id'])) {
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
if (! $sender) {
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
}
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
}
}
if (! $profile) {
$profile = \App\Models\SmsProfile::query()
->where('active', true)
->orderByRaw('CASE WHEN default_sender_id IS NULL THEN 1 ELSE 0 END')
->orderBy('id')
->first();
}
if (! $profile) {
return back()->with('warning', 'Ni aktivnega SMS profila.');
}
if (! $sender && ! empty($profile->default_sender_id)) {
$sender = \App\Models\SmsSender::query()->find($profile->default_sender_id);
}
try {
/** @var \App\Services\Sms\SmsService $sms */
$sms = app(\App\Services\Sms\SmsService::class);
// Check available credits before enqueueing (fail-closed)
try {
$raw = (string) $sms->getCreditBalance($profile);
$num = null;
if ($raw !== '') {
$normalized = str_replace(',', '.', trim($raw));
if (preg_match('/-?\d+(?:\.\d+)?/', $normalized, $m)) {
$num = (float) ($m[0] ?? null);
}
}
if (! is_null($num) && $num <= 0.0) {
return back()->with('error', 'No credits left.');
}
} catch (\Throwable $e) {
\Log::warning('SMS credit balance check failed', [
'error' => $e->getMessage(),
'profile_id' => $profile->id,
]);
return back()->with('error', 'Unable to verify SMS credits.');
}
// Create an activity before sending
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
$activityData = [
'note' => $activityNote,
'user_id' => optional($request->user())->id,
];
// If template provided, attach its action/decision to the activity
if (! empty($validated['template_id'])) {
$tpl = \App\Models\SmsTemplate::find((int) $validated['template_id']);
if ($tpl) {
$activityData['action_id'] = $tpl->action_id;
$activityData['decision_id'] = $tpl->decision_id;
}
}
// Attach contract_id if contract_uuid is provided and belongs to this case
if (! empty($validated['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
if ($contract) {
$activityData['contract_id'] = $contract->id;
}
}
$activity = $clientCase->activities()->create($activityData);
// Queue the SMS send; pass activity_id so the job can update note on failure and skip creating a new activity
\App\Jobs\SendSmsJob::dispatch(
profileId: $profile->id,
to: (string) $phone->nu,
content: (string) $validated['message'],
senderId: $sender?->id,
countryCode: $phone->country_code ?: null,
deliveryReport: (bool) ($validated['delivery_report'] ?? false),
clientReference: null,
templateId: $validated['template_id'] ?? null,
clientCaseId: $clientCase->id,
userId: optional($request->user())->id,
activityId: $activity?->id,
);
return back()->with('success', 'SMS je bil dodan v čakalno vrsto.');
} catch (\Throwable $e) {
\Log::warning('SMS enqueue failed', [
'error' => $e->getMessage(),
'case_id' => $clientCase->id,
'phone_id' => $phone_id,
]);
return back()->with('error', 'SMS ni bil dodan v čakalno vrsto.');
}
}
/**
* Return contracts for the given client case (for SMS dialog dropdown).
*/
public function listContracts(ClientCase $clientCase)
{
$contracts = $clientCase->contracts()
->with('account.type')
->select('id', 'uuid', 'reference', 'active', 'start_date', 'end_date')
->latest('id')
->get()
->map(function ($c) {
/** @var SmsService $sms */
$sms = app(SmsService::class);
$acc = $c->account;
$initialRaw = $acc?->initial_amount !== null ? (string) $acc->initial_amount : null;
$balanceRaw = $acc?->balance_amount !== null ? (string) $acc->balance_amount : null;
return [
'uuid' => $c->uuid,
'reference' => $c->reference,
'active' => (bool) $c->active,
'start_date' => (string) ($c->start_date ?? ''),
'end_date' => (string) ($c->end_date ?? ''),
'account' => $acc ? [
'reference' => $acc->reference,
'type' => $acc->type?->name,
'initial_amount' => $initialRaw !== null ? $sms->formatAmountEu($initialRaw) : null,
'balance_amount' => $balanceRaw !== null ? $sms->formatAmountEu($balanceRaw) : null,
'initial_amount_raw' => $initialRaw,
'balance_amount_raw' => $balanceRaw,
] : null,
];
});
return response()->json(['data' => $contracts]);
}
/**
* Render an SMS template preview with optional contract/account placeholders filled.
*/
public function previewSms(ClientCase $clientCase, Request $request, SmsService $sms)
{
$validated = $request->validate([
'template_id' => ['required', 'integer', 'exists:sms_templates,id'],
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
]);
/** @var \App\Models\SmsTemplate $template */
$template = \App\Models\SmsTemplate::findOrFail((int) $validated['template_id']);
$vars = [];
$contractUuid = $validated['contract_uuid'] ?? null;
if ($contractUuid) {
// Ensure the contract belongs to this client case
$contract = $clientCase->contracts()->where('uuid', $contractUuid)
->with('account.type')
->first();
if ($contract) {
$vars['contract'] = [
'id' => $contract->id,
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'start_date' => (string) ($contract->start_date ?? ''),
'end_date' => (string) ($contract->end_date ?? ''),
];
if ($contract->account) {
$initialRaw = (string) $contract->account->initial_amount;
$balanceRaw = (string) $contract->account->balance_amount;
$vars['account'] = [
'id' => $contract->account->id,
'reference' => $contract->account->reference,
'initial_amount' => $sms->formatAmountEu($initialRaw),
'balance_amount' => $sms->formatAmountEu($balanceRaw),
'initial_amount_raw' => $initialRaw,
'balance_amount_raw' => $balanceRaw,
'type' => $contract->account->type?->name,
];
}
}
}
$content = $sms->renderContent($template->content, $vars);
return response()->json([
'content' => $content,
'variables' => $vars,
]);
}
}