changes 0328092025
This commit is contained in:
parent
b40ee9dcde
commit
7e8e0a479b
14
.env.testing
Normal file
14
.env.testing
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
APP_ENV=testing
|
||||
APP_DEBUG=true
|
||||
APP_KEY=base64:rcfpwrgQQdcse2wrc/Sc1LR8hYxkIXBOg2BX957WcnI=
|
||||
APP_URL=http://localhost
|
||||
CACHE_STORE=array
|
||||
MAIL_MAILER=array
|
||||
PULSE_ENABLED=false
|
||||
QUEUE_CONNECTION=sync
|
||||
SESSION_DRIVER=array
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
# Use an isolated SQLite database for tests to avoid wiping your dev DB
|
||||
DB_CONNECTION=sqlite
|
||||
DB_DATABASE=database/database.sqlite
|
||||
19
.env.testing.example
Normal file
19
.env.testing.example
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Copy this file to .env.testing and update values for your local Postgres test database.
|
||||
# Running `php artisan test` will then use this separate database instead of your dev DB.
|
||||
|
||||
APP_ENV=testing
|
||||
APP_DEBUG=true
|
||||
CACHE_STORE=array
|
||||
MAIL_MAILER=array
|
||||
PULSE_ENABLED=false
|
||||
QUEUE_CONNECTION=sync
|
||||
SESSION_DRIVER=array
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
# Use a dedicated test database to avoid clearing your dev DB during tests
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=teren_app_test
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=secret
|
||||
73
app/Console/Commands/DebugDocumentView.php
Normal file
73
app/Console/Commands/DebugDocumentView.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Document;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DebugDocumentView extends Command
|
||||
{
|
||||
protected $signature = 'debug:document {doc_uuid} {case_uuid}';
|
||||
|
||||
protected $description = 'Diagnose why document view returns 404 (binding, ownership, file presence)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$docUuid = (string) $this->argument('doc_uuid');
|
||||
$caseUuid = (string) $this->argument('case_uuid');
|
||||
|
||||
$case = ClientCase::where('uuid', $caseUuid)->first();
|
||||
if (! $case) {
|
||||
$this->error('ClientCase not found by uuid: '.$caseUuid);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->info('ClientCase found: id='.$case->id.' uuid='.$case->uuid);
|
||||
|
||||
$doc = Document::withTrashed()->where('uuid', $docUuid)->first();
|
||||
if (! $doc) {
|
||||
$this->error('Document not found by uuid (including trashed): '.$docUuid);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->info('Document found: id='.$doc->id.' uuid='.$doc->uuid.' trashed='.(int) ($doc->deleted_at !== null));
|
||||
$this->line(' documentable_type='.$doc->documentable_type.' documentable_id='.$doc->documentable_id);
|
||||
$this->line(' disk='.$doc->disk.' path='.$doc->path);
|
||||
$this->line(' preview_path='.(string) $doc->preview_path.' preview_mime='.(string) $doc->preview_mime);
|
||||
|
||||
// Ownership check like in controller
|
||||
$belongsToCase = $doc->documentable_type === ClientCase::class && $doc->documentable_id === $case->id;
|
||||
$belongsToContractOfCase = false;
|
||||
if ($doc->documentable_type === Contract::class) {
|
||||
$belongsToContractOfCase = Contract::withTrashed()
|
||||
->where('id', $doc->documentable_id)
|
||||
->where('client_case_id', $case->id)
|
||||
->exists();
|
||||
}
|
||||
$this->line('Ownership: belongsToCase='.(int) $belongsToCase.' belongsToContractOfCase='.(int) $belongsToContractOfCase);
|
||||
|
||||
// File existence checks
|
||||
$disk = $doc->disk ?: 'public';
|
||||
$relPath = ltrim($doc->path ?? '', '/\\');
|
||||
if (str_starts_with($relPath, 'public/')) {
|
||||
$relPath = substr($relPath, 7);
|
||||
}
|
||||
$existsOnDisk = Storage::disk($disk)->exists($relPath);
|
||||
$this->line('Source exists on disk='.$existsOnDisk.' (disk='.$disk.' relPath='.$relPath.')');
|
||||
if (! $existsOnDisk) {
|
||||
$publicFull = public_path($relPath);
|
||||
$this->line('Public candidate='.$publicFull.' exists='.(int) is_file($publicFull));
|
||||
}
|
||||
|
||||
$previewDisk = config('files.preview_disk', 'public');
|
||||
$previewExists = $doc->preview_path ? Storage::disk($previewDisk)->exists($doc->preview_path) : false;
|
||||
$this->line('Preview exists on previewDisk='.$previewExists.' (disk='.$previewDisk.' path='.(string) $doc->preview_path.')');
|
||||
|
||||
$this->info('Done. Compare with controller logic to pin the 404 branch.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
98
app/Console/Commands/GenerateMissingPreviews.php
Normal file
98
app/Console/Commands/GenerateMissingPreviews.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\GenerateDocumentPreview;
|
||||
use App\Models\Document;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class GenerateMissingPreviews extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'documents:generate-previews {--now : Run the preview job synchronously instead of queueing it} {--limit=100 : Max documents to process}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Queue or run preview generation for DOC/DOCX documents that are missing a generated preview.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = (int) $this->option('limit');
|
||||
$now = (bool) $this->option('now');
|
||||
|
||||
$docs = Document::query()
|
||||
->whereNull('deleted_at')
|
||||
->where(function ($q) {
|
||||
$q->whereRaw("lower(extension) in ('doc','docx')");
|
||||
})
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit * 5)
|
||||
->get();
|
||||
|
||||
if ($docs->isEmpty()) {
|
||||
$this->info('No documents requiring preview generation.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Scanning '.$docs->count().' candidate document(s) for (re)generation...');
|
||||
$dispatched = 0;
|
||||
foreach ($docs as $doc) {
|
||||
// Verify source file exists on disk or under public before dispatching
|
||||
$disk = $doc->disk ?: 'public';
|
||||
$relPath = ltrim($doc->path ?? '', '/\\');
|
||||
if (str_starts_with($relPath, 'public/')) {
|
||||
$relPath = substr($relPath, 7);
|
||||
}
|
||||
$has = Storage::disk($disk)->exists($relPath);
|
||||
if (! $has) {
|
||||
$publicFull = public_path($relPath);
|
||||
$real = @realpath($publicFull);
|
||||
$publicRoot = @realpath(public_path());
|
||||
$realN = $real ? str_replace('\\\\', '/', $real) : null;
|
||||
$rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null;
|
||||
$has = $realN && $rootN && str_starts_with($realN, $rootN) && is_file($real);
|
||||
}
|
||||
if (! $has) {
|
||||
$this->warn('Skipping doc '.$doc->id.' (source file missing): '.$doc->path);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine if (re)generation is required
|
||||
$needs = false;
|
||||
$previewDisk = config('files.preview_disk', 'public');
|
||||
if (empty($doc->preview_path)) {
|
||||
$needs = true;
|
||||
} else {
|
||||
$existsPreview = Storage::disk($previewDisk)->exists($doc->preview_path);
|
||||
if (! $existsPreview) {
|
||||
$needs = true;
|
||||
} elseif ($doc->preview_generated_at && $doc->updated_at && $doc->updated_at->gt($doc->preview_generated_at)) {
|
||||
$needs = true;
|
||||
}
|
||||
}
|
||||
if (! $needs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($now) {
|
||||
GenerateDocumentPreview::dispatchSync($doc->id);
|
||||
} else {
|
||||
GenerateDocumentPreview::dispatch($doc->id);
|
||||
}
|
||||
$dispatched++;
|
||||
}
|
||||
|
||||
$this->info(($now ? 'Ran' : 'Queued').' preview generation for '.$dispatched.' document(s).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,16 +2,15 @@
|
|||
|
||||
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 Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Exception;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Requests\StoreContractRequest;
|
||||
use App\Http\Requests\UpdateContractRequest;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ClientCaseContoller extends Controller
|
||||
|
|
@ -23,17 +22,16 @@ public function index(ClientCase $clientCase, Request $request)
|
|||
{
|
||||
return Inertia::render('Cases/Index', [
|
||||
'client_cases' => $clientCase::with(['person'])
|
||||
->when($request->input('search'), fn($que, $search) =>
|
||||
$que->whereHas(
|
||||
'person',
|
||||
fn($q) => $q->where('full_name', 'ilike', '%' . $search . '%')
|
||||
)
|
||||
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
|
||||
'person',
|
||||
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
|
||||
)
|
||||
)
|
||||
->where('active', 1)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(15, ['*'], 'client-cases-page')
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['search'])
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -55,13 +53,13 @@ public function store(Request $request)
|
|||
|
||||
$client = \App\Models\Client::where('uuid', $cuuid)->firstOrFail();
|
||||
|
||||
if( isset($client->id) ){
|
||||
if (isset($client->id)) {
|
||||
|
||||
\DB::transaction(function() use ($request, $client){
|
||||
\DB::transaction(function () use ($request, $client) {
|
||||
$pq = $request->input('person');
|
||||
|
||||
$person = $client->person()->create([
|
||||
'nu' => rand(100000,200000),
|
||||
'nu' => rand(100000, 200000),
|
||||
'first_name' => $pq['first_name'],
|
||||
'last_name' => $pq['last_name'],
|
||||
'full_name' => $pq['full_name'],
|
||||
|
|
@ -71,23 +69,23 @@ public function store(Request $request)
|
|||
'social_security_number' => $pq['social_security_number'],
|
||||
'description' => $pq['description'],
|
||||
'group_id' => 2,
|
||||
'type_id' => 1
|
||||
'type_id' => 1,
|
||||
]);
|
||||
|
||||
$person->addresses()->create([
|
||||
'address' => $pq['address']['address'],
|
||||
'country' => $pq['address']['country'],
|
||||
'type_id' => $pq['address']['type_id']
|
||||
'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']
|
||||
'type_id' => $pq['phone']['type_id'],
|
||||
]);
|
||||
|
||||
$person->clientCase()->create([
|
||||
'client_id' => $client->id
|
||||
'client_id' => $client->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
@ -97,8 +95,8 @@ public function store(Request $request)
|
|||
|
||||
public function storeContract(ClientCase $clientCase, StoreContractRequest $request)
|
||||
{
|
||||
|
||||
\DB::transaction(function() use ($request, $clientCase){
|
||||
|
||||
\DB::transaction(function () use ($request, $clientCase) {
|
||||
|
||||
// Create contract
|
||||
$contract = $clientCase->contracts()->create([
|
||||
|
|
@ -110,11 +108,13 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ
|
|||
|
||||
// Note: Contract config auto-application is handled in Contract model created hook.
|
||||
|
||||
// Optionally create/update related account amounts
|
||||
// Optionally create related account with amounts and/or type
|
||||
$initial = $request->input('initial_amount');
|
||||
$balance = $request->input('balance_amount');
|
||||
if (!is_null($initial) || !is_null($balance)) {
|
||||
$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,
|
||||
]);
|
||||
|
|
@ -125,11 +125,11 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ
|
|||
return to_route('clientCase.show', $clientCase);
|
||||
}
|
||||
|
||||
public function updateContract(ClientCase $clientCase, String $uuid, UpdateContractRequest $request)
|
||||
public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request)
|
||||
{
|
||||
$contract = Contract::where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
\DB::transaction(function() use ($request, $contract){
|
||||
\DB::transaction(function () use ($request, $contract) {
|
||||
$contract->update([
|
||||
'reference' => $request->input('reference'),
|
||||
'type_id' => $request->input('type_id'),
|
||||
|
|
@ -139,14 +139,24 @@ public function updateContract(ClientCase $clientCase, String $uuid, UpdateContr
|
|||
|
||||
$initial = $request->input('initial_amount');
|
||||
$balance = $request->input('balance_amount');
|
||||
if (!is_null($initial) || !is_null($balance)) {
|
||||
$accountData = [
|
||||
'initial_amount' => $initial ?? 0,
|
||||
'balance_amount' => $balance ?? 0,
|
||||
];
|
||||
$shouldUpsertAccount = (! is_null($initial)) || (! is_null($balance)) || $request->has('account_type_id');
|
||||
if ($shouldUpsertAccount) {
|
||||
$accountData = [];
|
||||
if (! is_null($initial)) {
|
||||
$accountData['initial_amount'] = $initial;
|
||||
}
|
||||
if (! is_null($balance)) {
|
||||
$accountData['balance_amount'] = $balance;
|
||||
}
|
||||
if ($request->has('account_type_id')) {
|
||||
$accountData['type_id'] = $request->input('account_type_id');
|
||||
}
|
||||
|
||||
if ($contract->account) {
|
||||
$contract->account->update($accountData);
|
||||
} else {
|
||||
// For create, ensure defaults exist if not provided
|
||||
$accountData = array_merge(['initial_amount' => 0, 'balance_amount' => 0], $accountData);
|
||||
$contract->account()->create($accountData);
|
||||
}
|
||||
}
|
||||
|
|
@ -156,7 +166,8 @@ public function updateContract(ClientCase $clientCase, String $uuid, UpdateContr
|
|||
return to_route('clientCase.show', $clientCase);
|
||||
}
|
||||
|
||||
public function storeActivity(ClientCase $clientCase, Request $request) {
|
||||
public function storeActivity(ClientCase $clientCase, Request $request)
|
||||
{
|
||||
try {
|
||||
$attributes = $request->validate([
|
||||
'due_date' => 'nullable|date',
|
||||
|
|
@ -166,16 +177,16 @@ public function storeActivity(ClientCase $clientCase, Request $request) {
|
|||
'decision_id' => 'exists:\App\Models\Decision,id',
|
||||
'contract_uuid' => 'nullable|uuid',
|
||||
]);
|
||||
|
||||
|
||||
// Map contract_uuid to contract_id within the same client case, if provided
|
||||
$contractId = null;
|
||||
if (!empty($attributes['contract_uuid'])) {
|
||||
if (! empty($attributes['contract_uuid'])) {
|
||||
$contract = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail('id');
|
||||
if ($contract) {
|
||||
$contractId = $contract->id;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create activity
|
||||
$row = $clientCase->activities()->create([
|
||||
'due_date' => $attributes['due_date'] ?? null,
|
||||
|
|
@ -189,24 +200,30 @@ public function storeActivity(ClientCase $clientCase, Request $request) {
|
|||
$class = '\\App\\Events\\' . $e->name;
|
||||
event(new $class($clientCase));
|
||||
}*/
|
||||
|
||||
|
||||
logger()->info('Activity successfully inserted', $attributes);
|
||||
return to_route('clientCase.show', $clientCase)->with('success', 'Successful created!');
|
||||
|
||||
// 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());
|
||||
|
||||
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 deleteContract(ClientCase $clientCase, String $uuid, Request $request) {
|
||||
public function deleteContract(ClientCase $clientCase, string $uuid, Request $request)
|
||||
{
|
||||
$contract = Contract::where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
\DB::transaction(function() use ($request, $contract){
|
||||
\DB::transaction(function () use ($contract) {
|
||||
$contract->delete();
|
||||
});
|
||||
|
||||
|
|
@ -254,22 +271,22 @@ public function attachSegment(ClientCase $clientCase, Request $request)
|
|||
'make_active_for_contract' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
\DB::transaction(function () use ($clientCase, $validated) {
|
||||
\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) {
|
||||
if (! $attached) {
|
||||
$clientCase->segments()->attach($validated['segment_id'], ['active' => true]);
|
||||
} else if (!$attached->active) {
|
||||
} 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)) {
|
||||
if (! empty($validated['contract_uuid']) && ($validated['make_active_for_contract'] ?? false)) {
|
||||
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->firstOrFail();
|
||||
\DB::table('contract_segment')
|
||||
->where('contract_id', $contract->id)
|
||||
|
|
@ -300,11 +317,18 @@ public function storeDocument(ClientCase $clientCase, Request $request)
|
|||
'name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_public' => 'sometimes|boolean',
|
||||
'contract_uuid' => 'nullable|uuid',
|
||||
]);
|
||||
|
||||
$file = $validated['file'];
|
||||
$disk = 'public';
|
||||
$directory = 'cases/' . $clientCase->uuid . '/documents';
|
||||
$contract = null;
|
||||
if (! empty($validated['contract_uuid'])) {
|
||||
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
|
||||
}
|
||||
$directory = $contract
|
||||
? ('contracts/'.$contract->uuid.'/documents')
|
||||
: ('cases/'.$clientCase->uuid.'/documents');
|
||||
$path = $file->store($directory, $disk);
|
||||
|
||||
$doc = new Document([
|
||||
|
|
@ -319,14 +343,18 @@ public function storeDocument(ClientCase $clientCase, Request $request)
|
|||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
'checksum' => null,
|
||||
'is_public' => (bool)($validated['is_public'] ?? false),
|
||||
'is_public' => (bool) ($validated['is_public'] ?? false),
|
||||
]);
|
||||
|
||||
$clientCase->documents()->save($doc);
|
||||
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'])) {
|
||||
if (in_array($ext, ['doc', 'docx'])) {
|
||||
\App\Jobs\GenerateDocumentPreview::dispatch($doc->id);
|
||||
}
|
||||
|
||||
|
|
@ -335,22 +363,52 @@ public function storeDocument(ClientCase $clientCase, Request $request)
|
|||
|
||||
public function viewDocument(ClientCase $clientCase, Document $document, Request $request)
|
||||
{
|
||||
// Ensure the document belongs to this client case
|
||||
if ($document->documentable_type !== ClientCase::class || $document->documentable_id !== $clientCase->id) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Optional: add authz checks here (e.g., policies)
|
||||
$disk = $document->disk ?: 'public';
|
||||
// Normalize relative path (handle legacy 'public/' or 'public\\' prefixes and backslashes on Windows)
|
||||
$relPath = $document->path ?? '';
|
||||
$relPath = str_replace('\\', '/', $relPath); // unify slashes
|
||||
$relPath = ltrim($relPath, '/');
|
||||
if (str_starts_with($relPath, 'public/')) {
|
||||
$relPath = substr($relPath, 7);
|
||||
}
|
||||
|
||||
// If a preview exists (e.g., PDF generated for doc/docx), stream that
|
||||
$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) abort(404);
|
||||
return response()->stream(function () use ($stream) { fpassthru($stream); }, 200, [
|
||||
if ($stream === false) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return response()->stream(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
}, 200, [
|
||||
'Content-Type' => $document->preview_mime ?: 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="' . addslashes(($document->original_name ?: $document->file_name) . '.pdf') . '"',
|
||||
'Content-Disposition' => 'inline; filename="'.addslashes(($document->original_name ?: $document->file_name).'.pdf').'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
|
|
@ -358,17 +416,147 @@ public function viewDocument(ClientCase $clientCase, Document $document, Request
|
|||
|
||||
// If it's a DOC/DOCX and no preview yet, queue generation and show 202 Accepted
|
||||
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
|
||||
if (in_array($ext, ['doc','docx'])) {
|
||||
if (in_array($ext, ['doc', 'docx'])) {
|
||||
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
|
||||
|
||||
return response('Preview is being generated. Please try again shortly.', 202);
|
||||
}
|
||||
|
||||
if (!Storage::disk($disk)->exists($document->path)) {
|
||||
// Try multiple path candidates to account for legacy prefixes
|
||||
$candidates = [];
|
||||
$candidates[] = $relPath;
|
||||
// also try raw original (normalized slashes, trimmed)
|
||||
$raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null;
|
||||
if ($raw && $raw !== $relPath) {
|
||||
$candidates[] = $raw;
|
||||
}
|
||||
// if path accidentally contains 'storage/' prefix (public symlink), strip it
|
||||
if (str_starts_with($relPath, 'storage/')) {
|
||||
$candidates[] = substr($relPath, 8);
|
||||
}
|
||||
if ($raw && str_starts_with($raw, 'storage/')) {
|
||||
$candidates[] = substr($raw, 8);
|
||||
}
|
||||
|
||||
$existsOnDisk = false;
|
||||
foreach ($candidates as $cand) {
|
||||
if (Storage::disk($disk)->exists($cand)) {
|
||||
$existsOnDisk = true;
|
||||
$relPath = $cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $existsOnDisk) {
|
||||
// Fallback: some legacy files may live directly under public/, attempt to stream from there
|
||||
$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)) {
|
||||
logger()->info('Document view fallback: serving from public path', [
|
||||
'document_id' => $document->id,
|
||||
'path' => $realN,
|
||||
]);
|
||||
$fp = @fopen($real, 'rb');
|
||||
if ($fp === false) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return response()->stream(function () use ($fp) {
|
||||
fpassthru($fp);
|
||||
}, 200, [
|
||||
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
|
||||
'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
|
||||
logger()->warning('Document view 404: file missing on disk and public fallback failed', [
|
||||
'document_id' => $document->id,
|
||||
'document_uuid' => $document->uuid,
|
||||
'disk' => $disk,
|
||||
'path' => $document->path,
|
||||
'normalizedCandidates' => $candidates,
|
||||
'public_candidate' => $publicFull,
|
||||
]);
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$stream = Storage::disk($disk)->readStream($document->path);
|
||||
$stream = Storage::disk($disk)->readStream($relPath);
|
||||
if ($stream === false) {
|
||||
logger()->warning('Document view: readStream failed, attempting fallbacks', [
|
||||
'document_id' => $document->id,
|
||||
'disk' => $disk,
|
||||
'relPath' => $relPath,
|
||||
]);
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
|
||||
'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
];
|
||||
|
||||
// Fallback 1: get() the bytes directly
|
||||
try {
|
||||
$bytes = Storage::disk($disk)->get($relPath);
|
||||
} catch (\Throwable $e) {
|
||||
$bytes = null;
|
||||
}
|
||||
if (! is_null($bytes) && $bytes !== false) {
|
||||
return response($bytes, 200, $headers);
|
||||
}
|
||||
|
||||
// Fallback 2: open via absolute path (local driver)
|
||||
$abs = null;
|
||||
try {
|
||||
if (method_exists(Storage::disk($disk), 'path')) {
|
||||
$abs = Storage::disk($disk)->path($relPath);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$abs = null;
|
||||
}
|
||||
if ($abs && is_file($abs)) {
|
||||
$fp = @fopen($abs, 'rb');
|
||||
if ($fp !== false) {
|
||||
logger()->info('Document view fallback: serving from absolute storage path', [
|
||||
'document_id' => $document->id,
|
||||
'abs' => str_replace('\\\\', '/', (string) realpath($abs)),
|
||||
]);
|
||||
|
||||
return response()->stream(function () use ($fp) {
|
||||
fpassthru($fp);
|
||||
}, 200, $headers);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 3: serve from public path if available
|
||||
$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)) {
|
||||
logger()->info('Document view fallback: serving from public path (post-readStream failure)', [
|
||||
'document_id' => $document->id,
|
||||
'path' => $realN,
|
||||
]);
|
||||
$fp = @fopen($real, 'rb');
|
||||
if ($fp !== false) {
|
||||
return response()->stream(function () use ($fp) {
|
||||
fpassthru($fp);
|
||||
}, 200, $headers);
|
||||
}
|
||||
}
|
||||
|
||||
logger()->warning('Document view 404: all fallbacks failed after readStream failure', [
|
||||
'document_id' => $document->id,
|
||||
'disk' => $disk,
|
||||
'relPath' => $relPath,
|
||||
]);
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
|
@ -376,7 +564,7 @@ public function viewDocument(ClientCase $clientCase, Document $document, Request
|
|||
fpassthru($stream);
|
||||
}, 200, [
|
||||
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
|
||||
'Content-Disposition' => 'inline; filename="' . addslashes($document->original_name ?: $document->file_name) . '"',
|
||||
'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
|
|
@ -384,57 +572,418 @@ public function viewDocument(ClientCase $clientCase, Document $document, Request
|
|||
|
||||
public function downloadDocument(ClientCase $clientCase, Document $document, Request $request)
|
||||
{
|
||||
if ($document->documentable_type !== ClientCase::class || $document->documentable_id !== $clientCase->id) {
|
||||
$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);
|
||||
}
|
||||
$disk = $document->disk ?: 'public';
|
||||
if (!Storage::disk($disk)->exists($document->path)) {
|
||||
// Normalize relative path for Windows and legacy prefixes
|
||||
$relPath = $document->path ?? '';
|
||||
$relPath = str_replace('\\', '/', $relPath);
|
||||
$relPath = ltrim($relPath, '/');
|
||||
if (str_starts_with($relPath, 'public/')) {
|
||||
$relPath = substr($relPath, 7);
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
$candidates[] = $relPath;
|
||||
$raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : 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);
|
||||
}
|
||||
|
||||
$existsOnDisk = false;
|
||||
foreach ($candidates as $cand) {
|
||||
if (Storage::disk($disk)->exists($cand)) {
|
||||
$existsOnDisk = true;
|
||||
$relPath = $cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $existsOnDisk) {
|
||||
// Fallback to public/ direct path if present
|
||||
$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)) {
|
||||
logger()->info('Document download fallback: serving from public path', [
|
||||
'document_id' => $document->id,
|
||||
'path' => $realN,
|
||||
]);
|
||||
$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;
|
||||
$fp = @fopen($real, 'rb');
|
||||
if ($fp === false) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return response()->stream(function () use ($fp) {
|
||||
fpassthru($fp);
|
||||
}, 200, [
|
||||
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
|
||||
logger()->warning('Document download 404: file missing on disk and public fallback failed', [
|
||||
'document_id' => $document->id,
|
||||
'document_uuid' => $document->uuid,
|
||||
'disk' => $disk,
|
||||
'path' => $document->path,
|
||||
'normalizedCandidates' => $candidates,
|
||||
'public_candidate' => $publicFull,
|
||||
]);
|
||||
abort(404);
|
||||
}
|
||||
$name = $document->original_name ?: $document->file_name;
|
||||
$stream = Storage::disk($disk)->readStream($document->path);
|
||||
$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;
|
||||
$stream = Storage::disk($disk)->readStream($relPath);
|
||||
if ($stream === false) {
|
||||
logger()->warning('Document download: readStream failed, attempting fallbacks', [
|
||||
'document_id' => $document->id,
|
||||
'disk' => $disk,
|
||||
'relPath' => $relPath,
|
||||
]);
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
];
|
||||
|
||||
// Fallback 1: get() the bytes directly
|
||||
try {
|
||||
$bytes = Storage::disk($disk)->get($relPath);
|
||||
} catch (\Throwable $e) {
|
||||
$bytes = null;
|
||||
}
|
||||
if (! is_null($bytes) && $bytes !== false) {
|
||||
return response($bytes, 200, $headers);
|
||||
}
|
||||
|
||||
// Fallback 2: open via absolute storage path
|
||||
$abs = null;
|
||||
try {
|
||||
if (method_exists(Storage::disk($disk), 'path')) {
|
||||
$abs = Storage::disk($disk)->path($relPath);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$abs = null;
|
||||
}
|
||||
if ($abs && is_file($abs)) {
|
||||
$fp = @fopen($abs, 'rb');
|
||||
if ($fp !== false) {
|
||||
logger()->info('Document download fallback: serving from absolute storage path', [
|
||||
'document_id' => $document->id,
|
||||
'abs' => str_replace('\\\\', '/', (string) realpath($abs)),
|
||||
]);
|
||||
|
||||
return response()->stream(function () use ($fp) {
|
||||
fpassthru($fp);
|
||||
}, 200, $headers);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 3: serve from public path if available
|
||||
$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)) {
|
||||
logger()->info('Document download fallback: serving from public path (post-readStream failure)', [
|
||||
'document_id' => $document->id,
|
||||
'path' => $realN,
|
||||
]);
|
||||
$fp = @fopen($real, 'rb');
|
||||
if ($fp !== false) {
|
||||
return response()->stream(function () use ($fp) {
|
||||
fpassthru($fp);
|
||||
}, 200, $headers);
|
||||
}
|
||||
}
|
||||
|
||||
logger()->warning('Document download 404: all fallbacks failed after readStream failure', [
|
||||
'document_id' => $document->id,
|
||||
'disk' => $disk,
|
||||
'relPath' => $relPath,
|
||||
]);
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return response()->stream(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
}, 200, [
|
||||
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="' . addslashes($name) . '"',
|
||||
'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// Reuse the existing logic by delegating to a small helper
|
||||
return $this->streamDocumentForDisk($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->streamDocumentForDisk($document, inline: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to stream a document either inline or as attachment with all Windows/public fallbacks.
|
||||
*/
|
||||
protected function streamDocumentForDisk(Document $document, bool $inline = true)
|
||||
{
|
||||
$disk = $document->disk ?: 'public';
|
||||
$relPath = $document->path ?? '';
|
||||
$relPath = str_replace('\\', '/', $relPath);
|
||||
$relPath = ltrim($relPath, '/');
|
||||
if (str_starts_with($relPath, 'public/')) {
|
||||
$relPath = substr($relPath, 7);
|
||||
}
|
||||
|
||||
// Previews for DOC/DOCX
|
||||
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
|
||||
$previewDisk = config('files.preview_disk', 'public');
|
||||
if ($inline && in_array($ext, ['doc', 'docx'])) {
|
||||
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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
|
||||
|
||||
return response('Preview is being generated. Please try again shortly.', 202);
|
||||
}
|
||||
|
||||
// Try storage candidates
|
||||
$candidates = [$relPath];
|
||||
$raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : 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);
|
||||
}
|
||||
|
||||
$found = null;
|
||||
foreach ($candidates as $cand) {
|
||||
if (Storage::disk($disk)->exists($cand)) {
|
||||
$found = $cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
|
||||
'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($document->original_name ?: $document->file_name).'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
];
|
||||
|
||||
if (! $found) {
|
||||
// public/ fallback
|
||||
$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)) {
|
||||
$fp = @fopen($real, 'rb');
|
||||
if ($fp !== false) {
|
||||
return response()->stream(function () use ($fp) {
|
||||
fpassthru($fp);
|
||||
}, 200, $headers);
|
||||
}
|
||||
}
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$stream = Storage::disk($disk)->readStream($found);
|
||||
if ($stream !== false) {
|
||||
return response()->stream(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
}, 200, $headers);
|
||||
}
|
||||
|
||||
// Fallbacks on readStream failure
|
||||
try {
|
||||
$bytes = Storage::disk($disk)->get($found);
|
||||
if (! is_null($bytes) && $bytes !== false) {
|
||||
return response($bytes, 200, $headers);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
// public/ again as last try
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(ClientCase $clientCase)
|
||||
{
|
||||
$case = $clientCase::with([
|
||||
'person' => fn($que) => $que->with(['addresses', 'phones'])
|
||||
])->where('active', 1)->findOrFail($clientCase->id);
|
||||
'person' => fn ($que) => $que->with(['addresses', 'phones', 'emails', 'bankAccounts']),
|
||||
])->where('active', 1)->findOrFail($clientCase->id);
|
||||
|
||||
$types = [
|
||||
'address_types' => \App\Models\Person\AddressType::all(),
|
||||
'phone_types' => \App\Models\Person\PhoneType::all()
|
||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
];
|
||||
|
||||
return Inertia::render('Cases/Show', [
|
||||
'client' => $case->client()->with('person', fn($q) => $q->with(['addresses', 'phones']))->firstOrFail(),
|
||||
// Prepare contracts and a reference map
|
||||
$contracts = $case->contracts()
|
||||
->with(['type', 'account', 'objects', 'segments:id,name'])
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
$contractRefMap = [];
|
||||
foreach ($contracts as $c) {
|
||||
$contractRefMap[$c->id] = $c->reference;
|
||||
}
|
||||
|
||||
// Merge client case and contract documents into a single array and include contract reference when applicable
|
||||
$contractIds = $contracts->pluck('id');
|
||||
$contractDocs = Document::query()
|
||||
->where('documentable_type', Contract::class)
|
||||
->whereIn('documentable_id', $contractIds)
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(function ($d) use ($contractRefMap) {
|
||||
$arr = $d->toArray();
|
||||
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
|
||||
$arr['documentable_type'] = Contract::class;
|
||||
$arr['contract_uuid'] = optional(Contract::withTrashed()->find($d->documentable_id))->uuid;
|
||||
|
||||
return $arr;
|
||||
});
|
||||
|
||||
$caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) {
|
||||
$arr = $d->toArray();
|
||||
$arr['documentable_type'] = ClientCase::class;
|
||||
$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', 'bankAccounts']))->firstOrFail(),
|
||||
'client_case' => $case,
|
||||
'contracts' => $case->contracts()
|
||||
->with(['type', 'account', 'objects', 'segments:id,name'])
|
||||
->orderByDesc('created_at')->get(),
|
||||
'activities' => $case->activities()->with(['action', 'decision', 'contract:id,uuid,reference'])
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20, ['*'], 'activities'),
|
||||
'documents' => $case->documents()->orderByDesc('created_at')->get(),
|
||||
'contracts' => $contracts,
|
||||
'activities' => tap(
|
||||
$case->activities()
|
||||
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20, ['*'], 'activities'),
|
||||
function ($p) {
|
||||
$p->getCollection()->transform(function ($a) {
|
||||
$a->setAttribute('user_name', optional($a->user)->name);
|
||||
return $a;
|
||||
});
|
||||
}
|
||||
),
|
||||
'documents' => $mergedDocs,
|
||||
'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(),
|
||||
'account_types' => \App\Models\AccountType::all(),
|
||||
'actions' => \App\Models\Action::with('decisions')->get(),
|
||||
'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'])
|
||||
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
|
||||
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,62 +9,62 @@
|
|||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
public function index(Client $client, Request $request){
|
||||
return Inertia::render('Client/Index',[
|
||||
public function index(Client $client, Request $request)
|
||||
{
|
||||
return Inertia::render('Client/Index', [
|
||||
'clients' => $client::query()
|
||||
->with('person')
|
||||
->when($request->input('search'), fn($que, $search) =>
|
||||
$que->whereHas(
|
||||
'person',
|
||||
fn($q) => $q->where('full_name', 'ilike', '%' . $search . '%')
|
||||
)
|
||||
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
|
||||
'person',
|
||||
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
|
||||
)
|
||||
)
|
||||
->where('active', 1)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(15)
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['search'])
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Client $client, Request $request) {
|
||||
public function show(Client $client, Request $request)
|
||||
{
|
||||
|
||||
$data = $client::query()
|
||||
->with(['person' => fn($que) => $que->with(['addresses','phones'])])
|
||||
->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts'])])
|
||||
->findOrFail($client->id);
|
||||
|
||||
$types = [
|
||||
'address_types' => \App\Models\Person\AddressType::all(),
|
||||
'phone_types' => \App\Models\Person\PhoneType::all()
|
||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
];
|
||||
|
||||
return Inertia::render('Client/Show', [
|
||||
'client' => $data,
|
||||
'client_cases' => $data->clientCases()
|
||||
->with('person')
|
||||
->when($request->input('search'), fn($que, $search) =>
|
||||
$que->whereHas(
|
||||
'person',
|
||||
fn($q) => $q->where('full_name', 'ilike', '%' . $search . '%')
|
||||
)
|
||||
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
|
||||
'person',
|
||||
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
|
||||
)
|
||||
)
|
||||
->where('active', 1)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(15)
|
||||
->withQueryString(),
|
||||
'types' => $types,
|
||||
'filters' => $request->only(['search'])
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
|
||||
DB::transaction(function() use ($request){
|
||||
DB::transaction(function () use ($request) {
|
||||
$address = $request->input('address');
|
||||
$phone = $request->input('phone');
|
||||
$person = \App\Models\Person\Person::create([
|
||||
'nu' => rand(100000,200000),
|
||||
'nu' => rand(100000, 200000),
|
||||
'first_name' => $request->input('first_name'),
|
||||
'last_name' => $request->input('last_name'),
|
||||
'full_name' => $request->input('full_name'),
|
||||
|
|
@ -74,31 +74,32 @@ public function store(Request $request)
|
|||
'social_security_number' => $request->input('social_security_number'),
|
||||
'description' => $request->input('description'),
|
||||
'group_id' => 1,
|
||||
'type_id' => 2
|
||||
'type_id' => 2,
|
||||
]);
|
||||
|
||||
$person->addresses()->create([
|
||||
'address' => $address['address'],
|
||||
'country' => $address['country'],
|
||||
'type_id' => $address['type_id']
|
||||
'type_id' => $address['type_id'],
|
||||
]);
|
||||
|
||||
$person->phones()->create([
|
||||
'nu' => $phone['nu'],
|
||||
'country_code' => $phone['country_code'],
|
||||
'type_id' => $phone['type_id']
|
||||
'type_id' => $phone['type_id'],
|
||||
]);
|
||||
|
||||
$person->client()->create();
|
||||
});
|
||||
|
||||
//\App\Models\Person\PersonAddress::create($address);
|
||||
// \App\Models\Person\PersonAddress::create($address);
|
||||
|
||||
return to_route('client');
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function update(Client $client, Request $request) {
|
||||
public function update(Client $client, Request $request)
|
||||
{
|
||||
|
||||
return to_route('client.show', $client);
|
||||
}
|
||||
|
|
|
|||
229
app/Http/Controllers/FieldJobController.php
Normal file
229
app/Http/Controllers/FieldJobController.php
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\Contract;
|
||||
use App\Models\FieldJob;
|
||||
use App\Models\FieldJobSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class FieldJobController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$setting = FieldJobSetting::query()->latest('id')->first();
|
||||
// Only fetch contracts that are currently in either the primary segment
|
||||
// or the optional queue segment defined on the latest FieldJobSetting.
|
||||
$segmentIds = collect([
|
||||
optional($setting)->queue_segment_id,
|
||||
optional($setting)->segment_id,
|
||||
])->filter()->unique()->values();
|
||||
|
||||
$contracts = Contract::query()
|
||||
->with(['clientCase.person', 'type', 'account'])
|
||||
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
|
||||
$q->whereHas('segments', function ($sq) use ($segmentIds) {
|
||||
// Relation already filters on active pivots
|
||||
$sq->whereIn('segments.id', $segmentIds);
|
||||
});
|
||||
}, function ($q) {
|
||||
// No segments configured on FieldJobSetting -> return none
|
||||
$q->whereRaw('1 = 0');
|
||||
})
|
||||
->latest('id')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
// Build active assignment map keyed by contract uuid for quicker UI checks
|
||||
$assignments = collect();
|
||||
if ($contracts->isNotEmpty()) {
|
||||
$activeJobs = FieldJob::query()
|
||||
->whereIn('contract_id', $contracts->pluck('id'))
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->with(['assignedUser:id,name', 'user:id,name', 'contract:id,uuid'])
|
||||
->get();
|
||||
|
||||
$assignments = $activeJobs->mapWithKeys(function (FieldJob $job) {
|
||||
return [
|
||||
optional($job->contract)->uuid => [
|
||||
'assigned_to' => $job->assignedUser ? ['id' => $job->assignedUser->id, 'name' => $job->assignedUser->name] : null,
|
||||
'assigned_by' => $job->user ? ['id' => $job->user->id, 'name' => $job->user->name] : null,
|
||||
'assigned_at' => $job->assigned_at,
|
||||
],
|
||||
];
|
||||
})->filter();
|
||||
}
|
||||
|
||||
$users = User::query()->orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return Inertia::render('FieldJob/Index', [
|
||||
'setting' => $setting,
|
||||
'contracts' => $contracts,
|
||||
'users' => $users,
|
||||
'assignments' => $assignments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function assign(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'contract_uuid' => 'required|string|exists:contracts,uuid',
|
||||
'assigned_user_id' => 'required|integer|exists:users,id',
|
||||
]);
|
||||
|
||||
$setting = FieldJobSetting::query()->latest('id')->first();
|
||||
if (! $setting) {
|
||||
return back()->withErrors(['setting' => 'No Field Job Setting found. Create one in Settings → Field Job Settings.']);
|
||||
}
|
||||
|
||||
$contract = Contract::query()->where('uuid', $data['contract_uuid'])->firstOrFail();
|
||||
|
||||
$job = FieldJob::create([
|
||||
'field_job_setting_id' => $setting->id,
|
||||
'assigned_user_id' => $data['assigned_user_id'],
|
||||
'contract_id' => $contract->id,
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
// Create an activity for the assignment
|
||||
// Find the first action linked to the assign decision via pivot; also prefer actions within the same segment as the setting
|
||||
$decisionId = $setting->assign_decision_id;
|
||||
$actionId = null;
|
||||
if ($decisionId) {
|
||||
// Strictly use the action_decision pivot: take the first action mapped to this decision
|
||||
$actionId = DB::table('action_decision')
|
||||
->where('decision_id', $decisionId)
|
||||
->orderBy('id')
|
||||
->value('action_id');
|
||||
}
|
||||
|
||||
if ($actionId) {
|
||||
$assigneeName = User::query()->where('id', $data['assigned_user_id'])->value('name');
|
||||
// Localized note: "Terensko opravilo dodeljeno" + assignee when present
|
||||
$note = 'Terensko opravilo dodeljeno'.($assigneeName ? ' uporabniku '.$assigneeName : '');
|
||||
Activity::create([
|
||||
'due_date' => null,
|
||||
'amount' => null,
|
||||
'note' => $note,
|
||||
'action_id' => $actionId,
|
||||
'decision_id' => $decisionId,
|
||||
'client_case_id' => $contract->client_case_id,
|
||||
'contract_id' => $contract->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Field job assigned.');
|
||||
}
|
||||
|
||||
public function cancel(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'contract_uuid' => 'required|string|exists:contracts,uuid',
|
||||
]);
|
||||
|
||||
$contract = Contract::query()->where('uuid', $data['contract_uuid'])->firstOrFail();
|
||||
|
||||
$job = FieldJob::query()
|
||||
->where('contract_id', $contract->id)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($job) {
|
||||
$job->cancelled_at = now();
|
||||
$job->save();
|
||||
|
||||
// Create an activity for the cancellation, mirroring the assign flow
|
||||
// Prefer the job's setting for a consistent decision
|
||||
$job->loadMissing('setting');
|
||||
$decisionId = optional($job->setting)->cancel_decision_id;
|
||||
if ($decisionId) {
|
||||
$actionId = DB::table('action_decision')
|
||||
->where('decision_id', $decisionId)
|
||||
->orderBy('id')
|
||||
->value('action_id');
|
||||
|
||||
if ($actionId) {
|
||||
Activity::create([
|
||||
'due_date' => null,
|
||||
'amount' => null,
|
||||
'note' => 'Terensko opravilo preklicano',
|
||||
'action_id' => $actionId,
|
||||
'decision_id' => $decisionId,
|
||||
'client_case_id' => $contract->client_case_id,
|
||||
'contract_id' => $contract->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'Field job cancelled.');
|
||||
}
|
||||
|
||||
public function complete(Request $request, \App\Models\ClientCase $clientCase)
|
||||
{
|
||||
// Complete all active field jobs for contracts of this case assigned to current user
|
||||
$userId = optional($request->user())->id;
|
||||
$setting = FieldJobSetting::query()->latest('id')->first();
|
||||
if (! $setting) {
|
||||
return back()->withErrors(['setting' => 'No Field Job Setting found.']);
|
||||
}
|
||||
|
||||
$decisionId = $setting->complete_decision_id;
|
||||
$actionId = null;
|
||||
if ($decisionId) {
|
||||
$actionId = DB::table('action_decision')
|
||||
->where('decision_id', $decisionId)
|
||||
->orderBy('id')
|
||||
->value('action_id');
|
||||
}
|
||||
|
||||
// Find all active jobs for this case for the current user
|
||||
$jobs = FieldJob::query()
|
||||
->whereHas('contract', function ($q) use ($clientCase) {
|
||||
$q->where('client_case_id', $clientCase->id);
|
||||
})
|
||||
->where(function ($q) use ($userId) {
|
||||
if ($userId) {
|
||||
$q->where('assigned_user_id', $userId);
|
||||
}
|
||||
})
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->with(['contract:id,client_case_id', 'setting'])
|
||||
->get();
|
||||
|
||||
DB::transaction(function () use ($jobs, $decisionId, $actionId) {
|
||||
foreach ($jobs as $job) {
|
||||
// Mark job complete
|
||||
$job->completed_at = now();
|
||||
$job->save();
|
||||
|
||||
// Log completion activity on the contract/case
|
||||
if ($actionId && $decisionId) {
|
||||
Activity::create([
|
||||
'due_date' => null,
|
||||
'amount' => null,
|
||||
'note' => 'Terensko opravilo zaključeno',
|
||||
'action_id' => $actionId,
|
||||
'decision_id' => $decisionId,
|
||||
'client_case_id' => $job->contract->client_case_id,
|
||||
'contract_id' => $job->contract_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// Move contract to configured return segment
|
||||
$job->returnContractToConfiguredSegment();
|
||||
}
|
||||
});
|
||||
|
||||
// Redirect back to phone index
|
||||
return to_route('phone.index');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreFieldJobSettingRequest;
|
||||
use App\Http\Requests\UpdateFieldJobSettingRequest;
|
||||
use App\Models\Decision;
|
||||
use App\Models\FieldJobSetting;
|
||||
use App\Models\Segment;
|
||||
use App\Models\Decision;
|
||||
use App\Http\Requests\StoreFieldJobSettingRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ class FieldJobSettingController extends Controller
|
|||
public function index(Request $request)
|
||||
{
|
||||
$settings = FieldJobSetting::query()
|
||||
->with(['segment', 'initialDecision', 'asignDecision', 'completeDecision'])
|
||||
->with(['segment', 'initialDecision', 'assignDecision', 'completeDecision', 'cancelDecision', 'returnSegment', 'queueSegment'])
|
||||
->get();
|
||||
|
||||
return Inertia::render('Settings/FieldJob/Index', [
|
||||
|
|
@ -31,10 +32,30 @@ public function store(StoreFieldJobSettingRequest $request)
|
|||
FieldJobSetting::create([
|
||||
'segment_id' => $attributes['segment_id'],
|
||||
'initial_decision_id' => $attributes['initial_decision_id'],
|
||||
'asign_decision_id' => $attributes['asign_decision_id'],
|
||||
'assign_decision_id' => $attributes['assign_decision_id'],
|
||||
'complete_decision_id' => $attributes['complete_decision_id'],
|
||||
'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null,
|
||||
'return_segment_id' => $attributes['return_segment_id'] ?? null,
|
||||
'queue_segment_id' => $attributes['queue_segment_id'] ?? null,
|
||||
]);
|
||||
|
||||
return to_route('settings.fieldjob.index')->with('success', 'Field job setting created successfully!');
|
||||
}
|
||||
|
||||
public function update(FieldJobSetting $setting, UpdateFieldJobSettingRequest $request)
|
||||
{
|
||||
$attributes = $request->validated();
|
||||
|
||||
$setting->update([
|
||||
'segment_id' => $attributes['segment_id'],
|
||||
'initial_decision_id' => $attributes['initial_decision_id'],
|
||||
'assign_decision_id' => $attributes['assign_decision_id'],
|
||||
'complete_decision_id' => $attributes['complete_decision_id'],
|
||||
'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null,
|
||||
'return_segment_id' => $attributes['return_segment_id'] ?? null,
|
||||
'queue_segment_id' => $attributes['queue_segment_id'] ?? null,
|
||||
]);
|
||||
|
||||
return to_route('settings.fieldjob.index')->with('success', 'Field job setting updated successfully!');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
141
app/Http/Controllers/PhoneViewController.php
Normal file
141
app/Http/Controllers/PhoneViewController.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\FieldJob;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PhoneViewController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
|
||||
$jobs = FieldJob::query()
|
||||
->where('assigned_user_id', $userId)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->with([
|
||||
'contract' => function ($q) {
|
||||
$q->with(['type:id,name', 'account', 'clientCase.person' => function ($pq) {
|
||||
$pq->with(['addresses', 'phones']);
|
||||
}]);
|
||||
},
|
||||
])
|
||||
->orderByDesc('assigned_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
]);
|
||||
}
|
||||
|
||||
public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
|
||||
// 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);
|
||||
|
||||
// Determine contracts of this case assigned to the current user via FieldJobs and still active
|
||||
$assignedContractIds = 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();
|
||||
|
||||
$contracts = \App\Models\Contract::query()
|
||||
->where('client_case_id', $case->id)
|
||||
->whereIn('id', $assignedContractIds)
|
||||
->with(['type:id,name', 'account'])
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
// 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()
|
||||
->groupBy('contract_id')
|
||||
->map(function ($group) {
|
||||
return $group->first();
|
||||
});
|
||||
|
||||
foreach ($latestObjects as $cid => $obj) {
|
||||
if (isset($byId[$cid])) {
|
||||
$byId[$cid]->setAttribute('last_object', $obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build merged documents: case documents + documents of assigned contracts
|
||||
$contractRefMap = [];
|
||||
foreach ($contracts as $c) {
|
||||
$contractRefMap[$c->id] = $c->reference;
|
||||
}
|
||||
|
||||
$contractDocs = \App\Models\Document::query()
|
||||
->where('documentable_type', \App\Models\Contract::class)
|
||||
->whereIn('documentable_id', $assignedContractIds)
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->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;
|
||||
|
||||
return $arr;
|
||||
});
|
||||
|
||||
$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' => \App\Models\Person\AddressType::all(),
|
||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
];
|
||||
|
||||
// Case activities (compact for phone): latest 20 with relations
|
||||
$activities = $case->activities()
|
||||
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
||||
->orderByDesc('created_at')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(function ($a) {
|
||||
$a->setAttribute('user_name', optional($a->user)->name);
|
||||
|
||||
return $a;
|
||||
});
|
||||
|
||||
return Inertia::render('Phone/Case/Index', [
|
||||
'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' => \App\Models\AccountType::all(),
|
||||
'actions' => \App\Models\Action::with('decisions')->get(),
|
||||
'activities' => $activities,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ public function rules(): array
|
|||
'description' => ['nullable', 'string', 'max:255'],
|
||||
'initial_amount' => ['nullable', 'numeric'],
|
||||
'balance_amount' => ['nullable', 'numeric'],
|
||||
'account_type_id' => ['nullable', 'integer', 'exists:account_types,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StoreFieldJobSettingRequest extends FormRequest
|
||||
{
|
||||
|
|
@ -16,8 +17,11 @@ public function rules(): array
|
|||
return [
|
||||
'segment_id' => ['required', 'integer', 'exists:segments,id'],
|
||||
'initial_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
||||
'asign_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
||||
'assign_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
||||
'complete_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
||||
'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
'queue_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -26,8 +30,51 @@ public function messages(): array
|
|||
return [
|
||||
'segment_id.required' => 'Segment is required.',
|
||||
'initial_decision_id.required' => 'Initial decision is required.',
|
||||
'asign_decision_id.required' => 'Assign decision is required.',
|
||||
'assign_decision_id.required' => 'Assign decision is required.',
|
||||
'complete_decision_id.required' => 'Complete decision is required.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the validator instance.
|
||||
*/
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator): void {
|
||||
// Validate that the assign_decision_id has a mapped action
|
||||
$assignDecisionId = $this->input('assign_decision_id');
|
||||
if (! empty($assignDecisionId)) {
|
||||
$mapped = DB::table('action_decision')
|
||||
->where('decision_id', $assignDecisionId)
|
||||
->exists();
|
||||
|
||||
if (! $mapped) {
|
||||
$validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map an action to this decision first.');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that the complete_decision_id has a mapped action
|
||||
$completeDecisionId = $this->input('complete_decision_id');
|
||||
if (! empty($completeDecisionId)) {
|
||||
$mapped = DB::table('action_decision')
|
||||
->where('decision_id', $completeDecisionId)
|
||||
->exists();
|
||||
|
||||
if (! $mapped) {
|
||||
$validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map an action to this decision first.');
|
||||
}
|
||||
}
|
||||
|
||||
$cancelDecisionId = $this->input('cancel_decision_id');
|
||||
if (! empty($cancelDecisionId)) {
|
||||
$mapped = DB::table('action_decision')
|
||||
->where('decision_id', $cancelDecisionId)
|
||||
->exists();
|
||||
|
||||
if (! $mapped) {
|
||||
$validator->errors()->add('cancel_decision_id', 'The selected cancel decision must have a mapped action. Please map an action to this decision first.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ public function rules(): array
|
|||
'description' => ['nullable', 'string', 'max:255'],
|
||||
'initial_amount' => ['nullable', 'numeric'],
|
||||
'balance_amount' => ['nullable', 'numeric'],
|
||||
'account_type_id' => ['sometimes', 'nullable', 'integer', 'exists:account_types,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
72
app/Http/Requests/UpdateFieldJobSettingRequest.php
Normal file
72
app/Http/Requests/UpdateFieldJobSettingRequest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateFieldJobSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'segment_id' => ['required', 'integer', 'exists:segments,id'],
|
||||
'initial_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
||||
'assign_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
||||
'complete_decision_id' => ['required', 'integer', 'exists:decisions,id'],
|
||||
'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||
'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
'queue_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'segment_id.required' => 'Segment is required.',
|
||||
'initial_decision_id.required' => 'Initial decision is required.',
|
||||
'assign_decision_id.required' => 'Assign decision is required.',
|
||||
'complete_decision_id.required' => 'Complete decision is required.',
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator): void {
|
||||
$assignDecisionId = $this->input('assign_decision_id');
|
||||
if (! empty($assignDecisionId)) {
|
||||
$mapped = DB::table('action_decision')
|
||||
->where('decision_id', $assignDecisionId)
|
||||
->exists();
|
||||
if (! $mapped) {
|
||||
$validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map an action to this decision first.');
|
||||
}
|
||||
}
|
||||
|
||||
$completeDecisionId = $this->input('complete_decision_id');
|
||||
if (! empty($completeDecisionId)) {
|
||||
$mapped = DB::table('action_decision')
|
||||
->where('decision_id', $completeDecisionId)
|
||||
->exists();
|
||||
if (! $mapped) {
|
||||
$validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map an action to this decision first.');
|
||||
}
|
||||
}
|
||||
|
||||
$cancelDecisionId = $this->input('cancel_decision_id');
|
||||
if (! empty($cancelDecisionId)) {
|
||||
$mapped = DB::table('action_decision')
|
||||
->where('decision_id', $cancelDecisionId)
|
||||
->exists();
|
||||
if (! $mapped) {
|
||||
$validator->errors()->add('cancel_decision_id', 'The selected cancel decision must have a mapped action. Please map an action to this decision first.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@
|
|||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
// Note: we intentionally use exec() with careful quoting and polling because on Windows soffice may spawn a child process.
|
||||
|
||||
class GenerateDocumentPreview implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
|
@ -21,19 +23,49 @@ class GenerateDocumentPreview implements ShouldQueue
|
|||
*/
|
||||
public $timeout = 180; // 3 minutes
|
||||
|
||||
public function __construct(public int $documentId)
|
||||
{
|
||||
}
|
||||
public function __construct(public int $documentId) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$doc = Document::find($this->documentId);
|
||||
if (!$doc)
|
||||
if (! $doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
$disk = $doc->disk ?: 'public';
|
||||
if (!Storage::disk($disk)->exists($doc->path))
|
||||
return;
|
||||
// Normalize path to support legacy entries with a leading 'public/'
|
||||
$relPath = ltrim($doc->path ?? '', '/\\');
|
||||
if (str_starts_with($relPath, 'public/')) {
|
||||
$relPath = substr($relPath, 7);
|
||||
}
|
||||
$sourceBytes = null;
|
||||
if (Storage::disk($disk)->exists($relPath)) {
|
||||
$sourceBytes = Storage::disk($disk)->get($relPath);
|
||||
} else {
|
||||
// Fallback to public/ filesystem in case of legacy placement
|
||||
$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)) {
|
||||
\Log::info('Preview job: using public path fallback for source file', [
|
||||
'document_id' => $doc->id,
|
||||
'path' => $realN,
|
||||
]);
|
||||
$sourceBytes = @file_get_contents($real);
|
||||
} else {
|
||||
\Log::warning('Preview job: source file missing on disk and public fallback failed', [
|
||||
'document_id' => $doc->id,
|
||||
'disk' => $disk,
|
||||
'path' => $doc->path,
|
||||
'normalized' => $relPath,
|
||||
'public_candidate' => $publicFull,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($doc->original_name ?: $doc->file_name, PATHINFO_EXTENSION));
|
||||
|
||||
|
|
@ -48,69 +80,52 @@ public function handle(): void
|
|||
'updated_at' => (string) $doc->updated_at,
|
||||
'preview_generated_at' => (string) $doc->preview_generated_at,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!in_array($ext, ['doc', 'docx']))
|
||||
return; // only convert office docs here
|
||||
if (! in_array($ext, ['doc', 'docx'])) {
|
||||
return;
|
||||
} // only convert office docs here
|
||||
|
||||
// Prepare temp files - keep original extension so LibreOffice can detect filter
|
||||
$tmpBase = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'doc_in_' . uniqid();
|
||||
$tmpIn = $tmpBase . '.' . $ext; // e.g., .doc or .docx
|
||||
file_put_contents($tmpIn, Storage::disk($disk)->get($doc->path));
|
||||
$tmpBase = sys_get_temp_dir().DIRECTORY_SEPARATOR.'doc_in_'.uniqid();
|
||||
$tmpIn = $tmpBase.'.'.$ext; // e.g., .doc or .docx
|
||||
file_put_contents($tmpIn, $sourceBytes);
|
||||
|
||||
$outDir = sys_get_temp_dir();
|
||||
// Ensure exec is available
|
||||
if (!function_exists('exec')) {
|
||||
Log::error('Preview generation failed: exec() not available in this PHP environment', ['document_id' => $doc->id]);
|
||||
return;
|
||||
}
|
||||
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
|
||||
if (in_array('exec', $disabled, true)) {
|
||||
Log::error('Preview generation failed: exec() is disabled in php.ini (disable_functions)', ['document_id' => $doc->id]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run soffice headless to convert to PDF
|
||||
$binCfg = config('files.libreoffice_bin');
|
||||
$bin = $binCfg ? (string) $binCfg : 'soffice';
|
||||
// If an absolute path is configured, ensure it exists to avoid long PATH resolution delays
|
||||
if ($binCfg && preg_match('/^[a-zA-Z]:\\\\|^\//', $bin) && !file_exists($bin)) {
|
||||
if ($binCfg && preg_match('/^[a-zA-Z]:\\\\|^\//', $bin) && ! file_exists($bin)) {
|
||||
Log::warning('Configured LibreOffice binary not found; falling back to PATH', [
|
||||
'document_id' => $doc->id,
|
||||
'bin' => $bin,
|
||||
]);
|
||||
$bin = 'soffice';
|
||||
}
|
||||
// Windows quoting differs from POSIX. Build command parts safely.
|
||||
$isWin = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
|
||||
if ($isWin) {
|
||||
$binPart = '"' . $bin . '"';
|
||||
$outDirPart = '"' . $outDir . '"';
|
||||
$inPart = '"' . $tmpIn . '"';
|
||||
} else {
|
||||
$binPart = escapeshellcmd($bin);
|
||||
$outDirPart = escapeshellarg($outDir);
|
||||
$inPart = escapeshellarg($tmpIn);
|
||||
}
|
||||
// Use a temporary user profile to avoid permissions/profile lock issues
|
||||
$loProfileDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'lo_profile_' . $doc->id;
|
||||
if (!is_dir($loProfileDir)) {
|
||||
$loProfileDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'lo_profile_'.$doc->id;
|
||||
if (! is_dir($loProfileDir)) {
|
||||
@mkdir($loProfileDir, 0700, true);
|
||||
}
|
||||
$loProfileUri = 'file:///' . ltrim(str_replace('\\', '/', $loProfileDir), '/');
|
||||
$loProfileUri = 'file:///'.ltrim(str_replace('\\', '/', $loProfileDir), '/');
|
||||
|
||||
// Build command string for exec()
|
||||
$isWin = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
|
||||
$binPart = $isWin ? '"'.$bin.'"' : escapeshellcmd($bin);
|
||||
$outDirPart = $isWin ? '"'.$outDir.'"' : escapeshellarg($outDir);
|
||||
$inPart = $isWin ? '"'.$tmpIn.'"' : escapeshellarg($tmpIn);
|
||||
$profilePart = $isWin ? '"'.$loProfileUri.'"' : escapeshellarg($loProfileUri);
|
||||
$cmd = sprintf(
|
||||
'%s --headless --norestore --nolockcheck -env:UserInstallation=%s --convert-to pdf --outdir %s %s',
|
||||
'%s --headless --norestore --nolockcheck --nologo --nodefault --nofirststartwizard -env:UserInstallation=%s --convert-to pdf --outdir %s %s',
|
||||
$binPart,
|
||||
$isWin ? '"' . $loProfileUri . '"' : escapeshellarg($loProfileUri),
|
||||
$profilePart,
|
||||
$outDirPart,
|
||||
$inPart
|
||||
);
|
||||
|
||||
// Capture stderr as well for diagnostics
|
||||
$cmdWithStderr = $cmd . ' 2>&1';
|
||||
$t0 = microtime(true);
|
||||
Log::info('Starting LibreOffice preview conversion', [
|
||||
'document_id' => $doc->id,
|
||||
|
|
@ -119,49 +134,64 @@ public function handle(): void
|
|||
]);
|
||||
$out = [];
|
||||
$ret = 0;
|
||||
exec($cmdWithStderr, $out, $ret);
|
||||
@exec($cmd.' 2>&1', $out, $ret);
|
||||
// Some Windows installs may return before file is fully written; we'll poll for the output file below anyway.
|
||||
if ($ret !== 0) {
|
||||
Log::warning('Preview generation failed', [
|
||||
'document_id' => $doc->id,
|
||||
'ret' => $ret,
|
||||
'cmd' => $cmd,
|
||||
'exit_code' => $ret,
|
||||
'output' => implode("\n", $out),
|
||||
]);
|
||||
@unlink($tmpIn);
|
||||
|
||||
return;
|
||||
}
|
||||
$elapsed = (int) round((microtime(true) - $t0) * 1000);
|
||||
|
||||
$pdfPathLocal = $tmpIn . '.pdf';
|
||||
$pdfPathLocal = $tmpIn.'.pdf';
|
||||
// LibreOffice writes output with source filename base; derive path
|
||||
$base = pathinfo($tmpIn, PATHINFO_FILENAME);
|
||||
$pdfPathLocal = $outDir . DIRECTORY_SEPARATOR . $base . '.pdf';
|
||||
if (!file_exists($pdfPathLocal)) {
|
||||
$pdfPathLocal = $outDir.DIRECTORY_SEPARATOR.$base.'.pdf';
|
||||
// Poll for up to 10s for the PDF to appear (handles async write on Windows)
|
||||
$waitUntil = microtime(true) + 10.0;
|
||||
while (! file_exists($pdfPathLocal) && microtime(true) < $waitUntil) {
|
||||
usleep(200 * 1000); // 200ms
|
||||
}
|
||||
if (! file_exists($pdfPathLocal)) {
|
||||
// fallback: try with original name base
|
||||
$origBase = pathinfo($doc->original_name ?: $doc->file_name, PATHINFO_FILENAME);
|
||||
$try = $outDir . DIRECTORY_SEPARATOR . $origBase . '.pdf';
|
||||
if (file_exists($try))
|
||||
$try = $outDir.DIRECTORY_SEPARATOR.$origBase.'.pdf';
|
||||
// brief poll for fallback name as well
|
||||
$waitUntil2 = microtime(true) + 5.0;
|
||||
while (! file_exists($try) && microtime(true) < $waitUntil2) {
|
||||
usleep(200 * 1000);
|
||||
}
|
||||
if (file_exists($try)) {
|
||||
$pdfPathLocal = $try;
|
||||
}
|
||||
}
|
||||
if (!file_exists($pdfPathLocal)) {
|
||||
if (! file_exists($pdfPathLocal)) {
|
||||
Log::warning('Preview generation did not produce expected PDF output', [
|
||||
'document_id' => $doc->id,
|
||||
'out_dir' => $outDir,
|
||||
'tmp_base' => $base,
|
||||
'command' => $cmd,
|
||||
'output' => implode("\n", $out),
|
||||
'stdout' => implode("\n", $out),
|
||||
]);
|
||||
@unlink($tmpIn);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute elapsed time once output exists
|
||||
$elapsed = (int) round((microtime(true) - $t0) * 1000);
|
||||
|
||||
// Store preview PDF to configured disk inside configured previews base path
|
||||
$previewDisk = config('files.preview_disk', 'public');
|
||||
$base = trim(config('files.preview_base', 'previews/cases'), '/');
|
||||
$previewDir = $base . '/' . ($doc->documentable?->uuid ?? 'unknown');
|
||||
$stored = Storage::disk($previewDisk)->put($previewDir . '/' . ($doc->uuid) . '.pdf', file_get_contents($pdfPathLocal));
|
||||
$previewDir = $base.'/'.($doc->documentable?->uuid ?? 'unknown');
|
||||
$stored = Storage::disk($previewDisk)->put($previewDir.'/'.($doc->uuid).'.pdf', file_get_contents($pdfPathLocal));
|
||||
if ($stored) {
|
||||
$doc->preview_path = $previewDir . '/' . $doc->uuid . '.pdf';
|
||||
$doc->preview_path = $previewDir.'/'.$doc->uuid.'.pdf';
|
||||
$doc->preview_mime = 'application/pdf';
|
||||
$doc->preview_generated_at = now();
|
||||
$doc->save();
|
||||
|
|
@ -170,6 +200,12 @@ public function handle(): void
|
|||
'preview_path' => $doc->preview_path,
|
||||
'elapsed_ms' => $elapsed,
|
||||
]);
|
||||
} else {
|
||||
Log::warning('Preview generated but storing to disk failed', [
|
||||
'document_id' => $doc->id,
|
||||
'preview_disk' => $previewDisk,
|
||||
'target' => $previewDir.'/'.$doc->uuid.'.pdf',
|
||||
]);
|
||||
}
|
||||
|
||||
@unlink($tmpIn);
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Activity extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\ActivityFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -20,7 +22,8 @@ class Activity extends Model
|
|||
'action_id',
|
||||
'user_id',
|
||||
'decision_id',
|
||||
'contract_id'
|
||||
'contract_id',
|
||||
'client_case_id',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
|
@ -28,19 +31,25 @@ class Activity extends Model
|
|||
'decision_id',
|
||||
'client_case_id',
|
||||
'user_id',
|
||||
'contract_id'
|
||||
'contract_id',
|
||||
];
|
||||
|
||||
protected static function booted(){
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function (Activity $activity) {
|
||||
if(!isset($activity->user_id)){
|
||||
if (! isset($activity->user_id)) {
|
||||
$activity->user_id = auth()->id();
|
||||
}
|
||||
|
||||
// If an activity with a due date is added for a contract, update the related account's promise_date
|
||||
if (! empty($activity->contract_id) && ! empty($activity->due_date)) {
|
||||
DB::table('accounts')
|
||||
->where('contract_id', $activity->contract_id)
|
||||
->update(['promise_date' => $activity->due_date, 'updated_at' => now()]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function action(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Action::class);
|
||||
|
|
@ -56,8 +65,13 @@ public function clientCase(): BelongsTo
|
|||
return $this->belongsTo(\App\Models\ClientCase::class);
|
||||
}
|
||||
|
||||
public function contract(): BelongsTo|null
|
||||
public function contract(): ?BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Contract::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,34 +3,35 @@
|
|||
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\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
class ClientCase extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\ClientCaseFactory> */
|
||||
use HasFactory;
|
||||
use Uuid;
|
||||
|
||||
use Searchable;
|
||||
use Uuid;
|
||||
|
||||
protected $fillable = [
|
||||
'client_id',
|
||||
'person_id'
|
||||
'person_id',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'id',
|
||||
'client_id',
|
||||
'person_id'
|
||||
'person_id',
|
||||
];
|
||||
|
||||
protected function makeAllSearchableUsing(Builder $query): Builder
|
||||
protected function makeAllSearchableUsing(Builder $query): Builder
|
||||
{
|
||||
return $query->with('person');
|
||||
}
|
||||
|
|
@ -39,11 +40,11 @@ public function toSearchableArray(): array
|
|||
{
|
||||
|
||||
return [
|
||||
'person.full_name' => ''
|
||||
'person.full_name' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public function client(): BelongsTo
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Client::class);
|
||||
}
|
||||
|
|
@ -64,7 +65,8 @@ public function activities(): HasMany
|
|||
return $this->hasMany(\App\Models\Activity::class);
|
||||
}
|
||||
|
||||
public function segments(): BelongsToMany {
|
||||
public function segments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\Segment::class)->withTimestamps();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,24 +3,22 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Traits\Uuid;
|
||||
use Illuminate\Database\Eloquent\Factories\BelongsToManyRelationship;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Contract extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\ContractFactory> */
|
||||
use HasFactory;
|
||||
use Uuid;
|
||||
|
||||
use SoftDeletes;
|
||||
use Uuid;
|
||||
|
||||
protected $fillable = [
|
||||
'reference',
|
||||
|
|
@ -28,13 +26,13 @@ class Contract extends Model
|
|||
'end_date',
|
||||
'client_case_id',
|
||||
'type_id',
|
||||
'description'
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'id',
|
||||
'client_case_id',
|
||||
'type_id'
|
||||
'type_id',
|
||||
];
|
||||
|
||||
public function type(): BelongsTo
|
||||
|
|
@ -47,8 +45,9 @@ public function clientCase(): BelongsTo
|
|||
return $this->belongsTo(\App\Models\ClientCase::class)
|
||||
->with(['person']);
|
||||
}
|
||||
|
||||
public function segments(): BelongsToMany {
|
||||
|
||||
public function segments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\Segment::class)
|
||||
->withPivot('active', 'created_at')
|
||||
->wherePivot('active', true);
|
||||
|
|
@ -65,6 +64,11 @@ public function objects(): HasMany
|
|||
return $this->hasMany(\App\Models\CaseObject::class, 'contract_id');
|
||||
}
|
||||
|
||||
public function documents(): MorphMany
|
||||
{
|
||||
return $this->morphMany(\App\Models\Document::class, 'documentable');
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function (Contract $contract): void {
|
||||
|
|
@ -96,7 +100,7 @@ protected static function booted(): void
|
|||
->where('client_case_id', $contract->client_case_id)
|
||||
->where('segment_id', $cfg->segment_id)
|
||||
->first();
|
||||
if (!$attached) {
|
||||
if (! $attached) {
|
||||
\DB::table('client_case_segment')->insert([
|
||||
'client_case_id' => $contract->client_case_id,
|
||||
'segment_id' => $cfg->segment_id,
|
||||
|
|
@ -104,7 +108,7 @@ protected static function booted(): void
|
|||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} elseif (!$attached->active) {
|
||||
} elseif (! $attached->active) {
|
||||
\DB::table('client_case_segment')
|
||||
->where('id', $attached->id)
|
||||
->update(['active' => true, 'updated_at' => now()]);
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@
|
|||
class Document extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use Uuid;
|
||||
use SoftDeletes;
|
||||
use Uuid;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
|
|
@ -80,4 +80,15 @@ protected static function booted(): void
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Include soft-deleted documents when resolving by route key (e.g. {document:uuid}).
|
||||
*/
|
||||
public function resolveRouteBinding($value, $field = null)
|
||||
{
|
||||
// Always include trashed so deep-linking to older documents works
|
||||
return static::withTrashed()
|
||||
->where($field ?? $this->getRouteKeyName(), $value)
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FieldJob extends Model
|
||||
{
|
||||
|
|
@ -14,7 +15,7 @@ class FieldJob extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'field_job_setting_id',
|
||||
'asigned_user_id',
|
||||
'assigned_user_id',
|
||||
'user_id',
|
||||
'contract_id',
|
||||
'assigned_at',
|
||||
|
|
@ -33,12 +34,43 @@ class FieldJob extends Model
|
|||
'address_snapshot ' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted(){
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function (FieldJob $fieldJob) {
|
||||
if(!isset($fieldJob->user_id)){
|
||||
if (! isset($fieldJob->user_id)) {
|
||||
$fieldJob->user_id = auth()->id();
|
||||
}
|
||||
});
|
||||
|
||||
static::updated(function (FieldJob $fieldJob): void {
|
||||
// If job was just completed or cancelled, move contract to configured segment
|
||||
$completedChanged = $fieldJob->wasChanged('completed_at') && ! is_null($fieldJob->completed_at);
|
||||
$cancelledChanged = $fieldJob->wasChanged('cancelled_at') && ! is_null($fieldJob->cancelled_at);
|
||||
|
||||
if (! $completedChanged && ! $cancelledChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $fieldJob->relationLoaded('setting')) {
|
||||
$fieldJob->load('setting');
|
||||
}
|
||||
|
||||
if ($cancelledChanged) {
|
||||
// On cancel: redirect to queue segment
|
||||
$segmentId = $fieldJob->setting?->queue_segment_id;
|
||||
$fieldJob->moveContractToSegment($segmentId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($completedChanged) {
|
||||
// On complete: redirect to return segment
|
||||
$segmentId = $fieldJob->setting?->return_segment_id;
|
||||
$fieldJob->moveContractToSegment($segmentId);
|
||||
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function setting(): BelongsTo
|
||||
|
|
@ -48,7 +80,7 @@ public function setting(): BelongsTo
|
|||
|
||||
public function assignedUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'asigned_user_id');
|
||||
return $this->belongsTo(User::class, 'assigned_user_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
|
|
@ -60,4 +92,55 @@ public function contract(): BelongsTo
|
|||
{
|
||||
return $this->belongsTo(Contract::class, 'contract_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set/ensure the contract has the return segment marked active based on the field job setting.
|
||||
*/
|
||||
/**
|
||||
* Ensure the contract has the provided segment marked active.
|
||||
*/
|
||||
public function moveContractToSegment(?int $segmentId): void
|
||||
{
|
||||
if (empty($segmentId) || empty($this->contract_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First, deactivate any currently active segments for this contract
|
||||
DB::table('contract_segment')
|
||||
->where('contract_id', $this->contract_id)
|
||||
->where('active', true)
|
||||
->update(['active' => false, 'updated_at' => now()]);
|
||||
|
||||
// Then activate (or create) the target segment pivot
|
||||
$pivot = DB::table('contract_segment')
|
||||
->where('contract_id', $this->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' => $this->contract_id,
|
||||
'segment_id' => $segmentId,
|
||||
'active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat convenience: move to configured return segment.
|
||||
*/
|
||||
public function returnContractToConfiguredSegment(): void
|
||||
{
|
||||
if (! $this->relationLoaded('setting')) {
|
||||
$this->load('setting');
|
||||
}
|
||||
|
||||
$this->moveContractToSegment($this->setting?->return_segment_id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ class FieldJobSetting extends Model
|
|||
protected $fillable = [
|
||||
'segment_id',
|
||||
'initial_decision_id',
|
||||
'asign_decision_id',
|
||||
'assign_decision_id',
|
||||
'complete_decision_id',
|
||||
'cancel_decision_id',
|
||||
'return_segment_id',
|
||||
'queue_segment_id',
|
||||
];
|
||||
|
||||
public function segment(): BelongsTo
|
||||
|
|
@ -23,9 +26,9 @@ public function segment(): BelongsTo
|
|||
return $this->belongsTo(Segment::class);
|
||||
}
|
||||
|
||||
public function asignDecision(): BelongsTo
|
||||
public function assignDecision(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Decision::class, 'asign_decision_id');
|
||||
return $this->belongsTo(Decision::class, 'assign_decision_id');
|
||||
}
|
||||
|
||||
public function initialDecision(): BelongsTo
|
||||
|
|
@ -38,6 +41,21 @@ public function completeDecision(): BelongsTo
|
|||
return $this->belongsTo(Decision::class, 'complete_decision_id');
|
||||
}
|
||||
|
||||
public function cancelDecision(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Decision::class, 'cancel_decision_id');
|
||||
}
|
||||
|
||||
public function returnSegment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Segment::class, 'return_segment_id');
|
||||
}
|
||||
|
||||
public function queueSegment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Segment::class, 'queue_segment_id');
|
||||
}
|
||||
|
||||
public function fieldJobs(): HasMany
|
||||
{
|
||||
return $this->hasMany(FieldJob::class);
|
||||
|
|
|
|||
|
|
@ -2,23 +2,23 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountType;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\ContractType;
|
||||
use App\Models\Email;
|
||||
use App\Models\Import;
|
||||
use App\Models\ImportEvent;
|
||||
use App\Models\ImportRow;
|
||||
use App\Models\Account;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Email;
|
||||
use App\Models\Person\Person;
|
||||
use App\Models\Person\PersonGroup;
|
||||
use App\Models\Person\PersonType;
|
||||
use App\Models\Person\PersonAddress;
|
||||
use App\Models\Person\PersonPhone;
|
||||
use App\Models\Person\AddressType;
|
||||
use App\Models\Person\Person;
|
||||
use App\Models\Person\PersonAddress;
|
||||
use App\Models\Person\PersonGroup;
|
||||
use App\Models\Person\PersonPhone;
|
||||
use App\Models\Person\PersonType;
|
||||
use App\Models\Person\PhoneType;
|
||||
use App\Models\ContractType;
|
||||
use App\Models\AccountType;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
|
@ -32,11 +32,14 @@ class ImportProcessor
|
|||
public function process(Import $import, ?Authenticatable $user = null): array
|
||||
{
|
||||
$started = now();
|
||||
$total = 0; $skipped = 0; $imported = 0; $invalid = 0;
|
||||
$total = 0;
|
||||
$skipped = 0;
|
||||
$imported = 0;
|
||||
$invalid = 0;
|
||||
$fh = null;
|
||||
|
||||
// Only CSV/TSV supported in this pass
|
||||
if (!in_array($import->source_type, ['csv','txt'])) {
|
||||
if (! in_array($import->source_type, ['csv', 'txt'])) {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
|
|
@ -45,13 +48,14 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
'message' => 'Only CSV/TXT supported in this pass.',
|
||||
]);
|
||||
$import->update(['status' => 'completed', 'finished_at' => now()]);
|
||||
return [ 'ok' => true, 'status' => $import->status, 'counts' => compact('total','skipped','imported','invalid') ];
|
||||
|
||||
return ['ok' => true, 'status' => $import->status, 'counts' => compact('total', 'skipped', 'imported', 'invalid')];
|
||||
}
|
||||
|
||||
// Get mappings for this import (with apply_mode)
|
||||
$mappings = DB::table('import_mappings')
|
||||
->where('import_id', $import->id)
|
||||
->get(['source_column','target_field','transform','apply_mode','options']);
|
||||
->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']);
|
||||
|
||||
$header = $import->meta['columns'] ?? null;
|
||||
$delimiter = $import->meta['detected_delimiter'] ?? ',';
|
||||
|
|
@ -60,7 +64,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
|
||||
// Parse file and create import_rows with mapped_data
|
||||
$fh = @fopen($path, 'r');
|
||||
if (!$fh) {
|
||||
if (! $fh) {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
|
|
@ -69,7 +73,8 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
'message' => 'Unable to open file for reading.',
|
||||
]);
|
||||
$import->update(['status' => 'failed', 'failed_at' => now()]);
|
||||
return [ 'ok' => false, 'status' => $import->status ];
|
||||
|
||||
return ['ok' => false, 'status' => $import->status];
|
||||
}
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
|
@ -87,8 +92,8 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
$first = fgetcsv($fh, 0, $delimiter);
|
||||
$rowNum++;
|
||||
// use actual detected header if not already stored
|
||||
if (!$header) {
|
||||
$header = array_map(fn($v) => trim((string) $v), $first ?: []);
|
||||
if (! $header) {
|
||||
$header = array_map(fn ($v) => trim((string) $v), $first ?: []);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +128,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
'level' => 'info',
|
||||
'message' => $contractResult['message'] ?? 'Skipped contract (no changes).',
|
||||
]);
|
||||
} elseif (in_array($contractResult['action'], ['inserted','updated'])) {
|
||||
} elseif (in_array($contractResult['action'], ['inserted', 'updated'])) {
|
||||
$imported++;
|
||||
$importRow->update([
|
||||
'status' => 'imported',
|
||||
|
|
@ -137,7 +142,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
'event' => 'row_imported',
|
||||
'level' => 'info',
|
||||
'message' => ucfirst($contractResult['action']).' contract',
|
||||
'context' => [ 'id' => $contractResult['contract']->id ],
|
||||
'context' => ['id' => $contractResult['contract']->id],
|
||||
]);
|
||||
} else {
|
||||
$invalid++;
|
||||
|
|
@ -174,7 +179,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
'event' => 'row_imported',
|
||||
'level' => 'info',
|
||||
'message' => ucfirst($accountResult['action']).' account',
|
||||
'context' => [ 'id' => $accountResult['account']->id ],
|
||||
'context' => ['id' => $accountResult['account']->id],
|
||||
]);
|
||||
} else {
|
||||
$invalid++;
|
||||
|
|
@ -190,7 +195,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
$personIdForRow = ClientCase::where('id', $ccId)->value('person_id');
|
||||
}
|
||||
// If we have a contract reference, resolve existing contract for this client and derive person
|
||||
if (!$personIdForRow && $import->client_id && !empty($mapped['contract']['reference'] ?? null)) {
|
||||
if (! $personIdForRow && $import->client_id && ! empty($mapped['contract']['reference'] ?? null)) {
|
||||
$existingContract = Contract::query()
|
||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->where('client_cases.client_id', $import->client_id)
|
||||
|
|
@ -202,7 +207,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
}
|
||||
}
|
||||
// If account processing created/resolved a contract, derive person via its client_case
|
||||
if (!$personIdForRow && $accountResult) {
|
||||
if (! $personIdForRow && $accountResult) {
|
||||
if (isset($accountResult['contract']) && $accountResult['contract'] instanceof Contract) {
|
||||
$ccId = $accountResult['contract']->client_case_id;
|
||||
$personIdForRow = ClientCase::where('id', $ccId)->value('person_id');
|
||||
|
|
@ -214,50 +219,75 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
}
|
||||
}
|
||||
// Resolve by contact values next
|
||||
if (!$personIdForRow) {
|
||||
$emailVal = trim((string)($mapped['email']['value'] ?? ''));
|
||||
if (! $personIdForRow) {
|
||||
$emailVal = trim((string) ($mapped['email']['value'] ?? ''));
|
||||
$phoneNu = trim((string) ($mapped['phone']['nu'] ?? ''));
|
||||
$addrLine = trim((string) ($mapped['address']['address'] ?? ''));
|
||||
|
||||
// Try to resolve by existing contacts first
|
||||
if ($emailVal !== '') {
|
||||
$personIdForRow = Email::where('value', $emailVal)->value('person_id');
|
||||
}
|
||||
}
|
||||
if (!$personIdForRow) {
|
||||
$phoneNu = trim((string)($mapped['phone']['nu'] ?? ''));
|
||||
if ($phoneNu !== '') {
|
||||
if (! $personIdForRow && $phoneNu !== '') {
|
||||
$personIdForRow = PersonPhone::where('nu', $phoneNu)->value('person_id');
|
||||
}
|
||||
}
|
||||
if (!$personIdForRow) {
|
||||
$addrLine = trim((string)($mapped['address']['address'] ?? ''));
|
||||
if ($addrLine !== '') {
|
||||
if (! $personIdForRow && $addrLine !== '') {
|
||||
$personIdForRow = PersonAddress::where('address', $addrLine)->value('person_id');
|
||||
}
|
||||
|
||||
// If still no person but we have any contact value, auto-create a minimal person
|
||||
if (! $personIdForRow && ($emailVal !== '' || $phoneNu !== '' || $addrLine !== '')) {
|
||||
$personIdForRow = $this->createMinimalPersonId();
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'import_row_id' => $importRow->id ?? null,
|
||||
'event' => 'person_autocreated_for_contacts',
|
||||
'level' => 'info',
|
||||
'message' => 'Created minimal person to attach contact data (email/phone/address).',
|
||||
'context' => [
|
||||
'email' => $emailVal ?: null,
|
||||
'phone' => $phoneNu ?: null,
|
||||
'address' => $addrLine ?: null,
|
||||
'person_id' => $personIdForRow,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
// Try identifiers from mapped person (no creation yet)
|
||||
if (!$personIdForRow && !empty($mapped['person'] ?? [])) {
|
||||
if (! $personIdForRow && ! empty($mapped['person'] ?? [])) {
|
||||
$personIdForRow = $this->findPersonIdByIdentifiers($mapped['person']);
|
||||
}
|
||||
// Finally, if still unknown and person fields provided, create
|
||||
if (!$personIdForRow && !empty($mapped['person'] ?? [])) {
|
||||
if (! $personIdForRow && ! empty($mapped['person'] ?? [])) {
|
||||
$personIdForRow = $this->findOrCreatePersonId($mapped['person']);
|
||||
}
|
||||
|
||||
// At this point, personIdForRow is either resolved or remains null (no contacts/person data)
|
||||
|
||||
$contactChanged = false;
|
||||
if ($personIdForRow) {
|
||||
if (!empty($mapped['email'] ?? [])) {
|
||||
if (! empty($mapped['email'] ?? [])) {
|
||||
$r = $this->upsertEmail($personIdForRow, $mapped['email'], $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; }
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
}
|
||||
}
|
||||
if (!empty($mapped['address'] ?? [])) {
|
||||
if (! empty($mapped['address'] ?? [])) {
|
||||
$r = $this->upsertAddress($personIdForRow, $mapped['address'], $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; }
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
}
|
||||
}
|
||||
if (!empty($mapped['phone'] ?? [])) {
|
||||
if (! empty($mapped['phone'] ?? [])) {
|
||||
$r = $this->upsertPhone($personIdForRow, $mapped['phone'], $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; }
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($mapped['contract']) && !isset($mapped['account'])) {
|
||||
if (! isset($mapped['contract']) && ! isset($mapped['account'])) {
|
||||
if ($contactChanged) {
|
||||
$imported++;
|
||||
$importRow->update([
|
||||
|
|
@ -272,7 +302,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
'event' => 'row_imported',
|
||||
'level' => 'info',
|
||||
'message' => 'Contacts upserted',
|
||||
'context' => [ 'person_id' => $personIdForRow ],
|
||||
'context' => ['person_id' => $personIdForRow],
|
||||
]);
|
||||
} else {
|
||||
$skipped++;
|
||||
|
|
@ -297,10 +327,12 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
return [
|
||||
'ok' => true,
|
||||
'status' => $import->status,
|
||||
'counts' => compact('total','skipped','imported','invalid'),
|
||||
'counts' => compact('total', 'skipped', 'imported', 'invalid'),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
if (is_resource($fh)) { @fclose($fh); }
|
||||
if (is_resource($fh)) {
|
||||
@fclose($fh);
|
||||
}
|
||||
DB::rollBack();
|
||||
// Mark failed and log after rollback (so no partial writes persist)
|
||||
$import->refresh();
|
||||
|
|
@ -312,22 +344,27 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
'level' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
return [ 'ok' => false, 'status' => 'failed', 'error' => $e->getMessage() ];
|
||||
|
||||
return ['ok' => false, 'status' => 'failed', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
private function buildRowAssoc(array $row, ?array $header): array
|
||||
{
|
||||
if (!$header) {
|
||||
if (! $header) {
|
||||
// positional mapping: 0..N-1
|
||||
$assoc = [];
|
||||
foreach ($row as $i => $v) { $assoc[(string)$i] = $v; }
|
||||
foreach ($row as $i => $v) {
|
||||
$assoc[(string) $i] = $v;
|
||||
}
|
||||
|
||||
return $assoc;
|
||||
}
|
||||
$assoc = [];
|
||||
foreach ($header as $i => $name) {
|
||||
$assoc[$name] = $row[$i] ?? null;
|
||||
}
|
||||
|
||||
return $assoc;
|
||||
}
|
||||
|
||||
|
|
@ -338,31 +375,40 @@ private function applyMappings(array $raw, $mappings): array
|
|||
foreach ($mappings as $map) {
|
||||
$src = $map->source_column;
|
||||
$target = $map->target_field;
|
||||
if (!$target) continue;
|
||||
if (! $target) {
|
||||
continue;
|
||||
}
|
||||
$value = $raw[$src] ?? null;
|
||||
|
||||
// very basic transforms
|
||||
if ($map->transform === 'trim') { $value = is_string($value) ? trim($value) : $value; }
|
||||
if ($map->transform === 'upper') { $value = is_string($value) ? strtoupper($value) : $value; }
|
||||
if ($map->transform === 'trim') {
|
||||
$value = is_string($value) ? trim($value) : $value;
|
||||
}
|
||||
if ($map->transform === 'upper') {
|
||||
$value = is_string($value) ? strtoupper($value) : $value;
|
||||
}
|
||||
|
||||
// detect record type from first segment, e.g., "account.balance_amount"
|
||||
$parts = explode('.', $target);
|
||||
if (!$recordType && isset($parts[0])) {
|
||||
if (! $recordType && isset($parts[0])) {
|
||||
$recordType = $parts[0];
|
||||
}
|
||||
// build nested array by dot notation
|
||||
$this->arraySetDot($mapped, $target, $value);
|
||||
}
|
||||
|
||||
return [$recordType, $mapped];
|
||||
}
|
||||
|
||||
private function arraySetDot(array &$arr, string $path, $value): void
|
||||
{
|
||||
$keys = explode('.', $path);
|
||||
$ref =& $arr;
|
||||
$ref = &$arr;
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($ref[$k]) || !is_array($ref[$k])) { $ref[$k] = []; }
|
||||
$ref =& $ref[$k];
|
||||
if (! isset($ref[$k]) || ! is_array($ref[$k])) {
|
||||
$ref[$k] = [];
|
||||
}
|
||||
$ref = &$ref[$k];
|
||||
}
|
||||
$ref = $value;
|
||||
}
|
||||
|
|
@ -374,7 +420,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
|||
$contractId = $acc['contract_id'] ?? null;
|
||||
$reference = $acc['reference'] ?? null;
|
||||
// If contract_id not provided, attempt to resolve by contract reference for the selected client
|
||||
if (!$contractId) {
|
||||
if (! $contractId) {
|
||||
$contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null);
|
||||
if ($clientId && $contractRef) {
|
||||
// 1) Search existing contract by reference for that client (across its client cases)
|
||||
|
|
@ -391,15 +437,15 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
|||
// Try strong identifiers first
|
||||
$personId = $this->findPersonIdByIdentifiers($mapped['person'] ?? []);
|
||||
// Create from provided person data if unresolved
|
||||
if (!$personId) {
|
||||
if (! $personId) {
|
||||
$personId = $this->findOrCreatePersonId($mapped['person'] ?? []);
|
||||
}
|
||||
// Last resort, create minimal
|
||||
if (!$personId) {
|
||||
if (! $personId) {
|
||||
$personId = $this->createMinimalPersonId();
|
||||
}
|
||||
// Use the selected client for this import to tie the case/contract
|
||||
if (!$clientId) {
|
||||
if (! $clientId) {
|
||||
return ['action' => 'skipped', 'message' => 'Client required to create contract'];
|
||||
}
|
||||
$resolvedClientId = $clientId;
|
||||
|
|
@ -410,8 +456,8 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
|||
'client_case_id' => $clientCaseId,
|
||||
'reference' => $contractRef,
|
||||
];
|
||||
foreach (['start_date','end_date','description','type_id'] as $k) {
|
||||
if (array_key_exists($k, $contractFields) && !is_null($contractFields[$k])) {
|
||||
foreach (['start_date', 'end_date', 'description', 'type_id'] as $k) {
|
||||
if (array_key_exists($k, $contractFields) && ! is_null($contractFields[$k])) {
|
||||
$newContractData[$k] = $contractFields[$k];
|
||||
}
|
||||
}
|
||||
|
|
@ -428,7 +474,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
|||
}
|
||||
}
|
||||
// Default account.reference to contract reference if missing
|
||||
if (!$reference) {
|
||||
if (! $reference) {
|
||||
$contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null);
|
||||
if ($contractRef) {
|
||||
$reference = $contractRef;
|
||||
|
|
@ -436,7 +482,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
|||
$mapped['account'] = $acc;
|
||||
}
|
||||
}
|
||||
if (!$contractId || !$reference) {
|
||||
if (! $contractId || ! $reference) {
|
||||
return ['action' => 'skipped', 'message' => 'Missing contract_id/reference'];
|
||||
}
|
||||
|
||||
|
|
@ -449,15 +495,25 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
|||
$applyInsert = [];
|
||||
$applyUpdate = [];
|
||||
foreach ($mappings as $map) {
|
||||
if (!$map->target_field) continue;
|
||||
if (! $map->target_field) {
|
||||
continue;
|
||||
}
|
||||
$parts = explode('.', $map->target_field);
|
||||
if ($parts[0] !== 'account') continue;
|
||||
if ($parts[0] !== 'account') {
|
||||
continue;
|
||||
}
|
||||
$field = $parts[1] ?? null;
|
||||
if (!$field) continue;
|
||||
if (! $field) {
|
||||
continue;
|
||||
}
|
||||
$value = $acc[$field] ?? null;
|
||||
$mode = $map->apply_mode ?? 'both';
|
||||
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $value; }
|
||||
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $value; }
|
||||
if (in_array($mode, ['insert', 'both'])) {
|
||||
$applyInsert[$field] = $value;
|
||||
}
|
||||
if (in_array($mode, ['update', 'both'])) {
|
||||
$applyUpdate[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
|
|
@ -465,25 +521,29 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
|
|||
return ['action' => 'skipped', 'message' => 'No fields marked for update'];
|
||||
}
|
||||
// Only update fields that are set; skip nulls to avoid wiping unintentionally
|
||||
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
if (empty($changes)) {
|
||||
return ['action' => 'skipped', 'message' => 'No non-null changes'];
|
||||
}
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
|
||||
// also include contract hints for downstream contact resolution
|
||||
return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId];
|
||||
} else {
|
||||
if (empty($applyInsert)) {
|
||||
return ['action' => 'skipped', 'message' => 'No fields marked for insert'];
|
||||
}
|
||||
$data = array_filter($applyInsert, fn($v) => !is_null($v));
|
||||
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
|
||||
$data['contract_id'] = $contractId;
|
||||
$data['reference'] = $reference;
|
||||
// ensure required defaults
|
||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAccountTypeId();
|
||||
if (!array_key_exists('active', $data)) { $data['active'] = 1; }
|
||||
if (! array_key_exists('active', $data)) {
|
||||
$data['active'] = 1;
|
||||
}
|
||||
$created = Account::create($data);
|
||||
|
||||
return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId];
|
||||
}
|
||||
}
|
||||
|
|
@ -493,13 +553,18 @@ private function findPersonIdByIdentifiers(array $p): ?int
|
|||
$tax = $p['tax_number'] ?? null;
|
||||
if ($tax) {
|
||||
$found = Person::where('tax_number', $tax)->first();
|
||||
if ($found) return $found->id;
|
||||
if ($found) {
|
||||
return $found->id;
|
||||
}
|
||||
}
|
||||
$ssn = $p['social_security_number'] ?? null;
|
||||
if ($ssn) {
|
||||
$found = Person::where('social_security_number', $ssn)->first();
|
||||
if ($found) return $found->id;
|
||||
if ($found) {
|
||||
return $found->id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -507,7 +572,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
|||
{
|
||||
$contractData = $mapped['contract'] ?? [];
|
||||
$reference = $contractData['reference'] ?? null;
|
||||
if (!$reference) {
|
||||
if (! $reference) {
|
||||
return ['action' => 'invalid', 'message' => 'Missing contract.reference'];
|
||||
}
|
||||
|
||||
|
|
@ -527,7 +592,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
|||
}
|
||||
|
||||
// If not found by client+reference and a specific client_case_id is provided, try that too
|
||||
if (!$existing && $clientCaseId) {
|
||||
if (! $existing && $clientCaseId) {
|
||||
$existing = Contract::query()
|
||||
->where('client_case_id', $clientCaseId)
|
||||
->where('reference', $reference)
|
||||
|
|
@ -535,17 +600,17 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
|||
}
|
||||
|
||||
// If we still need to insert, we must resolve clientCaseId, but avoid creating new person/case unless necessary
|
||||
if (!$existing && !$clientCaseId) {
|
||||
if (! $existing && ! $clientCaseId) {
|
||||
// Resolve by identifiers or provided person; do not use Client->person
|
||||
$personId = null;
|
||||
if (!empty($mapped['person'] ?? [])) {
|
||||
if (! empty($mapped['person'] ?? [])) {
|
||||
$personId = $this->findPersonIdByIdentifiers($mapped['person']);
|
||||
if (!$personId) {
|
||||
if (! $personId) {
|
||||
$personId = $this->findOrCreatePersonId($mapped['person']);
|
||||
}
|
||||
}
|
||||
// As a last resort, create a minimal person for this client
|
||||
if ($clientId && !$personId) {
|
||||
if ($clientId && ! $personId) {
|
||||
$personId = $this->createMinimalPersonId();
|
||||
}
|
||||
|
||||
|
|
@ -563,39 +628,51 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
|||
$applyInsert = [];
|
||||
$applyUpdate = [];
|
||||
foreach ($mappings as $map) {
|
||||
if (!$map->target_field) continue;
|
||||
if (! $map->target_field) {
|
||||
continue;
|
||||
}
|
||||
$parts = explode('.', $map->target_field);
|
||||
if ($parts[0] !== 'contract') continue;
|
||||
if ($parts[0] !== 'contract') {
|
||||
continue;
|
||||
}
|
||||
$field = $parts[1] ?? null;
|
||||
if (!$field) continue;
|
||||
if (! $field) {
|
||||
continue;
|
||||
}
|
||||
$value = $contractData[$field] ?? null;
|
||||
$mode = $map->apply_mode ?? 'both';
|
||||
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $value; }
|
||||
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $value; }
|
||||
if (in_array($mode, ['insert', 'both'])) {
|
||||
$applyInsert[$field] = $value;
|
||||
}
|
||||
if (in_array($mode, ['update', 'both'])) {
|
||||
$applyUpdate[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
if (empty($applyUpdate)) {
|
||||
return ['action' => 'skipped', 'message' => 'No contract fields marked for update'];
|
||||
}
|
||||
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
if (empty($changes)) {
|
||||
return ['action' => 'skipped', 'message' => 'No non-null contract changes'];
|
||||
}
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
|
||||
return ['action' => 'updated', 'contract' => $existing];
|
||||
} else {
|
||||
if (empty($applyInsert)) {
|
||||
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
|
||||
}
|
||||
$data = array_filter($applyInsert, fn($v) => !is_null($v));
|
||||
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
|
||||
$data['client_case_id'] = $clientCaseId;
|
||||
$data['reference'] = $reference;
|
||||
// ensure required defaults
|
||||
$data['start_date'] = $data['start_date'] ?? now()->toDateString();
|
||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultContractTypeId();
|
||||
$created = Contract::create($data);
|
||||
|
||||
return ['action' => 'inserted', 'contract' => $created];
|
||||
}
|
||||
}
|
||||
|
|
@ -604,33 +681,43 @@ private function findOrCreatePersonId(array $p): ?int
|
|||
{
|
||||
// Basic dedup: by tax_number, ssn, else full_name
|
||||
$query = Person::query();
|
||||
if (!empty($p['tax_number'] ?? null)) {
|
||||
if (! empty($p['tax_number'] ?? null)) {
|
||||
$found = $query->where('tax_number', $p['tax_number'])->first();
|
||||
if ($found) return $found->id;
|
||||
if ($found) {
|
||||
return $found->id;
|
||||
}
|
||||
}
|
||||
if (!empty($p['social_security_number'] ?? null)) {
|
||||
if (! empty($p['social_security_number'] ?? null)) {
|
||||
$found = Person::where('social_security_number', $p['social_security_number'])->first();
|
||||
if ($found) return $found->id;
|
||||
if ($found) {
|
||||
return $found->id;
|
||||
}
|
||||
}
|
||||
// Do NOT use full_name as an identifier
|
||||
// Create person if any fields present; ensure required foreign keys
|
||||
if (!empty($p)) {
|
||||
if (! empty($p)) {
|
||||
$data = [];
|
||||
foreach (['first_name','last_name','full_name','tax_number','social_security_number','birthday','gender','description','group_id','type_id'] as $k) {
|
||||
if (array_key_exists($k, $p)) $data[$k] = $p[$k];
|
||||
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id'] as $k) {
|
||||
if (array_key_exists($k, $p)) {
|
||||
$data[$k] = $p[$k];
|
||||
}
|
||||
}
|
||||
// derive full_name if missing
|
||||
if (empty($data['full_name'])) {
|
||||
$fn = trim((string)($data['first_name'] ?? ''));
|
||||
$ln = trim((string)($data['last_name'] ?? ''));
|
||||
if ($fn || $ln) $data['full_name'] = trim($fn.' '.$ln);
|
||||
$fn = trim((string) ($data['first_name'] ?? ''));
|
||||
$ln = trim((string) ($data['last_name'] ?? ''));
|
||||
if ($fn || $ln) {
|
||||
$data['full_name'] = trim($fn.' '.$ln);
|
||||
}
|
||||
}
|
||||
// ensure required group/type ids
|
||||
$data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId();
|
||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId();
|
||||
$created = Person::create($data);
|
||||
|
||||
return $created->id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -678,117 +765,182 @@ private function getDefaultPhoneTypeId(): int
|
|||
private function findOrCreateClientId(int $personId): int
|
||||
{
|
||||
$client = Client::where('person_id', $personId)->first();
|
||||
if ($client) return $client->id;
|
||||
if ($client) {
|
||||
return $client->id;
|
||||
}
|
||||
|
||||
return Client::create(['person_id' => $personId])->id;
|
||||
}
|
||||
|
||||
private function findOrCreateClientCaseId(int $clientId, int $personId): int
|
||||
{
|
||||
$cc = ClientCase::where('client_id', $clientId)->where('person_id', $personId)->first();
|
||||
if ($cc) return $cc->id;
|
||||
if ($cc) {
|
||||
return $cc->id;
|
||||
}
|
||||
|
||||
return ClientCase::create(['client_id' => $clientId, 'person_id' => $personId])->id;
|
||||
}
|
||||
|
||||
private function upsertEmail(int $personId, array $emailData, $mappings): array
|
||||
{
|
||||
$value = trim((string)($emailData['value'] ?? ''));
|
||||
if ($value === '') return ['action' => 'skipped', 'message' => 'No email value'];
|
||||
$value = trim((string) ($emailData['value'] ?? ''));
|
||||
if ($value === '') {
|
||||
return ['action' => 'skipped', 'message' => 'No email value'];
|
||||
}
|
||||
$existing = Email::where('person_id', $personId)->where('value', $value)->first();
|
||||
$applyInsert = [];
|
||||
$applyUpdate = [];
|
||||
foreach ($mappings as $map) {
|
||||
if (!$map->target_field) continue;
|
||||
if (! $map->target_field) {
|
||||
continue;
|
||||
}
|
||||
$parts = explode('.', $map->target_field);
|
||||
if ($parts[0] !== 'email') continue;
|
||||
$field = $parts[1] ?? null; if (!$field) continue;
|
||||
if ($parts[0] !== 'email') {
|
||||
continue;
|
||||
}
|
||||
$field = $parts[1] ?? null;
|
||||
if (! $field) {
|
||||
continue;
|
||||
}
|
||||
$val = $emailData[$field] ?? null;
|
||||
$mode = $map->apply_mode ?? 'both';
|
||||
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; }
|
||||
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; }
|
||||
if (in_array($mode, ['insert', 'both'])) {
|
||||
$applyInsert[$field] = $val;
|
||||
}
|
||||
if (in_array($mode, ['update', 'both'])) {
|
||||
$applyUpdate[$field] = $val;
|
||||
}
|
||||
}
|
||||
if ($existing) {
|
||||
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
|
||||
if (empty($changes)) return ['action' => 'skipped', 'message' => 'No email updates'];
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
if (empty($changes)) {
|
||||
return ['action' => 'skipped', 'message' => 'No email updates'];
|
||||
}
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
|
||||
return ['action' => 'updated', 'email' => $existing];
|
||||
} else {
|
||||
if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No email fields for insert'];
|
||||
$data = array_filter($applyInsert, fn($v) => !is_null($v));
|
||||
if (empty($applyInsert)) {
|
||||
return ['action' => 'skipped', 'message' => 'No email fields for insert'];
|
||||
}
|
||||
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
|
||||
$data['person_id'] = $personId;
|
||||
if (!array_key_exists('is_active', $data)) $data['is_active'] = true;
|
||||
if (! array_key_exists('is_active', $data)) {
|
||||
$data['is_active'] = true;
|
||||
}
|
||||
$created = Email::create($data);
|
||||
|
||||
return ['action' => 'inserted', 'email' => $created];
|
||||
}
|
||||
}
|
||||
|
||||
private function upsertAddress(int $personId, array $addrData, $mappings): array
|
||||
{
|
||||
$addressLine = trim((string)($addrData['address'] ?? ''));
|
||||
if ($addressLine === '') return ['action' => 'skipped', 'message' => 'No address value'];
|
||||
$addressLine = trim((string) ($addrData['address'] ?? ''));
|
||||
if ($addressLine === '') {
|
||||
return ['action' => 'skipped', 'message' => 'No address value'];
|
||||
}
|
||||
// Default country SLO if not provided
|
||||
if (!isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
|
||||
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
|
||||
$addrData['country'] = 'SLO';
|
||||
}
|
||||
$existing = PersonAddress::where('person_id', $personId)->where('address', $addressLine)->first();
|
||||
$applyInsert = [];
|
||||
$applyUpdate = [];
|
||||
foreach ($mappings as $map) {
|
||||
if (!$map->target_field) continue;
|
||||
if (! $map->target_field) {
|
||||
continue;
|
||||
}
|
||||
$parts = explode('.', $map->target_field);
|
||||
if ($parts[0] !== 'address') continue;
|
||||
$field = $parts[1] ?? null; if (!$field) continue;
|
||||
if ($parts[0] !== 'address') {
|
||||
continue;
|
||||
}
|
||||
$field = $parts[1] ?? null;
|
||||
if (! $field) {
|
||||
continue;
|
||||
}
|
||||
$val = $addrData[$field] ?? null;
|
||||
$mode = $map->apply_mode ?? 'both';
|
||||
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; }
|
||||
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; }
|
||||
if (in_array($mode, ['insert', 'both'])) {
|
||||
$applyInsert[$field] = $val;
|
||||
}
|
||||
if (in_array($mode, ['update', 'both'])) {
|
||||
$applyUpdate[$field] = $val;
|
||||
}
|
||||
}
|
||||
if ($existing) {
|
||||
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
|
||||
if (empty($changes)) return ['action' => 'skipped', 'message' => 'No address updates'];
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
if (empty($changes)) {
|
||||
return ['action' => 'skipped', 'message' => 'No address updates'];
|
||||
}
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
|
||||
return ['action' => 'updated', 'address' => $existing];
|
||||
} else {
|
||||
if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No address fields for insert'];
|
||||
$data = array_filter($applyInsert, fn($v) => !is_null($v));
|
||||
if (empty($applyInsert)) {
|
||||
return ['action' => 'skipped', 'message' => 'No address fields for insert'];
|
||||
}
|
||||
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
|
||||
$data['person_id'] = $personId;
|
||||
$data['country'] = $data['country'] ?? 'SLO';
|
||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
|
||||
$created = PersonAddress::create($data);
|
||||
|
||||
return ['action' => 'inserted', 'address' => $created];
|
||||
}
|
||||
}
|
||||
|
||||
private function upsertPhone(int $personId, array $phoneData, $mappings): array
|
||||
{
|
||||
$nu = trim((string)($phoneData['nu'] ?? ''));
|
||||
if ($nu === '') return ['action' => 'skipped', 'message' => 'No phone value'];
|
||||
$nu = trim((string) ($phoneData['nu'] ?? ''));
|
||||
if ($nu === '') {
|
||||
return ['action' => 'skipped', 'message' => 'No phone value'];
|
||||
}
|
||||
$existing = PersonPhone::where('person_id', $personId)->where('nu', $nu)->first();
|
||||
$applyInsert = [];
|
||||
$applyUpdate = [];
|
||||
foreach ($mappings as $map) {
|
||||
if (!$map->target_field) continue;
|
||||
if (! $map->target_field) {
|
||||
continue;
|
||||
}
|
||||
$parts = explode('.', $map->target_field);
|
||||
if ($parts[0] !== 'phone') continue;
|
||||
$field = $parts[1] ?? null; if (!$field) continue;
|
||||
if ($parts[0] !== 'phone') {
|
||||
continue;
|
||||
}
|
||||
$field = $parts[1] ?? null;
|
||||
if (! $field) {
|
||||
continue;
|
||||
}
|
||||
$val = $phoneData[$field] ?? null;
|
||||
$mode = $map->apply_mode ?? 'both';
|
||||
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; }
|
||||
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; }
|
||||
if (in_array($mode, ['insert', 'both'])) {
|
||||
$applyInsert[$field] = $val;
|
||||
}
|
||||
if (in_array($mode, ['update', 'both'])) {
|
||||
$applyUpdate[$field] = $val;
|
||||
}
|
||||
}
|
||||
if ($existing) {
|
||||
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
|
||||
if (empty($changes)) return ['action' => 'skipped', 'message' => 'No phone updates'];
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
if (empty($changes)) {
|
||||
return ['action' => 'skipped', 'message' => 'No phone updates'];
|
||||
}
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
|
||||
return ['action' => 'updated', 'phone' => $existing];
|
||||
} else {
|
||||
if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No phone fields for insert'];
|
||||
$data = array_filter($applyInsert, fn($v) => !is_null($v));
|
||||
if (empty($applyInsert)) {
|
||||
return ['action' => 'skipped', 'message' => 'No phone fields for insert'];
|
||||
}
|
||||
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
|
||||
$data['person_id'] = $personId;
|
||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPhoneTypeId();
|
||||
$created = PersonPhone::create($data);
|
||||
|
||||
return ['action' => 'inserted', 'phone' => $created];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
composer.lock
generated
2
composer.lock
generated
|
|
@ -10335,6 +10335,6 @@
|
|||
"platform": {
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Person\Person;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
|
|
@ -17,7 +19,8 @@ class ClientCaseFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
'client_id' => Client::factory(),
|
||||
'person_id' => Person::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Person\Person;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
|
|
@ -17,7 +18,8 @@ class ClientFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
'person_id' => Person::factory(),
|
||||
'active' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\ContractType;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
|
|
@ -17,7 +19,12 @@ class ContractFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
'reference' => $this->faker->optional()->bothify('REF-####'),
|
||||
'start_date' => $this->faker->date(),
|
||||
'end_date' => $this->faker->optional()->date(),
|
||||
'client_case_id' => ClientCase::factory(),
|
||||
'type_id' => ContractType::factory(),
|
||||
'description' => $this->faker->optional()->sentence(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
database/factories/ContractTypeFactory.php
Normal file
19
database/factories/ContractTypeFactory.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ContractType>
|
||||
*/
|
||||
class ContractTypeFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->unique()->word(),
|
||||
'description' => $this->faker->optional()->sentence(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,8 @@ class DecisionFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
'name' => $this->faker->unique()->words(2, true),
|
||||
'color_tag' => $this->faker->optional()->safeColorName(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace Database\Factories\Person;
|
||||
|
||||
use App\Models\Person\PersonGroup;
|
||||
use App\Models\Person\PersonType;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
|
|
@ -19,13 +21,14 @@ public function definition(): array
|
|||
return [
|
||||
'first_name' => $this->faker->firstName(),
|
||||
'last_name' => $this->faker->lastName(),
|
||||
'full_name' => fn(array $attrs) => trim(($attrs['first_name'] ?? '').' '.($attrs['last_name'] ?? '')),
|
||||
'gender' => $this->faker->randomElement(['m','w']),
|
||||
'full_name' => fn (array $attrs) => trim(($attrs['first_name'] ?? '').' '.($attrs['last_name'] ?? '')),
|
||||
'gender' => $this->faker->randomElement(['m', 'w']),
|
||||
'birthday' => $this->faker->optional()->date(),
|
||||
'tax_number' => $this->faker->optional()->bothify('########'),
|
||||
'social_security_number' => $this->faker->optional()->bothify('#########'),
|
||||
'description' => $this->faker->optional()->sentence(),
|
||||
// group_id/type_id are required; keep null here and let tests/seeds assign or rely on defaults in code paths that use factories
|
||||
'group_id' => PersonGroup::factory(),
|
||||
'type_id' => PersonType::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,15 +9,12 @@
|
|||
*/
|
||||
class PersonGroupFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
'name' => $this->faker->unique()->word(),
|
||||
'description' => $this->faker->optional()->sentence(),
|
||||
'color_tag' => $this->faker->optional()->safeColorName(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ class PersonTypeFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
'name' => $this->faker->unique()->word(),
|
||||
'description' => $this->faker->optional()->sentence(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ class SegmentFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
'name' => $this->faker->unique()->words(2, true),
|
||||
'description' => $this->faker->sentence(),
|
||||
'active' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,76 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// PostgreSQL: drop NOT NULL constraint on description
|
||||
DB::statement('ALTER TABLE segments ALTER COLUMN description DROP NOT NULL');
|
||||
$driver = DB::connection()->getDriverName();
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
// PostgreSQL: drop NOT NULL constraint on description
|
||||
DB::statement('ALTER TABLE segments ALTER COLUMN description DROP NOT NULL');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
// MySQL / MariaDB
|
||||
DB::statement('ALTER TABLE segments MODIFY description VARCHAR(255) NULL');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// SQLite or other drivers: avoid brittle raw SQL. If Doctrine DBAL isn't installed,
|
||||
// changing a column may not be supported. Since this is only relaxing NOT NULL,
|
||||
// we can safely no-op for SQLite tests.
|
||||
if ($driver === 'sqlite') {
|
||||
return; // no-op for tests
|
||||
}
|
||||
|
||||
// Fallback attempt using Schema (requires doctrine/dbal; if unavailable, it will be ignored in tests)
|
||||
Schema::table('segments', function (Blueprint $table): void {
|
||||
try {
|
||||
$table->string('description', 255)->nullable()->change();
|
||||
} catch (\Throwable $e) {
|
||||
// ignore if not supported in current driver
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$driver = DB::connection()->getDriverName();
|
||||
|
||||
// Ensure no NULLs before setting NOT NULL
|
||||
DB::statement("UPDATE segments SET description = '' WHERE description IS NULL");
|
||||
DB::statement('ALTER TABLE segments ALTER COLUMN description SET NOT NULL');
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
DB::statement('ALTER TABLE segments ALTER COLUMN description SET NOT NULL');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
DB::statement('ALTER TABLE segments MODIFY description VARCHAR(255) NOT NULL');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
return; // no-op for tests
|
||||
}
|
||||
|
||||
Schema::table('segments', function (Blueprint $table): void {
|
||||
try {
|
||||
$table->string('description', 255)->nullable(false)->change();
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('field_job_settings', function (Blueprint $table) {
|
||||
$table->foreignId('cancel_decision_id')
|
||||
->nullable()
|
||||
->after('complete_decision_id')
|
||||
->constrained('decisions')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('field_job_settings', function (Blueprint $table) {
|
||||
$table->dropForeign(['cancel_decision_id']);
|
||||
$table->dropColumn('cancel_decision_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('field_job_settings', function (Blueprint $table): void {
|
||||
$table->foreignId('return_segment_id')
|
||||
->nullable()
|
||||
->constrained('segments')
|
||||
->nullOnDelete()
|
||||
->after('cancel_decision_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('field_job_settings', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('return_segment_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Rename columns with typos: asign -> assign, asigned -> assigned
|
||||
DB::statement('ALTER TABLE field_job_settings RENAME COLUMN asign_decision_id TO assign_decision_id');
|
||||
DB::statement('ALTER TABLE field_jobs RENAME COLUMN asigned_user_id TO assigned_user_id');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE field_job_settings RENAME COLUMN assign_decision_id TO asign_decision_id');
|
||||
DB::statement('ALTER TABLE field_jobs RENAME COLUMN assigned_user_id TO asigned_user_id');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('field_job_settings', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('field_job_settings', 'queue_segment_id')) {
|
||||
$table->foreignId('queue_segment_id')->nullable()->constrained('segments')->nullOnDelete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('field_job_settings', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('field_job_settings', 'queue_segment_id')) {
|
||||
$table->dropConstrainedForeignId('queue_segment_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -3,10 +3,9 @@
|
|||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Person\PersonType;
|
||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
|
|
@ -17,11 +16,15 @@ public function run(): void
|
|||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => Hash::make("password")
|
||||
]);
|
||||
// Ensure a default test user exists (idempotent)
|
||||
\App\Models\User::query()->updateOrCreate(
|
||||
['email' => 'test@example.com'],
|
||||
[
|
||||
'name' => 'Test User',
|
||||
'password' => Hash::make('password'),
|
||||
'email_verified_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
$this->call([
|
||||
PersonSeeder::class,
|
||||
|
|
@ -29,6 +32,7 @@ public function run(): void
|
|||
ActionSeeder::class,
|
||||
EventSeeder::class,
|
||||
ImportTemplateSeeder::class,
|
||||
TestUserSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
database/seeders/TestUserSeeder.php
Normal file
37
database/seeders/TestUserSeeder.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class TestUserSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$email = 'field.tester@example.com';
|
||||
$password = 'password123';
|
||||
$name = 'Field Tester';
|
||||
|
||||
// Create or update a predictable test user you can use to log in.
|
||||
$user = User::query()->firstOrCreate(
|
||||
['email' => $email],
|
||||
[
|
||||
'name' => $name,
|
||||
// Will be auto-hashed by the User model cast.
|
||||
'password' => $password,
|
||||
]
|
||||
);
|
||||
|
||||
if (! $user->wasRecentlyCreated) {
|
||||
$user->name = $name;
|
||||
$user->password = $password; // auto-hashed by cast
|
||||
}
|
||||
|
||||
if ($user->email_verified_at === null) {
|
||||
$user->email_verified_at = now();
|
||||
}
|
||||
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,4 +3,4 @@ REF-1001,John,Doe,"123 Maple St, Springfield",+1 555-0101,john.doe@example.com,2
|
|||
REF-1002,Jane,Smith,"456 Oak Ave, Metropolis",+44 20 7946 0958,jane.smith@example.co.uk,2025-09-05,2025-10-05,320.00
|
||||
REF-1003,Carlos,García,"Calle 12 #34, Madrid",+34 91 123 4567,carlos.garcia@example.es,2025-09-10,2025-10-10,78.99
|
||||
REF-1004,Anna,Müller,"Hauptstrasse 5, Berlin",+49 30 123456,anna.mueller@example.de,2025-09-12,2025-10-12,980.50
|
||||
REF-1005,Luka,Novak,"Ilica 10, Zagreb",+385 1 2345 678,luka.novak@example.hr,2025-09-15,2025-10-15,45.00
|
||||
REF-1005,Luka,Novak,"Ilica 10, Zagreb",+385 1 2345 678,luka.novak@example.hr,2025-09-15,2025-10-15,46.30
|
||||
|
|
|
|||
|
|
|
@ -16,6 +16,11 @@ const props = defineProps({
|
|||
description: String,
|
||||
header: Array,
|
||||
body: Array,
|
||||
// Make table header sticky while body scrolls
|
||||
stickyHeader: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
editor: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
|
|
@ -115,12 +120,16 @@ const remove = () => {
|
|||
<p v-if="description" class="mt-1 text-sm text-gray-600">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div :class="['relative rounded-lg border border-gray-200 bg-white shadow-sm overflow-x-auto overflow-y-auto', bodyMaxHeight]">
|
||||
<div :class="['relative rounded-lg border border-gray-200 bg-white shadow-sm overflow-x-auto overflow-y-auto', bodyMaxHeight, stickyHeader ? 'table-sticky' : '']">
|
||||
<FwbTable hoverable striped class="text-sm">
|
||||
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur supports-[backdrop-filter]:bg-gray-50/80 border-b border-gray-200 shadow-sm">
|
||||
<FwbTableHeadCell v-for="(h, hIndex) in header" :key="hIndex" class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6">{{ h.data }}</FwbTableHeadCell>
|
||||
<FwbTableHeadCell v-if="editor" class="w-px text-gray-700 py-3"></FwbTableHeadCell>
|
||||
<FwbTableHeadCell v-else class="w-px text-gray-700 py-3" />
|
||||
<FwbTableHeadCell
|
||||
v-for="(h, hIndex) in header"
|
||||
:key="hIndex"
|
||||
class="sticky top-0 z-10 uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6 bg-gray-50/90"
|
||||
>{{ h.data }}</FwbTableHeadCell>
|
||||
<FwbTableHeadCell v-if="editor" class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90"></FwbTableHeadCell>
|
||||
<FwbTableHeadCell v-else class="sticky top-0 z-10 w-px text-gray-700 py-3 bg-gray-50/90" />
|
||||
</FwbTableHead>
|
||||
<FwbTableBody>
|
||||
<FwbTableRow v-for="(row, key, parent_index) in body" :key="key" :class="row.options.class">
|
||||
|
|
@ -208,4 +217,27 @@ const remove = () => {
|
|||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Ensure sticky header remains above scrollable body inside wrapper */
|
||||
:deep(.table-sticky thead) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:deep(.table-sticky thead th) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: rgba(249, 250, 251, 0.9); /* gray-50/90 */
|
||||
backdrop-filter: saturate(180%) blur(5px);
|
||||
}
|
||||
|
||||
/* Maintain column widths alignment when scrollbar appears */
|
||||
.table-sticky {
|
||||
/* Make sure the header and body share the same scroll container */
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -11,6 +11,9 @@ import { ref, watch } from 'vue'
|
|||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
postUrl: { type: String, required: true },
|
||||
// Optional list of contracts to allow attaching the document directly to a contract
|
||||
// Each item should have at least: { uuid, reference }
|
||||
contracts: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['close', 'uploaded'])
|
||||
|
||||
|
|
@ -21,7 +24,8 @@ const form = useForm({
|
|||
name: '',
|
||||
description: '',
|
||||
file: null,
|
||||
is_public: false,
|
||||
is_public: true,
|
||||
contract_uuid: null,
|
||||
})
|
||||
const localError = ref('')
|
||||
|
||||
|
|
@ -86,6 +90,13 @@ const close = () => emit('close')
|
|||
<template #title>Dodaj dokument</template>
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<div v-if="props.contracts && props.contracts.length" class="grid grid-cols-1 gap-2">
|
||||
<InputLabel for="doc_attach" value="Pripiši k" />
|
||||
<select id="doc_attach" v-model="form.contract_uuid" class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option :value="null">Primer</option>
|
||||
<option v-for="c in props.contracts" :key="c.uuid" :value="c.uuid">Pogodba: {{ c.reference }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="doc_name" value="Name" />
|
||||
<TextInput id="doc_name" class="mt-1 block w-full" v-model="form.name" />
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ const props = defineProps({
|
|||
// Optional: build a direct download URL for a document; if not provided, a 'download' event will be emitted
|
||||
downloadUrlBuilder: { type: Function, default: null },
|
||||
})
|
||||
// Derive a human-friendly source for a document: Case or Contract reference
|
||||
const sourceLabel = (doc) => {
|
||||
// Server can include optional documentable meta; fall back to type
|
||||
if (doc.documentable_type?.toLowerCase?.().includes('contract')) {
|
||||
return doc.contract_reference ? `Pogodba ${doc.contract_reference}` : 'Pogodba'
|
||||
}
|
||||
return 'Primer'
|
||||
}
|
||||
|
||||
const emit = defineEmits(['view', 'download'])
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
|
|
@ -110,11 +119,11 @@ const handleDownload = (doc) => {
|
|||
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<FwbTable hoverable striped class="text-sm">
|
||||
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm">
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Name</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Type</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Size</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Added</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Drugo</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Naziv</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Vrsta</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Velikost</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Dodano</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Vir</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="w-px" />
|
||||
</FwbTableHead>
|
||||
<FwbTableBody>
|
||||
|
|
@ -122,7 +131,7 @@ const handleDownload = (doc) => {
|
|||
<FwbTableRow>
|
||||
<FwbTableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="text-indigo-600 hover:underline" @click="$emit('view', doc)">{{ doc.original_name || doc.name }}</button>
|
||||
<button type="button" class="text-indigo-600 hover:underline" @click="$emit('view', doc)">{{ doc.name }}</button>
|
||||
<FwbBadge v-if="doc.is_public" type="green">Public</FwbBadge>
|
||||
</div>
|
||||
</FwbTableCell>
|
||||
|
|
@ -134,6 +143,9 @@ const handleDownload = (doc) => {
|
|||
</FwbTableCell>
|
||||
<FwbTableCell>{{ formatSize(doc.size) }}</FwbTableCell>
|
||||
<FwbTableCell>{{ new Date(doc.created_at).toLocaleString() }}</FwbTableCell>
|
||||
<FwbTableCell>
|
||||
<FwbBadge type="purple">{{ sourceLabel(doc) }}</FwbBadge>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell class="text-center">
|
||||
<button
|
||||
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
|
|
|
|||
143
resources/js/Components/PersonDetailPhone.vue
Normal file
143
resources/js/Components/PersonDetailPhone.vue
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
person: { type: Object, required: true },
|
||||
types: { type: Object, default: () => ({}) },
|
||||
// Allow overriding the default active tab: 'addresses' | 'phones' | 'emails' | 'bank'
|
||||
defaultTab: { type: String, default: 'addresses' },
|
||||
})
|
||||
|
||||
const phoneTypes = computed(() => {
|
||||
const arr = props.types?.phone_types || []
|
||||
const map = {}
|
||||
for (const t of arr) { map[t.id] = t.name }
|
||||
return map
|
||||
})
|
||||
|
||||
const displayName = computed(() => {
|
||||
const p = props.person || {}
|
||||
const full = p.full_name?.trim()
|
||||
if (full) { return full }
|
||||
const first = p.first_name?.trim() || ''
|
||||
const last = p.last_name?.trim() || ''
|
||||
return `${first} ${last}`.trim()
|
||||
})
|
||||
|
||||
const primaryAddress = computed(() => props.person?.addresses?.[0] || null)
|
||||
const primaryEmail = computed(() => props.person?.emails?.[0]?.value || null)
|
||||
// Backend phone model uses `nu` as the number
|
||||
const allPhones = computed(() => props.person?.phones || [])
|
||||
const allAddresses = computed(() => props.person?.addresses || [])
|
||||
const allEmails = computed(() => props.person?.emails || [])
|
||||
// Laravel serializes relation names to snake_case, so prefer bank_accounts, fallback to bankAccounts
|
||||
const allBankAccounts = computed(() => props.person?.bank_accounts || props.person?.bankAccounts || [])
|
||||
const bankIban = computed(() => allBankAccounts.value?.[0]?.iban || null)
|
||||
const taxNumber = computed(() => props.person?.tax_number || null)
|
||||
const ssn = computed(() => props.person?.social_security_number || null)
|
||||
|
||||
// Summary sizing
|
||||
const showMore = ref(false)
|
||||
const summaryPhones = computed(() => allPhones.value.slice(0, showMore.value ? 2 : 1))
|
||||
|
||||
// Tabs
|
||||
const activeTab = ref(props.defaultTab || 'addresses')
|
||||
watch(() => props.defaultTab, (val) => { if (val) activeTab.value = val })
|
||||
|
||||
function maskIban(iban) {
|
||||
if (!iban || typeof iban !== 'string') return null
|
||||
const clean = iban.replace(/\s+/g, '')
|
||||
if (clean.length <= 8) return clean
|
||||
return `${clean.slice(0, 4)} **** **** ${clean.slice(-4)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-sm">
|
||||
<div v-if="displayName" class="font-medium text-gray-900">{{ displayName }}</div>
|
||||
|
||||
<div v-if="primaryAddress" class="mt-1 text-gray-700">
|
||||
<span>{{ primaryAddress.address }}</span>
|
||||
<span v-if="primaryAddress.country" class="text-gray-500 text-xs ml-1">({{ primaryAddress.country }})</span>
|
||||
</div>
|
||||
|
||||
<div v-if="summaryPhones?.length" class="mt-1 space-y-0.5">
|
||||
<div v-for="p in summaryPhones" :key="p.id" class="text-gray-700">
|
||||
<span>{{ p.nu }}</span>
|
||||
<span v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name" class="text-gray-500 text-xs ml-1">({{ p.type?.name || phoneTypes[p.type_id] }})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showMore && primaryEmail" class="mt-1 text-gray-700">{{ primaryEmail }}</div>
|
||||
|
||||
<div v-if="showMore && bankIban" class="mt-1 text-gray-700">TRR: <span class="font-mono">{{ maskIban(bankIban) }}</span></div>
|
||||
|
||||
<div v-if="showMore && taxNumber" class="mt-1 text-gray-700">Davčna: <span class="font-mono">{{ taxNumber }}</span></div>
|
||||
<div v-if="showMore && ssn" class="mt-1 text-gray-700">EMŠO: <span class="font-mono">{{ ssn }}</span></div>
|
||||
|
||||
<button type="button" class="mt-2 text-xs text-blue-600 hover:underline" @click="showMore = !showMore">
|
||||
{{ showMore ? 'Skrij' : 'Prikaži več' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mt-3">
|
||||
<div class="flex gap-2 overflow-x-auto">
|
||||
<button type="button" @click="activeTab = 'addresses'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='addresses' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a1 1 0 0 1 .832.445l6 8.5a1 1 0 0 1 .168.555V17a1 1 0 0 1-1 1h-4v-4H8v4H4a1 1 0 0 1-1-1v-5.5a1 1 0 0 1 .168-.555l6-8.5A1 1 0 0 1 10 2Z"/></svg>
|
||||
Naslovi ({{ allAddresses.length }})
|
||||
</button>
|
||||
<button type="button" @click="activeTab = 'phones'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='phones' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M2.3 3.3c.6-1 1.9-1.3 2.9-.7l1.7 1a2 2 0 0 1 .9 2.5l-.5 1.2a2 2 0 0 0 .4 2.2l2.8 2.8a2 2 0 0 0 2.2.4l1.2-.5a2 2 0 0 1 2.5.9l1 1.7c.6 1 .3 2.3-.7 2.9-2 1.1-4.5 1.1-6.5 0-2.5-1.3-4.8-3.6-6.1-6.1-1.1-2-1.1-4.5 0-6.5Z"/></svg>
|
||||
Telefoni ({{ allPhones.length }})
|
||||
</button>
|
||||
<button type="button" @click="activeTab = 'emails'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='emails' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M2.5 5A1.5 1.5 0 0 1 4 3.5h12A1.5 1.5 0 0 1 17.5 5v10A1.5 1.5 0 0 1 16 16.5H4A1.5 1.5 0 0 1 2.5 15V5Zm2.1.5 5.4 3.6a1 1 0 0 0 1.1 0l5.4-3.6V5H4.6Z"/></svg>
|
||||
E-pošta ({{ allEmails.length }})
|
||||
</button>
|
||||
<button type="button" @click="activeTab = 'bank'" :class="['px-3 py-1 rounded text-xs inline-flex items-center gap-1', activeTab==='bank' ? 'bg-gray-200 text-gray-900' : 'bg-white text-gray-700 border']">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M10 2 2 6v2h16V6l-8-4Zm-6 7h12v7H4V9Zm-1 8h14v1H3v-1Z"/></svg>
|
||||
TRR ({{ allBankAccounts.length }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<!-- Addresses -->
|
||||
<div v-if="activeTab==='addresses'">
|
||||
<div v-if="!allAddresses.length" class="text-gray-500 text-xs">Ni naslovov.</div>
|
||||
<div v-for="(a,idx) in allAddresses" :key="a.id || idx" class="py-1">
|
||||
<div class="text-gray-800">{{ a.address }}</div>
|
||||
<div v-if="a.country" class="text-gray-600 text-xs">{{ a.country }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phones -->
|
||||
<div v-else-if="activeTab==='phones'">
|
||||
<div v-if="!allPhones.length" class="text-gray-500 text-xs">Ni telefonov.</div>
|
||||
<div v-for="(p,idx) in allPhones" :key="p.id || idx" class="py-1">
|
||||
<div class="text-gray-800">{{ p.nu }} <span v-if="(p.type_id && phoneTypes[p.type_id]) || p.type?.name" class="text-gray-500 text-xs">({{ p.type?.name || phoneTypes[p.type_id] }})</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emails -->
|
||||
<div v-else-if="activeTab==='emails'">
|
||||
<div v-if="!allEmails.length" class="text-gray-500 text-xs">Ni e-poštnih naslovov.</div>
|
||||
<div v-for="(e,idx) in allEmails" :key="e.id || idx" class="py-1">
|
||||
<div class="text-gray-800">{{ e.value }}<span v-if="e.label" class="text-gray-500 text-xs ml-1">({{ e.label }})</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bank accounts -->
|
||||
<div v-else>
|
||||
<div v-if="!allBankAccounts.length" class="text-gray-500 text-xs">Ni TRR računov.</div>
|
||||
<div v-for="(b,idx) in allBankAccounts" :key="b.id || idx" class="py-1">
|
||||
<div class="text-gray-800">{{ maskIban(b.iban) }}</div>
|
||||
<div v-if="b.bank_name" class="text-gray-600 text-xs">{{ b.bank_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
|
@ -7,6 +7,8 @@ import Dropdown from '@/Components/Dropdown.vue';
|
|||
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||
import Breadcrumbs from '@/Components/Breadcrumbs.vue';
|
||||
import GlobalSearch from './Partials/GlobalSearch.vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faMobileScreenButton } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
|
|
@ -184,6 +186,15 @@ watch(
|
|||
<span v-if="!sidebarCollapsed">Nova uvozna predloga</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link :href="route('fieldjobs.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('fieldjobs.index') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Terenske naloge">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.5-7.5 10.5-7.5 10.5S4.5 18 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed">Terenske naloge</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link :href="route('settings')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('settings') || route().current('settings.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nastavitve">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
||||
|
|
@ -222,8 +233,12 @@ watch(
|
|||
<kbd class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border bg-gray-50">Ctrl K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- User drop menu --->
|
||||
<div class="flex items-center">
|
||||
<!-- Phone page quick access button -->
|
||||
<Link :href="route('phone.index')" class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 mr-2" title="Phone">
|
||||
<FontAwesomeIcon :icon="faMobileScreenButton" class="h-5 w-5" />
|
||||
</Link>
|
||||
<div class="ms-3 relative">
|
||||
<Dropdown align="right" width="48">
|
||||
<template #trigger>
|
||||
|
|
|
|||
233
resources/js/Layouts/AppPhoneLayout.vue
Normal file
233
resources/js/Layouts/AppPhoneLayout.vue
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, watch, computed } from 'vue';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/vue3';
|
||||
import ApplicationMark from '@/Components/ApplicationMark.vue';
|
||||
import Banner from '@/Components/Banner.vue';
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||
import GlobalSearch from './Partials/GlobalSearch.vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { faDesktop } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
});
|
||||
|
||||
// Sidebar + responsive behavior (same feel as AppLayout)
|
||||
const sidebarCollapsed = ref(false);
|
||||
const hasSavedSidebarPref = ref(false);
|
||||
const isMobile = ref(false);
|
||||
const mobileSidebarOpen = ref(false);
|
||||
function applyAutoCollapse() {
|
||||
if (typeof window === 'undefined') return;
|
||||
isMobile.value = window.innerWidth < 1024;
|
||||
sidebarCollapsed.value = isMobile.value;
|
||||
}
|
||||
function handleResize() {
|
||||
if (typeof window !== 'undefined') {
|
||||
isMobile.value = window.innerWidth < 1024;
|
||||
if (!isMobile.value) mobileSidebarOpen.value = false;
|
||||
}
|
||||
if (!hasSavedSidebarPref.value) applyAutoCollapse();
|
||||
}
|
||||
onMounted(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('sidebarCollapsed');
|
||||
if (saved !== null) {
|
||||
hasSavedSidebarPref.value = true;
|
||||
sidebarCollapsed.value = saved === '1';
|
||||
} else {
|
||||
applyAutoCollapse();
|
||||
}
|
||||
} catch {}
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
onUnmounted(() => window.removeEventListener('resize', handleResize));
|
||||
watch(sidebarCollapsed, (v) => {
|
||||
if (!hasSavedSidebarPref.value) return;
|
||||
try { localStorage.setItem('sidebarCollapsed', v ? '1' : '0'); } catch {}
|
||||
});
|
||||
|
||||
function toggleSidebar() {
|
||||
hasSavedSidebarPref.value = true;
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
}
|
||||
function toggleMobileSidebar() {
|
||||
mobileSidebarOpen.value = !mobileSidebarOpen.value;
|
||||
}
|
||||
function handleSidebarToggleClick() {
|
||||
if (isMobile.value) toggleMobileSidebar();
|
||||
else toggleSidebar();
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
router.post(route('logout'));
|
||||
};
|
||||
|
||||
// Flash toast notifications (same as AppLayout for consistency)
|
||||
const page = usePage();
|
||||
const flash = computed(() => page.props.flash || {});
|
||||
const showToast = ref(false);
|
||||
const toastMessage = ref('');
|
||||
const toastType = ref('success');
|
||||
watch(
|
||||
() => [flash.value.success, flash.value.error, flash.value.warning, flash.value.info],
|
||||
([s, e, w, i]) => {
|
||||
const message = s || e || w || i;
|
||||
const type = s ? 'success' : e ? 'error' : w ? 'warning' : i ? 'info' : null;
|
||||
if (message && type) {
|
||||
toastMessage.value = message;
|
||||
toastType.value = type;
|
||||
showToast.value = true;
|
||||
setTimeout(() => (showToast.value = false), 3000);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Global search modal state
|
||||
const searchOpen = ref(false);
|
||||
const openSearch = () => (searchOpen.value = true);
|
||||
const closeSearch = () => (searchOpen.value = false);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Head :title="title" />
|
||||
|
||||
<Banner />
|
||||
|
||||
<div class="min-h-screen bg-gray-100 flex">
|
||||
<!-- Mobile backdrop -->
|
||||
<div v-if="isMobile && mobileSidebarOpen" class="fixed inset-0 z-40 bg-black/30" @click="mobileSidebarOpen=false"></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside :class="[
|
||||
sidebarCollapsed ? 'w-16' : 'w-64',
|
||||
'bg-white border-r border-gray-200 transition-all duration-200 z-50',
|
||||
isMobile
|
||||
? ('fixed inset-y-0 left-0 transform ' + (mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'))
|
||||
: 'sticky top-0 h-screen overflow-y-auto'
|
||||
]">
|
||||
<div class="h-16 px-4 flex items-center justify-between border-b">
|
||||
<Link :href="route('phone.index')" class="flex items-center gap-2">
|
||||
<ApplicationMark class="h-8 w-auto" />
|
||||
<span v-if="!sidebarCollapsed" class="text-sm font-semibold">Teren</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav class="py-4">
|
||||
<ul class="space-y-1">
|
||||
<!-- Single phone link only -->
|
||||
<li>
|
||||
<Link :href="route('phone.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('phone.index') || route().current('phone.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Opravila">
|
||||
<!-- clipboard-list icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75H7.5A2.25 2.25 0 005.25 9v9A2.25 2.25 0 007.5 20.25h9A2.25 2.25 0 0018.75 18v-9A2.25 2.25 0 0016.5 6.75H15M9 6.75A1.5 1.5 0 0010.5 5.25h3A1.5 1.5 0 0015 6.75M9 6.75A1.5 1.5 0 0110.5 8.25h3A1.5 1.5 0 0015 6.75M9 12h.008v.008H9V12zm0 3h.008v.008H9V15zm3-3h3m-3 3h3" />
|
||||
</svg>
|
||||
<span v-if="!sidebarCollapsed">Opravila</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main column -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Top bar -->
|
||||
<div class="h-16 bg-white border-b border-gray-100 px-4 flex items-center justify-between sticky top-0 z-30">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Sidebar toggle -->
|
||||
<button
|
||||
@click="handleSidebarToggleClick()"
|
||||
class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
:title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Search trigger -->
|
||||
<button @click="openSearch" class="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-md border border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Globalni iskalnik</span>
|
||||
<kbd class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border bg-gray-50">Ctrl K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<!-- User drop menu + Desktop switch button -->
|
||||
<div class="flex items-center">
|
||||
<!-- Desktop page quick access button -->
|
||||
<Link :href="route('clientCase')" class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 mr-2" title="Desktop">
|
||||
<FontAwesomeIcon :icon="faDesktop" class="h-5 w-5" />
|
||||
</Link>
|
||||
<div class="ms-3 relative">
|
||||
<Dropdown align="right" width="48">
|
||||
<template #trigger>
|
||||
<button v-if="$page.props.jetstream.managesProfilePhotos" class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition">
|
||||
<img class="h-8 w-8 rounded-full object-cover" :src="$page.props.auth.user.profile_photo_url" :alt="$page.props.auth.user.name">
|
||||
</button>
|
||||
|
||||
<span v-else class="inline-flex rounded-md">
|
||||
<button type="button" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150">
|
||||
{{ $page.props.auth.user.name }}
|
||||
<svg class="ms-2 -me-0.5 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="block px-4 py-2 text-xs text-gray-400">Nastavitve računa</div>
|
||||
|
||||
<DropdownLink :href="route('profile.show')">Profil</DropdownLink>
|
||||
<DropdownLink v-if="$page.props.jetstream.hasApiFeatures" :href="route('api-tokens.index')">API Tokens</DropdownLink>
|
||||
|
||||
<div class="border-t border-gray-200" />
|
||||
|
||||
<form @submit.prevent="logout">
|
||||
<DropdownLink as="button">Izpis</DropdownLink>
|
||||
</form>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page Heading -->
|
||||
<header v-if="$slots.header" class="bg-white border-b shadow-sm">
|
||||
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main class="p-4">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Search Modal -->
|
||||
<GlobalSearch :open="searchOpen" @update:open="(v)=>searchOpen=v" />
|
||||
|
||||
<!-- Simple Toast -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="showToast"
|
||||
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-white"
|
||||
:class="{
|
||||
'bg-emerald-600': toastType==='success',
|
||||
'bg-red-600': toastType==='error',
|
||||
'bg-amber-500': toastType==='warning',
|
||||
'bg-blue-600': toastType==='info',
|
||||
}"
|
||||
>
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -16,7 +16,8 @@ let header = [
|
|||
C_TD.make('Odločitev', 'header'),
|
||||
C_TD.make('Opomba', 'header'),
|
||||
C_TD.make('Datum zapadlosti', 'header'),
|
||||
C_TD.make('Znesek obljube', 'header')
|
||||
C_TD.make('Znesek obljube', 'header'),
|
||||
C_TD.make('Dodal', 'header')
|
||||
];
|
||||
|
||||
const createBody = (data) => {
|
||||
|
|
@ -25,6 +26,7 @@ const createBody = (data) => {
|
|||
data.forEach((p) => {
|
||||
const createdDate = new Date(p.created_at).toLocaleDateString('de');
|
||||
const dueDate = (p.due_date) ? new Date().toLocaleDateString('de') : null;
|
||||
const userName = (p.user && p.user.name) ? p.user.name : (p.user_name || '');
|
||||
|
||||
const cols = [
|
||||
C_TD.make(p.contract?.reference ?? ''),
|
||||
|
|
@ -33,7 +35,8 @@ const createBody = (data) => {
|
|||
C_TD.make(p.decision.name, 'body'),
|
||||
C_TD.make(p.note, 'body' ),
|
||||
C_TD.make(dueDate, 'body' ),
|
||||
C_TD.make(Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(p.amount), 'body' )
|
||||
C_TD.make(Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(p.amount), 'body' ),
|
||||
C_TD.make(userName, 'body')
|
||||
];
|
||||
|
||||
body.push(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import { useForm } from '@inertiajs/vue3'
|
|||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
client_case: { type: Object, required: true },
|
||||
contract: { type: Object, required: true },
|
||||
// Contract can initially be null while dialog is hidden; make it optional to avoid prop warning
|
||||
contract: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'created'])
|
||||
|
|
@ -23,6 +24,10 @@ const form = useForm({
|
|||
})
|
||||
|
||||
const submit = () => {
|
||||
if (!props.contract) {
|
||||
// No contract selected; do nothing safely
|
||||
return
|
||||
}
|
||||
form.post(route('clientCase.contract.object.store', { client_case: props.client_case.uuid, uuid: props.contract.uuid }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { emit('created'); form.reset(); close() },
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const items = () => Array.isArray(props.contract?.objects) ? props.contract.obje
|
|||
<template>
|
||||
<DialogModal :show="show" @close="close">
|
||||
<template #title>
|
||||
Premeti
|
||||
Predmeti
|
||||
<span v-if="contract" class="ml-2 text-sm text-gray-500">(Pogodba: {{ contract.reference }})</span>
|
||||
</template>
|
||||
<template #content>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const props = defineProps({
|
|||
client_case: Object,
|
||||
show: { type: Boolean, default: false },
|
||||
types: Array,
|
||||
account_types: { type: Array, default: () => [] },
|
||||
// Optional: when provided, drawer acts as edit mode
|
||||
contract: { type: Object, default: null },
|
||||
});
|
||||
|
|
@ -40,6 +41,7 @@ const formContract = useForm({
|
|||
// nested account fields, if exists
|
||||
initial_amount: props.contract?.account?.initial_amount ?? null,
|
||||
balance_amount: props.contract?.account?.balance_amount ?? null,
|
||||
account_type_id: props.contract?.account?.type_id ?? null,
|
||||
});
|
||||
|
||||
// keep form in sync when switching between create and edit
|
||||
|
|
@ -51,6 +53,7 @@ const applyContract = (c) => {
|
|||
formContract.description = c?.description ?? ''
|
||||
formContract.initial_amount = c?.account?.initial_amount ?? null
|
||||
formContract.balance_amount = c?.account?.balance_amount ?? null
|
||||
formContract.account_type_id = c?.account?.type_id ?? null
|
||||
}
|
||||
|
||||
watch(() => props.contract, (c) => {
|
||||
|
|
@ -160,6 +163,18 @@ const storeOrUpdate = () => {
|
|||
Račun
|
||||
</template>
|
||||
</SectionTitle>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="accountTypeSelect" value="Tip računa"/>
|
||||
<select
|
||||
id="accountTypeSelect"
|
||||
v-model="formContract.account_type_id"
|
||||
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option :value="null">—</option>
|
||||
<option v-for="at in account_types" :key="at.id" :value="at.id">{{ at.name ?? ('#' + at.id) }}</option>
|
||||
</select>
|
||||
<InputError :message="formContract.errors.account_type_id" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<InputLabel for="initialAmount" value="Predani znesek"/>
|
||||
|
|
|
|||
|
|
@ -1,250 +1,463 @@
|
|||
<script setup>
|
||||
import { FwbTable, FwbTableHead, FwbTableHeadCell, FwbTableBody, FwbTableRow, FwbTableCell } from 'flowbite-vue'
|
||||
import Dropdown from '@/Components/Dropdown.vue'
|
||||
import CaseObjectCreateDialog from './CaseObjectCreateDialog.vue'
|
||||
import CaseObjectsDialog from './CaseObjectsDialog.vue'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { faCircleInfo, faEllipsisVertical, faPenToSquare, faTrash, faListCheck, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import {
|
||||
FwbTable,
|
||||
FwbTableHead,
|
||||
FwbTableHeadCell,
|
||||
FwbTableBody,
|
||||
FwbTableRow,
|
||||
FwbTableCell,
|
||||
} from "flowbite-vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import CaseObjectCreateDialog from "./CaseObjectCreateDialog.vue";
|
||||
import CaseObjectsDialog from "./CaseObjectsDialog.vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import {
|
||||
faCircleInfo,
|
||||
faClock,
|
||||
faEllipsisVertical,
|
||||
faPenToSquare,
|
||||
faTrash,
|
||||
faListCheck,
|
||||
faPlus,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const props = defineProps({
|
||||
client_case: Object,
|
||||
contract_types: Array,
|
||||
contracts: { type: Array, default: () => [] },
|
||||
segments: { type: Array, default: () => [] },
|
||||
all_segments: { type: Array, default: () => [] },
|
||||
})
|
||||
client_case: Object,
|
||||
contract_types: Array,
|
||||
contracts: { type: Array, default: () => [] },
|
||||
segments: { type: Array, default: () => [] },
|
||||
all_segments: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'add-activity'])
|
||||
const emit = defineEmits(["edit", "delete", "add-activity"]);
|
||||
|
||||
const formatDate = (d) => {
|
||||
if (!d) return '-'
|
||||
const dt = new Date(d)
|
||||
return isNaN(dt.getTime()) ? '-' : dt.toLocaleDateString('de')
|
||||
}
|
||||
if (!d) return "-";
|
||||
const dt = new Date(d);
|
||||
return isNaN(dt.getTime()) ? "-" : dt.toLocaleDateString("de");
|
||||
};
|
||||
|
||||
const hasDesc = (c) => {
|
||||
const d = c?.description
|
||||
return typeof d === 'string' && d.trim().length > 0
|
||||
}
|
||||
const d = c?.description;
|
||||
return typeof d === "string" && d.trim().length > 0;
|
||||
};
|
||||
|
||||
const onEdit = (c) => emit('edit', c)
|
||||
const onDelete = (c) => emit('delete', c)
|
||||
const onAddActivity = (c) => emit('add-activity', c)
|
||||
const onEdit = (c) => emit("edit", c);
|
||||
const onDelete = (c) => emit("delete", c);
|
||||
const onAddActivity = (c) => emit("add-activity", c);
|
||||
|
||||
// CaseObject dialog state
|
||||
import { ref, computed } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
const showObjectDialog = ref(false)
|
||||
const showObjectsList = ref(false)
|
||||
const selectedContract = ref(null)
|
||||
const openObjectDialog = (c) => { selectedContract.value = c; showObjectDialog.value = true }
|
||||
const closeObjectDialog = () => { showObjectDialog.value = false; selectedContract.value = null }
|
||||
const openObjectsList = (c) => { selectedContract.value = c; showObjectsList.value = true }
|
||||
const closeObjectsList = () => { showObjectsList.value = false; selectedContract.value = null }
|
||||
import { ref, computed } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
const showObjectDialog = ref(false);
|
||||
const showObjectsList = ref(false);
|
||||
const selectedContract = ref(null);
|
||||
const openObjectDialog = (c) => {
|
||||
selectedContract.value = c;
|
||||
showObjectDialog.value = true;
|
||||
};
|
||||
const closeObjectDialog = () => {
|
||||
showObjectDialog.value = false;
|
||||
selectedContract.value = null;
|
||||
};
|
||||
const openObjectsList = (c) => {
|
||||
selectedContract.value = c;
|
||||
showObjectsList.value = true;
|
||||
};
|
||||
const closeObjectsList = () => {
|
||||
showObjectsList.value = false;
|
||||
selectedContract.value = null;
|
||||
};
|
||||
|
||||
// Promise date helpers
|
||||
const todayStr = computed(() => {
|
||||
const d = new Date();
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
});
|
||||
|
||||
const getPromiseDate = (c) => c?.account?.promise_date || null;
|
||||
const promiseStatus = (c) => {
|
||||
const p = getPromiseDate(c);
|
||||
if (!p) return null;
|
||||
if (p > todayStr.value) return "future";
|
||||
if (p === todayStr.value) return "today";
|
||||
return "past";
|
||||
};
|
||||
const promiseColorClass = (c) => {
|
||||
const s = promiseStatus(c);
|
||||
if (s === "future") return "text-green-600";
|
||||
if (s === "today") return "text-yellow-500";
|
||||
if (s === "past") return "text-red-600";
|
||||
return "text-gray-400";
|
||||
};
|
||||
|
||||
// Segment helpers
|
||||
const contractActiveSegment = (c) => {
|
||||
const arr = c?.segments || []
|
||||
return arr.find(s => s.pivot?.active) || arr[0] || null
|
||||
}
|
||||
const segmentName = (id) => props.segments.find(s => s.id === id)?.name || ''
|
||||
const confirmChange = ref({ show: false, contract: null, segmentId: null, fromAll: false })
|
||||
const arr = c?.segments || [];
|
||||
return arr.find((s) => s.pivot?.active) || arr[0] || null;
|
||||
};
|
||||
const segmentName = (id) => props.segments.find((s) => s.id === id)?.name || "";
|
||||
const confirmChange = ref({
|
||||
show: false,
|
||||
contract: null,
|
||||
segmentId: null,
|
||||
fromAll: false,
|
||||
});
|
||||
const askChangeSegment = (c, segmentId, fromAll = false) => {
|
||||
confirmChange.value = { show: true, contract: c, segmentId, fromAll }
|
||||
}
|
||||
const closeConfirm = () => { confirmChange.value = { show: false, contract: null, segmentId: null } }
|
||||
confirmChange.value = { show: true, contract: c, segmentId, fromAll };
|
||||
};
|
||||
const closeConfirm = () => {
|
||||
confirmChange.value = { show: false, contract: null, segmentId: null };
|
||||
};
|
||||
const doChangeSegment = () => {
|
||||
const { contract, segmentId, fromAll } = confirmChange.value
|
||||
if (!contract || !segmentId) return closeConfirm()
|
||||
if (fromAll) {
|
||||
router.post(route('clientCase.segments.attach', props.client_case), {
|
||||
segment_id: segmentId,
|
||||
contract_uuid: contract.uuid,
|
||||
make_active_for_contract: true,
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
only: ['contracts', 'segments'],
|
||||
onFinish: () => closeConfirm(),
|
||||
})
|
||||
} else {
|
||||
router.post(route('clientCase.contract.updateSegment', { client_case: props.client_case.uuid, uuid: contract.uuid }), { segment_id: segmentId }, {
|
||||
preserveScroll: true,
|
||||
only: ['contracts'],
|
||||
onFinish: () => closeConfirm(),
|
||||
})
|
||||
}
|
||||
}
|
||||
const { contract, segmentId, fromAll } = confirmChange.value;
|
||||
if (!contract || !segmentId) return closeConfirm();
|
||||
if (fromAll) {
|
||||
router.post(
|
||||
route("clientCase.segments.attach", props.client_case),
|
||||
{
|
||||
segment_id: segmentId,
|
||||
contract_uuid: contract.uuid,
|
||||
make_active_for_contract: true,
|
||||
},
|
||||
{
|
||||
preserveScroll: true,
|
||||
only: ["contracts", "segments"],
|
||||
onFinish: () => closeConfirm(),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
router.post(
|
||||
route("clientCase.contract.updateSegment", {
|
||||
client_case: props.client_case.uuid,
|
||||
uuid: contract.uuid,
|
||||
}),
|
||||
{ segment_id: segmentId },
|
||||
{
|
||||
preserveScroll: true,
|
||||
only: ["contracts"],
|
||||
onFinish: () => closeConfirm(),
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<FwbTable hoverable striped class="text-sm">
|
||||
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm">
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Ref.</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Datum začetka</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Tip</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Segment</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-right">Predano</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-right">Odprto</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-center">Opis</FwbTableHeadCell>
|
||||
<FwbTableHeadCell class="w-px" />
|
||||
</FwbTableHead>
|
||||
<FwbTableBody>
|
||||
<template v-for="(c, i) in contracts" :key="c.uuid || i">
|
||||
<FwbTableRow>
|
||||
<FwbTableCell>{{ c.reference }}</FwbTableCell>
|
||||
<FwbTableCell>{{ formatDate(c.start_date) }}</FwbTableCell>
|
||||
<FwbTableCell>{{ c?.type?.name }}</FwbTableCell>
|
||||
<FwbTableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-700">{{ contractActiveSegment(c)?.name || '-' }}</span>
|
||||
<Dropdown width="64" align="left">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-7 w-7 rounded-full hover:bg-gray-100"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !segments || segments.length === 0 }"
|
||||
:title="segments && segments.length ? 'Change segment' : 'No segments available for this case'"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPenToSquare" class="h-4 w-4 text-gray-600" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="py-1">
|
||||
<template v-if="segments && segments.length">
|
||||
<button v-for="s in segments" :key="s.id" type="button" class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50" @click="askChangeSegment(c, s.id)">
|
||||
<span>{{ s.name }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="all_segments && all_segments.length">
|
||||
<div class="px-3 py-2 text-xs text-gray-500">Ni segmentov v tem primeru. Dodaj in nastavi segment:</div>
|
||||
<button v-for="s in all_segments" :key="s.id" type="button" class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50" @click="askChangeSegment(c, s.id, true)">
|
||||
<span>{{ s.name }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="px-3 py-2 text-sm text-gray-500">No segments configured.</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell class="text-right">{{ Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(c?.account?.initial_amount ?? 0) }}</FwbTableCell>
|
||||
<FwbTableCell class="text-right">{{ Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(c?.account?.balance_amount ?? 0) }}</FwbTableCell>
|
||||
<FwbTableCell class="text-center">
|
||||
<Dropdown v-if="hasDesc(c)" width="64" align="left">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
|
||||
:title="'Pokaži opis'"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-700" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="max-w-sm px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap">
|
||||
{{ c.description }}
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
disabled
|
||||
class="inline-flex items-center justify-center h-8 w-8 rounded-full text-gray-400 cursor-not-allowed"
|
||||
:title="'Ni opisa'"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4" />
|
||||
</button>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell class="text-right whitespace-nowrap">
|
||||
<Dropdown align="right" width="56">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
|
||||
:title="'Actions'"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faEllipsisVertical" class="h-4 w-4 text-gray-700" />
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
@click="onEdit(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPenToSquare" class="h-4 w-4 text-gray-600" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
@click="openObjectsList(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
|
||||
<span>Predmeti</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
@click="openObjectDialog(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" />
|
||||
<span>Premet</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-red-700 hover:bg-red-50 flex items-center gap-2"
|
||||
@click="onDelete(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4 text-red-600" />
|
||||
<span>Briši</span>
|
||||
</button>
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
@click="onAddActivity(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faListCheck" class="h-4 w-4 text-gray-600" />
|
||||
<span>Aktivnost</span>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</FwbTableCell>
|
||||
</FwbTableRow>
|
||||
<div
|
||||
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||
>
|
||||
<FwbTable hoverable striped class="text-sm">
|
||||
<FwbTableHead
|
||||
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
|
||||
>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||
>Ref.
|
||||
</FwbTableHeadCell>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||
>Datum začetka
|
||||
</FwbTableHeadCell>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||
>Tip
|
||||
</FwbTableHeadCell>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||||
>Segment
|
||||
</FwbTableHeadCell>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-right"
|
||||
>
|
||||
Predano</FwbTableHeadCell
|
||||
>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-right"
|
||||
>
|
||||
Odprto</FwbTableHeadCell
|
||||
>
|
||||
<FwbTableHeadCell
|
||||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 text-center"
|
||||
>
|
||||
Opis</FwbTableHeadCell
|
||||
>
|
||||
<FwbTableHeadCell class="w-px" />
|
||||
</FwbTableHead>
|
||||
<FwbTableBody>
|
||||
<template v-for="(c, i) in contracts" :key="c.uuid || i">
|
||||
<FwbTableRow>
|
||||
<FwbTableCell>{{ c.reference }}</FwbTableCell>
|
||||
<FwbTableCell>{{ formatDate(c.start_date) }}</FwbTableCell>
|
||||
<FwbTableCell>{{ c?.type?.name }}</FwbTableCell>
|
||||
<FwbTableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-700">{{
|
||||
contractActiveSegment(c)?.name || "-"
|
||||
}}</span>
|
||||
<Dropdown width="64" align="left">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-7 w-7 rounded-full hover:bg-gray-100"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed':
|
||||
!segments || segments.length === 0,
|
||||
}"
|
||||
:title="
|
||||
segments && segments.length
|
||||
? 'Change segment'
|
||||
: 'No segments available for this case'
|
||||
"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faPenToSquare"
|
||||
class="h-4 w-4 text-gray-600"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="py-1">
|
||||
<template v-if="segments && segments.length">
|
||||
<button
|
||||
v-for="s in segments"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
|
||||
@click="askChangeSegment(c, s.id)"
|
||||
>
|
||||
<span>{{ s.name }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="all_segments && all_segments.length">
|
||||
<div class="px-3 py-2 text-xs text-gray-500">
|
||||
Ni segmentov v tem primeru. Dodaj in nastavi segment:
|
||||
</div>
|
||||
<button
|
||||
v-for="s in all_segments"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-50"
|
||||
@click="askChangeSegment(c, s.id, true)"
|
||||
>
|
||||
<span>{{ s.name }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="px-3 py-2 text-sm text-gray-500">
|
||||
No segments configured.
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell class="text-right">{{
|
||||
Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(c?.account?.initial_amount ?? 0)
|
||||
}}</FwbTableCell>
|
||||
<FwbTableCell class="text-right">{{
|
||||
Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(c?.account?.balance_amount ?? 0)
|
||||
}}</FwbTableCell>
|
||||
<FwbTableCell class="text-center">
|
||||
<div class="inline-flex items-center justify-center gap-0.5">
|
||||
<Dropdown width="64" align="left">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-5 w-5 rounded-full"
|
||||
:title="'Pokaži opis'"
|
||||
:disabled="!hasDesc(c)"
|
||||
:class="hasDesc(c) ? 'hover:bg-gray-100 focus:outline-none' : text-gray-400"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faCircleInfo"
|
||||
class="h-4 w-4"
|
||||
:class="hasDesc(c) ? 'text-gray-700' : 'text-gray-400'"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
class="max-w-sm px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap"
|
||||
>
|
||||
{{ c.description }}
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
|
||||
<!-- Promise date indicator -->
|
||||
<Dropdown width="64" align="left">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-5 w-5 rounded-full hover:bg-gray-100 focus:outline-none"
|
||||
:title="
|
||||
getPromiseDate(c)
|
||||
? 'Obljubljen datum plačila'
|
||||
: 'Ni obljubljenega datuma'
|
||||
"
|
||||
:disabled="!getPromiseDate(c)"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faClock"
|
||||
class="h-4 w-4"
|
||||
:class="promiseColorClass(c)"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="px-3 py-2 text-sm text-gray-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
:icon="faClock"
|
||||
class="h-4 w-4"
|
||||
:class="promiseColorClass(c)"
|
||||
/>
|
||||
<span class="font-medium">Obljubljeno plačilo</span>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<span class="text-gray-500">Datum:</span>
|
||||
<span class="ml-1">{{ formatDate(getPromiseDate(c)) }}</span>
|
||||
</div>
|
||||
<div class="mt-1" v-if="promiseStatus(c) === 'future'">
|
||||
<span class="text-green-600">V prihodnosti</span>
|
||||
</div>
|
||||
<div class="mt-1" v-else-if="promiseStatus(c) === 'today'">
|
||||
<span class="text-yellow-600">Danes</span>
|
||||
</div>
|
||||
<div class="mt-1" v-else-if="promiseStatus(c) === 'past'">
|
||||
<span class="text-red-600">Zapadlo</span>
|
||||
</div>
|
||||
<div class="mt-1 text-gray-500" v-else>
|
||||
Ni nastavljenega datuma.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</FwbTableCell>
|
||||
<FwbTableCell class="text-right whitespace-nowrap">
|
||||
<Dropdown align="right" width="56">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center h-5 w-5 rounded-full hover:bg-gray-100 focus:outline-none"
|
||||
:title="'Actions'"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faEllipsisVertical"
|
||||
class="h-4 w-4 text-gray-700"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</FwbTableBody>
|
||||
</FwbTable>
|
||||
<div v-if="!contracts || contracts.length === 0" class="p-6 text-center text-sm text-gray-500">No contracts.</div>
|
||||
<template #content>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
@click="onEdit(c)"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="faPenToSquare"
|
||||
class="h-4 w-4 text-gray-600"
|
||||
/>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
@click="openObjectsList(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
|
||||
<span>Predmeti</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
@click="openObjectDialog(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4 text-gray-600" />
|
||||
<span>Predmeti</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-red-700 hover:bg-red-50 flex items-center gap-2"
|
||||
@click="onDelete(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4 text-red-600" />
|
||||
<span>Briši</span>
|
||||
</button>
|
||||
<div class="my-1 border-t border-gray-100" />
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
@click="onAddActivity(c)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faListCheck" class="h-4 w-4 text-gray-600" />
|
||||
<span>Aktivnost</span>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</FwbTableCell>
|
||||
</FwbTableRow>
|
||||
</template>
|
||||
</FwbTableBody>
|
||||
</FwbTable>
|
||||
<div
|
||||
v-if="!contracts || contracts.length === 0"
|
||||
class="p-6 text-center text-sm text-gray-500"
|
||||
>
|
||||
No contracts.
|
||||
</div>
|
||||
<!-- Confirm change segment -->
|
||||
<div v-if="confirmChange.show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div class="bg-white rounded-lg shadow-lg p-4 w-full max-w-sm">
|
||||
<div class="text-sm text-gray-800">
|
||||
Ali želite spremeniti segment za pogodbo <span class="font-medium">{{ confirmChange.contract?.reference }}</span>?
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="closeConfirm">Prekliči</button>
|
||||
<button class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" @click="doChangeSegment">Potrdi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CaseObjectCreateDialog
|
||||
:show="showObjectDialog"
|
||||
@close="closeObjectDialog"
|
||||
:client_case="client_case"
|
||||
:contract="selectedContract"
|
||||
/>
|
||||
<CaseObjectsDialog
|
||||
:show="showObjectsList"
|
||||
@close="closeObjectsList"
|
||||
:client_case="client_case"
|
||||
:contract="selectedContract"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Confirm change segment -->
|
||||
<div
|
||||
v-if="confirmChange.show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-lg p-4 w-full max-w-sm">
|
||||
<div class="text-sm text-gray-800">
|
||||
Ali želite spremeniti segment za pogodbo
|
||||
<span class="font-medium">{{ confirmChange.contract?.reference }}</span
|
||||
>?
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
||||
@click="closeConfirm"
|
||||
>
|
||||
Prekliči
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
@click="doChangeSegment"
|
||||
>
|
||||
Potrdi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CaseObjectCreateDialog
|
||||
:show="showObjectDialog"
|
||||
@close="closeObjectDialog"
|
||||
:client_case="client_case"
|
||||
:contract="selectedContract"
|
||||
/>
|
||||
<CaseObjectsDialog
|
||||
:show="showObjectsList"
|
||||
@close="closeObjectsList"
|
||||
:client_case="client_case"
|
||||
:contract="selectedContract"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const props = defineProps({
|
|||
contracts: Array,
|
||||
activities: Object,
|
||||
contract_types: Array,
|
||||
account_types: { type: Array, default: () => [] },
|
||||
actions: Array,
|
||||
types: Object,
|
||||
documents: Array,
|
||||
|
|
@ -42,15 +43,19 @@ const onUploaded = () => {
|
|||
const viewer = ref({ open: false, src: '', title: '' });
|
||||
const openViewer = (doc) => {
|
||||
const kind = classifyDocument(doc)
|
||||
const isContractDoc = (doc?.documentable_type || '').toLowerCase().includes('contract')
|
||||
if (kind === 'preview') {
|
||||
const url = route('clientCase.document.view', { client_case: props.client_case.uuid, document: doc.uuid })
|
||||
viewer.value = { open: true, src: url, title: doc.original_name || doc.name };
|
||||
const url = isContractDoc && doc.contract_uuid
|
||||
? route('contract.document.view', { contract: doc.contract_uuid, document: doc.uuid })
|
||||
: route('clientCase.document.view', { client_case: props.client_case.uuid, document: doc.uuid })
|
||||
viewer.value = { open: true, src: url, title: doc.original_name || doc.name }
|
||||
} else {
|
||||
const url = route('clientCase.document.download', { client_case: props.client_case.uuid, document: doc.uuid })
|
||||
// immediate download: navigate to URL
|
||||
const url = isContractDoc && doc.contract_uuid
|
||||
? route('contract.document.download', { contract: doc.contract_uuid, document: doc.uuid })
|
||||
: route('clientCase.document.download', { client_case: props.client_case.uuid, document: doc.uuid })
|
||||
window.location.href = url
|
||||
}
|
||||
};
|
||||
}
|
||||
const closeViewer = () => { viewer.value.open = false; viewer.value.src = ''; };
|
||||
|
||||
const clientDetails = ref(false);
|
||||
|
|
@ -245,7 +250,12 @@ const submitAttachSegment = () => {
|
|||
<DocumentsTable
|
||||
:documents="documents"
|
||||
@view="openViewer"
|
||||
:download-url-builder="doc => route('clientCase.document.download', { client_case: client_case.uuid, document: doc.uuid })"
|
||||
:download-url-builder="doc => {
|
||||
const isContractDoc = (doc?.documentable_type || '').toLowerCase().includes('contract')
|
||||
return isContractDoc && doc.contract_uuid
|
||||
? route('contract.document.download', { contract: doc.contract_uuid, document: doc.uuid })
|
||||
: route('clientCase.document.download', { client_case: client_case.uuid, document: doc.uuid })
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -256,6 +266,7 @@ const submitAttachSegment = () => {
|
|||
@close="closeUpload"
|
||||
@uploaded="onUploaded"
|
||||
:post-url="route('clientCase.document.store', client_case)"
|
||||
:contracts="contracts"
|
||||
/>
|
||||
<DocumentViewerDialog :show="viewer.open" :src="viewer.src" :title="viewer.title" @close="closeViewer" />
|
||||
</AppLayout>
|
||||
|
|
@ -263,6 +274,7 @@ const submitAttachSegment = () => {
|
|||
:show="drawerCreateContract"
|
||||
@close="closeDrawer"
|
||||
:types="contract_types"
|
||||
:account_types="account_types"
|
||||
:client_case="client_case"
|
||||
:contract="contractEditing"
|
||||
/>
|
||||
|
|
|
|||
202
resources/js/Pages/FieldJob/Index.vue
Normal file
202
resources/js/Pages/FieldJob/Index.vue
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
setting: Object,
|
||||
contracts: Array,
|
||||
users: Array,
|
||||
assignments: Object,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
contract_uuid: null,
|
||||
assigned_user_id: null,
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
});
|
||||
|
||||
// Format helpers (Slovenian formatting)
|
||||
function formatDate(value) {
|
||||
if (!value) { return '-'; }
|
||||
const d = new Date(value);
|
||||
if (isNaN(d)) { return value; }
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
return `${dd}.${mm}.${yyyy}`;
|
||||
}
|
||||
|
||||
function formatCurrencyEUR(value) {
|
||||
if (value === null || value === undefined) { return '-'; }
|
||||
const n = Number(value);
|
||||
if (isNaN(n)) { return String(value); }
|
||||
// Thousands separator as dot, decimal as comma, with € suffix
|
||||
return n.toLocaleString('sl-SI', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
|
||||
}
|
||||
|
||||
function assign(contract) {
|
||||
form.contract_uuid = contract.uuid
|
||||
// minimal UX: if no user selected yet, just post will fail with error; page can be enhanced later with dropdown.
|
||||
form.post(route('fieldjobs.assign'))
|
||||
}
|
||||
|
||||
function cancelAssignment(contract) {
|
||||
const payload = { contract_uuid: contract.uuid }
|
||||
form.transform(() => payload).post(route('fieldjobs.cancel'))
|
||||
}
|
||||
|
||||
function isAssigned(contract) {
|
||||
return !!(props.assignments && props.assignments[contract.uuid])
|
||||
}
|
||||
|
||||
function assignedTo(contract) {
|
||||
return props.assignments?.[contract.uuid]?.assigned_to?.name || null
|
||||
}
|
||||
|
||||
function assignedBy(contract) {
|
||||
return props.assignments?.[contract.uuid]?.assigned_by?.name || null
|
||||
}
|
||||
|
||||
// removed window.open behavior; default SPA navigation via Inertia Link
|
||||
|
||||
// Derived lists
|
||||
const unassignedContracts = computed(() => {
|
||||
return (props.contracts || []).filter(c => !isAssigned(c))
|
||||
})
|
||||
|
||||
const assignedContracts = computed(() => {
|
||||
return (props.contracts || []).filter(c => isAssigned(c))
|
||||
})
|
||||
|
||||
// Filter for assigned table
|
||||
const assignedFilterUserId = ref('')
|
||||
const assignedContractsFiltered = computed(() => {
|
||||
const list = assignedContracts.value
|
||||
if (!assignedFilterUserId.value) {
|
||||
return list
|
||||
}
|
||||
return list.filter(c => {
|
||||
const uid = props.assignments?.[c.uuid]?.assigned_to?.id
|
||||
return String(uid) === String(assignedFilterUserId.value)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Dodeljevanje terenskih opravil">
|
||||
<template #header></template>
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div v-if="!setting" class="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded p-4 mb-6">
|
||||
Nastavitev za terenska opravila ni najdena. Najprej jo ustvarite v Nastavitve → Nastavitve terenskih opravil.
|
||||
</div>
|
||||
<!-- Unassigned (Assignable) Contracts -->
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">Pogodbe (nedodeljene)</h2>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Dodeli uporabniku</label>
|
||||
<select v-model="form.assigned_user_id" class="border rounded px-3 py-2 w-full max-w-xs">
|
||||
<option :value="null" disabled>Izberite uporabnika</option>
|
||||
<option v-for="u in users || []" :key="u.id" :value="u.id">{{ u.name }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.assigned_user_id" class="text-red-600 text-sm mt-1">{{ form.errors.assigned_user_id }}</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2 pr-4">Sklic</th>
|
||||
<th class="py-2 pr-4">Stranka</th>
|
||||
<th class="py-2 pr-4">Vrsta</th>
|
||||
<th class="py-2 pr-4">Začetek</th>
|
||||
<th class="py-2 pr-4">Konec</th>
|
||||
<th class="py-2 pr-4">Stanje</th>
|
||||
<th class="py-2 pr-4">Dejanje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in unassignedContracts" :key="c.uuid" class="border-b last:border-0">
|
||||
<td class="py-2 pr-4">{{ c.reference }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<Link
|
||||
v-if="c.client_case?.uuid"
|
||||
:href="route('clientCase.show', { client_case: c.client_case.uuid })"
|
||||
class="text-indigo-600 hover:underline"
|
||||
>
|
||||
{{ c.client_case?.person?.full_name || 'Primer stranke' }}
|
||||
</Link>
|
||||
<span v-else>{{ c.client_case?.person?.full_name || '-' }}</span>
|
||||
</td>
|
||||
<td class="py-2 pr-4">{{ c.type?.name }}</td>
|
||||
<td class="py-2 pr-4">{{ formatDate(c.start_date) }}</td>
|
||||
<td class="py-2 pr-4">{{ formatDate(c.end_date) }}</td>
|
||||
<td class="py-2 pr-4">{{ formatCurrencyEUR(c.account?.balance_amount) }}</td>
|
||||
<td class="py-2 pr-4 flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1 text-sm rounded bg-indigo-600 text-white"
|
||||
@click="assign(c)"
|
||||
>Dodeli</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assigned Contracts -->
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">Dodeljene pogodbe</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-700">Filter po uporabniku</label>
|
||||
<select v-model="assignedFilterUserId" class="border rounded px-3 py-2">
|
||||
<option value="">Vsi</option>
|
||||
<option v-for="u in users || []" :key="u.id" :value="u.id">{{ u.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2 pr-4">Sklic</th>
|
||||
<th class="py-2 pr-4">Stranka</th>
|
||||
<th class="py-2 pr-4">Dodeljeno dne</th>
|
||||
<th class="py-2 pr-4">Dodeljeno komu</th>
|
||||
<th class="py-2 pr-4">Stanje</th>
|
||||
<th class="py-2 pr-4">Dejanje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in assignedContractsFiltered" :key="c.uuid" class="border-b last:border-0">
|
||||
<td class="py-2 pr-4">{{ c.reference }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<Link
|
||||
v-if="c.client_case?.uuid"
|
||||
:href="route('clientCase.show', { client_case: c.client_case.uuid })"
|
||||
class="text-indigo-600 hover:underline"
|
||||
>
|
||||
{{ c.client_case?.person?.full_name || 'Primer stranke' }}
|
||||
</Link>
|
||||
<span v-else>{{ c.client_case?.person?.full_name || '-' }}</span>
|
||||
</td>
|
||||
<td class="py-2 pr-4">{{ formatDate(props.assignments?.[c.uuid]?.assigned_at) }}</td>
|
||||
<td class="py-2 pr-4">{{ assignedTo(c) || '-' }}</td>
|
||||
<td class="py-2 pr-4">{{ formatCurrencyEUR(c.account?.balance_amount) }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<button class="px-3 py-1 text-sm rounded bg-red-600 text-white" @click="cancelAssignment(c)">Prekliči</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="assignedContractsFiltered.length === 0">
|
||||
<td colspan="6" class="py-4 text-gray-500">Ni dodeljenih pogodb za izbran filter.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
339
resources/js/Pages/Phone/Case/Index.vue
Normal file
339
resources/js/Pages/Phone/Case/Index.vue
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<script setup>
|
||||
import AppPhoneLayout from '@/Layouts/AppPhoneLayout.vue';
|
||||
import SectionTitle from '@/Components/SectionTitle.vue';
|
||||
import PersonDetailPhone from '@/Components/PersonDetailPhone.vue';
|
||||
// Removed table-based component for phone; render a list instead
|
||||
// import DocumentsTable from '@/Components/DocumentsTable.vue';
|
||||
import DocumentViewerDialog from '@/Components/DocumentViewerDialog.vue';
|
||||
import { classifyDocument } from '@/Services/documents';
|
||||
import { reactive, ref, computed } from 'vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import BasicButton from '@/Components/buttons/BasicButton.vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import ActivityDrawer from '@/Pages/Cases/Partials/ActivityDrawer.vue';
|
||||
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
client_case: Object,
|
||||
contracts: Array,
|
||||
documents: Array,
|
||||
types: Object,
|
||||
actions: Array,
|
||||
activities: Array,
|
||||
});
|
||||
|
||||
const viewer = reactive({ open: false, src: '', title: '' });
|
||||
function openViewer(doc) {
|
||||
const kind = classifyDocument(doc);
|
||||
const isContractDoc = (doc?.documentable_type || '').toLowerCase().includes('contract');
|
||||
if (kind === 'preview') {
|
||||
const url = isContractDoc && doc.contract_uuid
|
||||
? route('contract.document.view', { contract: doc.contract_uuid, document: doc.uuid })
|
||||
: route('clientCase.document.view', { client_case: props.client_case.uuid, document: doc.uuid });
|
||||
viewer.open = true; viewer.src = url; viewer.title = doc.original_name || doc.name;
|
||||
} else {
|
||||
const url = isContractDoc && doc.contract_uuid
|
||||
? route('contract.document.download', { contract: doc.contract_uuid, document: doc.uuid })
|
||||
: route('clientCase.document.download', { client_case: props.client_case.uuid, document: doc.uuid });
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
function closeViewer() { viewer.open = false; viewer.src = ''; }
|
||||
|
||||
function formatAmount(val) {
|
||||
if (val === null || val === undefined) return '0,00';
|
||||
const num = typeof val === 'number' ? val : parseFloat(val);
|
||||
if (Number.isNaN(num)) return String(val);
|
||||
return num.toLocaleString('sl-SI', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
// Activity drawer state
|
||||
const drawerAddActivity = ref(false);
|
||||
const activityContractUuid = ref(null);
|
||||
const openDrawerAddActivity = (c = null) => {
|
||||
activityContractUuid.value = c?.uuid ?? null;
|
||||
drawerAddActivity.value = true;
|
||||
};
|
||||
const closeDrawer = () => { drawerAddActivity.value = false; };
|
||||
|
||||
// Document upload state
|
||||
const docDialogOpen = ref(false);
|
||||
const docForm = useForm({
|
||||
file: null,
|
||||
name: '',
|
||||
description: '',
|
||||
is_public: true,
|
||||
contract_uuid: null,
|
||||
});
|
||||
const onPickDocument = (e) => {
|
||||
const f = e?.target?.files?.[0];
|
||||
if (f) { docForm.file = f; }
|
||||
};
|
||||
const openDocDialog = (c = null) => {
|
||||
docForm.contract_uuid = c?.uuid ?? null;
|
||||
docDialogOpen.value = true;
|
||||
};
|
||||
const closeDocDialog = () => { docDialogOpen.value = false; };
|
||||
const submitDocument = () => {
|
||||
if (!docForm.file) { return; }
|
||||
docForm.post(route('clientCase.document.store', { client_case: props.client_case.uuid }), {
|
||||
forceFormData: true,
|
||||
onSuccess: () => {
|
||||
closeDocDialog();
|
||||
docForm.reset('file', 'name', 'description', 'is_public', 'contract_uuid');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectedContract = computed(() => {
|
||||
if (!docForm.contract_uuid) return null;
|
||||
return props.contracts?.find(c => c.uuid === docForm.contract_uuid) || null;
|
||||
});
|
||||
|
||||
// Complete flow
|
||||
const confirmComplete = ref(false);
|
||||
const submitComplete = () => {
|
||||
// POST to phone.case.complete and redirect handled by server
|
||||
// Use a small form post via Inertia
|
||||
const form = useForm({});
|
||||
form.post(route('phone.case.complete', { client_case: props.client_case.uuid }), {
|
||||
onFinish: () => { confirmComplete.value = false; },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPhoneLayout :title="`Primer: ${client_case?.person?.full_name || ''}`">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<a :href="route('phone.index')" class="text-sm text-blue-600 hover:underline shrink-0">← Nazaj</a>
|
||||
<h2 class="font-semibold text-xl text-gray-800 truncate">{{ client_case?.person?.full_name }}</h2>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700"
|
||||
@click="confirmComplete = true"
|
||||
>Zaključi</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-4 sm:py-6">
|
||||
<div class="mx-auto max-w-5xl px-2 sm:px-4">
|
||||
<!-- Client details (account holder) -->
|
||||
<div class="bg-white rounded-lg shadow border overflow-hidden">
|
||||
<div class="p-3 sm:p-4">
|
||||
<SectionTitle>
|
||||
<template #title>Stranka</template>
|
||||
</SectionTitle>
|
||||
<div class="mt-2">
|
||||
<PersonDetailPhone :types="types" :person="client.person" default-tab="phones" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Person (case person) -->
|
||||
<div class="bg-white rounded-lg shadow border overflow-hidden">
|
||||
<div class="p-3 sm:p-4">
|
||||
<SectionTitle>
|
||||
<template #title>Primer - oseba</template>
|
||||
</SectionTitle>
|
||||
<div class="mt-2">
|
||||
<PersonDetailPhone :types="types" :person="client_case.person" default-tab="phones" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contracts assigned to me -->
|
||||
<div class="mt-4 sm:mt-6 bg-white rounded-lg shadow border overflow-hidden">
|
||||
<div class="p-3 sm:p-4">
|
||||
<SectionTitle>
|
||||
<template #title>Pogodbe</template>
|
||||
</SectionTitle>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="c in contracts"
|
||||
:key="c.uuid || c.id"
|
||||
class="rounded border p-3 sm:p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{{ c.reference || c.uuid }}</p>
|
||||
<p class="text-sm text-gray-600">Tip: {{ c.type?.name || '—' }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="space-y-2">
|
||||
<p v-if="c.account" class="text-sm text-gray-700">Odprto: {{ formatAmount(c.account.balance_amount) }} €</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 w-full sm:w-auto"
|
||||
@click="openDrawerAddActivity(c)"
|
||||
>+ Aktivnost</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700 w-full sm:w-auto"
|
||||
@click="openDocDialog(c)"
|
||||
>+ Dokument</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="c.last_object" class="mt-2 text-sm text-gray-700">
|
||||
<p class="font-medium">Predmet:</p>
|
||||
<p>
|
||||
<span class="text-gray-900">{{ c.last_object.name || c.last_object.reference }}</span>
|
||||
<span v-if="c.last_object.type" class="ml-2 text-gray-500">({{ c.last_object.type }})</span>
|
||||
</p>
|
||||
<p v-if="c.last_object.description" class="text-gray-600 mt-1">{{ c.last_object.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!contracts?.length" class="text-sm text-gray-600">Ni pogodbenih obveznosti dodeljenih vam za ta primer.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documents (case + assigned contracts) -->
|
||||
<div class="mt-4 sm:mt-6 bg-white rounded-lg shadow border overflow-hidden">
|
||||
<div class="p-3 sm:p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<SectionTitle>
|
||||
<template #title>Dokumenti</template>
|
||||
</SectionTitle>
|
||||
<button
|
||||
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
@click="openDocDialog()"
|
||||
>Dodaj</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 divide-y">
|
||||
<div
|
||||
v-for="d in documents"
|
||||
:key="d.uuid || d.id"
|
||||
class="py-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-gray-900 truncate">{{ d.name || d.original_name }}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
<span v-if="d.contract_reference">Pogodba: {{ d.contract_reference }}</span>
|
||||
<span v-else>Primer</span>
|
||||
<span v-if="d.created_at" class="ml-2">· {{ new Date(d.created_at).toLocaleDateString('sl-SI') }}</span>
|
||||
</div>
|
||||
<div v-if="d.description" class="text-gray-600 text-sm mt-1 line-clamp-2">{{ d.description }}</div>
|
||||
</div>
|
||||
<div class="shrink-0 flex flex-col items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-800"
|
||||
@click="openViewer(d)"
|
||||
>Ogled</button>
|
||||
<a
|
||||
class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-800"
|
||||
:href="(() => { const isC = (d?.documentable_type || '').toLowerCase().includes('contract'); return isC && d.contract_uuid ? route('contract.document.download', { contract: d.contract_uuid, document: d.uuid }) : route('clientCase.document.download', { client_case: client_case.uuid, document: d.uuid }); })()"
|
||||
>Prenesi</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!documents?.length" class="text-gray-600 text-sm py-2">Ni dokumentov.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activities -->
|
||||
<div class="mt-4 sm:mt-6 bg-white rounded-lg shadow border overflow-hidden">
|
||||
<div class="p-3 sm:p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<SectionTitle>
|
||||
<template #title>Aktivnosti</template>
|
||||
</SectionTitle>
|
||||
<button
|
||||
class="text-sm px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
@click="openDrawerAddActivity()"
|
||||
>Nova</button>
|
||||
</div>
|
||||
<div class="mt-2 divide-y">
|
||||
<div v-for="a in activities" :key="a.id" class="py-2 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-gray-800">{{ a.action?.name }}<span v-if="a.decision"> → {{ a.decision?.name }}</span></div>
|
||||
<div class="text-right text-gray-500">
|
||||
<div v-if="a.contract">Pogodba: {{ a.contract.reference }}</div>
|
||||
<div class="text-xs" v-if="a.created_at || a.user || a.user_name">
|
||||
<span v-if="a.created_at">{{ new Date(a.created_at).toLocaleDateString('sl-SI') }}</span>
|
||||
<span v-if="(a.user && a.user.name) || a.user_name" class="ml-1">· {{ a.user?.name || a.user_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="a.note" class="text-gray-600">{{ a.note }}</div>
|
||||
<div class="text-gray-500">
|
||||
<span v-if="a.due_date">Zapadlost: {{ a.due_date }}</span>
|
||||
<span v-if="a.amount != null" class="ml-2">Znesek: {{ formatAmount(a.amount) }} €</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!activities?.length" class="text-gray-600 py-2">Ni aktivnosti.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentViewerDialog :show="viewer.open" :src="viewer.src" :title="viewer.title" @close="closeViewer" />
|
||||
<ActivityDrawer :show="drawerAddActivity" @close="closeDrawer" :client_case="client_case" :actions="actions" :contract-uuid="activityContractUuid" />
|
||||
|
||||
<ConfirmationModal :show="confirmComplete" @close="confirmComplete = false">
|
||||
<template #title>Potrditev</template>
|
||||
<template #content>
|
||||
Ali ste prepričani da želite že zaključit stranko?
|
||||
</template>
|
||||
<template #footer>
|
||||
<button type="button" class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200" @click="confirmComplete = false">Prekliči</button>
|
||||
<button type="button" class="px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700 ml-2" @click="submitComplete">Potrdi</button>
|
||||
</template>
|
||||
</ConfirmationModal>
|
||||
|
||||
<!-- Upload Document Modal -->
|
||||
<DialogModal :show="docDialogOpen" @close="closeDocDialog">
|
||||
<template #title>Dodaj dokument</template>
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<div v-if="selectedContract" class="text-sm text-gray-700">
|
||||
Dokument bo dodan k pogodbi: <span class="font-medium">{{ selectedContract.reference || selectedContract.uuid }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="docFile" value="Datoteka" />
|
||||
<input id="docFile" type="file" class="mt-1 block w-full" @change="onPickDocument" />
|
||||
<div v-if="docForm.errors.file" class="text-sm text-red-600 mt-1">{{ docForm.errors.file }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="docName" value="Ime" />
|
||||
<TextInput id="docName" v-model="docForm.name" class="mt-1 block w-full" />
|
||||
<div v-if="docForm.errors.name" class="text-sm text-red-600 mt-1">{{ docForm.errors.name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<InputLabel for="docDesc" value="Opis" />
|
||||
<TextInput id="docDesc" v-model="docForm.description" class="mt-1 block w-full" />
|
||||
<div v-if="docForm.errors.description" class="text-sm text-red-600 mt-1">{{ docForm.errors.description }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="docPublic" type="checkbox" v-model="docForm.is_public" />
|
||||
<InputLabel for="docPublic" value="Javno" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200" @click="closeDocDialog">Prekliči</button>
|
||||
<button type="button" class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" :disabled="docForm.processing || !docForm.file" @click="submitDocument">Naloži</button>
|
||||
</div>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</AppPhoneLayout>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
109
resources/js/Pages/Phone/Index.vue
Normal file
109
resources/js/Pages/Phone/Index.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<script setup>
|
||||
import AppPhoneLayout from '@/Layouts/AppPhoneLayout.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
jobs: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const items = computed(() => props.jobs || []);
|
||||
|
||||
// Search filter (contract reference or person full name)
|
||||
const search = ref('');
|
||||
const filteredJobs = computed(() => {
|
||||
const term = search.value.trim().toLowerCase();
|
||||
if (!term) return items.value;
|
||||
return items.value.filter(job => {
|
||||
const refStr = (job.contract?.reference || job.contract?.uuid || '').toString().toLowerCase();
|
||||
const nameStr = (job.contract?.client_case?.person?.full_name || '').toLowerCase();
|
||||
return refStr.includes(term) || nameStr.includes(term);
|
||||
});
|
||||
});
|
||||
|
||||
function formatDateDMY(d) {
|
||||
if (!d) return '-';
|
||||
// Handle date-only strings from Laravel JSON casts (YYYY-MM-DD...)
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(d)) {
|
||||
const [y, m, rest] = d.split('-');
|
||||
const day = (rest || '').slice(0, 2) || '01';
|
||||
return `${day}.${m}.${y}`;
|
||||
}
|
||||
const dt = new Date(d);
|
||||
if (Number.isNaN(dt.getTime())) return String(d);
|
||||
const dd = String(dt.getDate()).padStart(2, '0');
|
||||
const mm = String(dt.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = dt.getFullYear();
|
||||
return `${dd}.${mm}.${yyyy}`;
|
||||
}
|
||||
|
||||
function formatAmount(val) {
|
||||
if (val === null || val === undefined) return '0,00';
|
||||
const num = typeof val === 'number' ? val : parseFloat(val);
|
||||
if (Number.isNaN(num)) return String(val);
|
||||
return num.toLocaleString('sl-SI', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPhoneLayout title="Phone">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Moja terenska opravila</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-4 sm:py-8">
|
||||
<div class="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Išči po referenci ali imenu..."
|
||||
class="flex-1 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
<button
|
||||
v-if="search"
|
||||
type="button"
|
||||
@click="search = ''"
|
||||
class="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-600"
|
||||
>Počisti</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<template v-if="filteredJobs.length">
|
||||
<div v-for="job in filteredJobs" :key="job.id" class="bg-white rounded-lg shadow border p-3 sm:p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500">Dodeljeno: <span class="font-medium text-gray-700">{{ formatDateDMY(job.assigned_at) }}</span></p>
|
||||
<span v-if="job.priority" class="inline-block text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700">Prioriteta</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<p class="text-base sm:text-lg font-semibold text-gray-800">
|
||||
{{ job.contract?.client_case?.person?.full_name || '—' }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 truncate">Kontrakt: {{ job.contract?.reference || job.contract?.uuid }}</p>
|
||||
<p class="text-sm text-gray-600">Tip: {{ job.contract?.type?.name || '—' }}</p>
|
||||
<p class="text-sm text-gray-600" v-if="job.contract?.account && job.contract.account.balance_amount !== null && job.contract.account.balance_amount !== undefined">
|
||||
Odprto: {{ formatAmount(job.contract.account.balance_amount) }} €
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3 text-sm text-gray-600">
|
||||
<p>
|
||||
<span class="font-medium">Naslov:</span>
|
||||
{{ job.contract?.client_case?.person?.addresses?.[0]?.address || '—' }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium">Telefon:</span>
|
||||
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<a :href="route('phone.case', { client_case: job.contract?.client_case?.uuid })" class="inline-flex-1 flex-1 text-center px-3 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700">Odpri primer</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="col-span-full bg-white rounded-lg shadow border p-6 text-center text-gray-600">
|
||||
<span v-if="search">Ni zadetkov za podani filter.</span>
|
||||
<span v-else>Trenutno nimate dodeljenih terenskih opravil.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPhoneLayout>
|
||||
</template>
|
||||
|
|
@ -15,6 +15,8 @@ const props = defineProps({
|
|||
});
|
||||
|
||||
const showCreate = ref(false);
|
||||
const showEdit = ref(false);
|
||||
const editingId = ref(null);
|
||||
const segmentOptions = ref([]);
|
||||
const decisionOptions = ref([]);
|
||||
|
||||
|
|
@ -26,8 +28,11 @@ onMounted(() => {
|
|||
const form = useForm({
|
||||
segment_id: null,
|
||||
initial_decision_id: null,
|
||||
asign_decision_id: null,
|
||||
assign_decision_id: null,
|
||||
complete_decision_id: null,
|
||||
cancel_decision_id: null,
|
||||
return_segment_id: null,
|
||||
queue_segment_id: null,
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
|
|
@ -46,6 +51,45 @@ const store = () => {
|
|||
onSuccess: () => closeCreate(),
|
||||
});
|
||||
};
|
||||
|
||||
const editForm = useForm({
|
||||
segment_id: null,
|
||||
initial_decision_id: null,
|
||||
assign_decision_id: null,
|
||||
complete_decision_id: null,
|
||||
cancel_decision_id: null,
|
||||
return_segment_id: null,
|
||||
queue_segment_id: null,
|
||||
});
|
||||
|
||||
const openEdit = (row) => {
|
||||
editingId.value = row.id;
|
||||
editForm.segment_id = row.segment_id ?? row.segment?.id ?? null;
|
||||
editForm.initial_decision_id = row.initial_decision_id ?? row.initial_decision?.id ?? row.initialDecision?.id ?? null;
|
||||
editForm.assign_decision_id = row.assign_decision_id ?? row.assign_decision?.id ?? row.assignDecision?.id ?? null;
|
||||
editForm.complete_decision_id = row.complete_decision_id ?? row.complete_decision?.id ?? row.completeDecision?.id ?? null;
|
||||
editForm.cancel_decision_id = row.cancel_decision_id ?? row.cancel_decision?.id ?? row.cancelDecision?.id ?? null;
|
||||
editForm.return_segment_id = row.return_segment_id ?? row.return_segment?.id ?? row.returnSegment?.id ?? null;
|
||||
editForm.queue_segment_id = row.queue_segment_id ?? row.queue_segment?.id ?? row.queueSegment?.id ?? null;
|
||||
showEdit.value = true;
|
||||
};
|
||||
|
||||
const closeEdit = () => {
|
||||
showEdit.value = false;
|
||||
editingId.value = null;
|
||||
editForm.reset();
|
||||
editForm.clearErrors();
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
if (!editingId.value) {
|
||||
return;
|
||||
}
|
||||
editForm.put(route('settings.fieldjob.update', { setting: editingId.value }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => closeEdit(),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -100,7 +144,7 @@ const store = () => {
|
|||
<InputLabel for="assignDecision" value="Assign Decision" />
|
||||
<multiselect
|
||||
id="assignDecision"
|
||||
v-model="form.asign_decision_id"
|
||||
v-model="form.assign_decision_id"
|
||||
:options="decisionOptions.map(o=>o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
|
|
@ -108,7 +152,7 @@ const store = () => {
|
|||
:append-to-body="true"
|
||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
||||
/>
|
||||
<InputError :message="form.errors.asign_decision_id" class="mt-1" />
|
||||
<InputError :message="form.errors.assign_decision_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
|
|
@ -125,6 +169,51 @@ const store = () => {
|
|||
/>
|
||||
<InputError :message="form.errors.complete_decision_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="cancelDecision" value="Cancel Decision" />
|
||||
<multiselect
|
||||
id="cancelDecision"
|
||||
v-model="form.cancel_decision_id"
|
||||
:options="decisionOptions.map(o=>o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select cancel decision (optional)"
|
||||
:append-to-body="true"
|
||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
||||
/>
|
||||
<InputError :message="form.errors.cancel_decision_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="returnSegment" value="Return Segment" />
|
||||
<multiselect
|
||||
id="returnSegment"
|
||||
v-model="form.return_segment_id"
|
||||
:options="segmentOptions.map(o=>o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select return segment (optional)"
|
||||
:append-to-body="true"
|
||||
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
|
||||
/>
|
||||
<InputError :message="form.errors.return_segment_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="queueSegment" value="Queue Segment" />
|
||||
<multiselect
|
||||
id="queueSegment"
|
||||
v-model="form.queue_segment_id"
|
||||
:options="segmentOptions.map(o=>o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select queue segment (optional)"
|
||||
:append-to-body="true"
|
||||
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
|
||||
/>
|
||||
<InputError :message="form.errors.queue_segment_id" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
|
|
@ -134,6 +223,126 @@ const store = () => {
|
|||
</form>
|
||||
</template>
|
||||
</DialogModal>
|
||||
<DialogModal :show="showEdit" @close="closeEdit">
|
||||
<template #title>
|
||||
Edit Field Job Setting
|
||||
</template>
|
||||
<template #content>
|
||||
<form @submit.prevent="update">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<InputLabel for="edit-segment" value="Segment" />
|
||||
<multiselect
|
||||
id="edit-segment"
|
||||
v-model="editForm.segment_id"
|
||||
:options="segmentOptions.map(o=>o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select segment"
|
||||
:append-to-body="true"
|
||||
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
|
||||
/>
|
||||
<InputError :message="editForm.errors.segment_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel for="edit-initialDecision" value="Initial Decision" />
|
||||
<multiselect
|
||||
id="edit-initialDecision"
|
||||
v-model="editForm.initial_decision_id"
|
||||
:options="decisionOptions.map(o=>o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select initial decision"
|
||||
:append-to-body="true"
|
||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
||||
/>
|
||||
<InputError :message="editForm.errors.initial_decision_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InputLabel for="edit-assignDecision" value="Assign Decision" />
|
||||
<multiselect
|
||||
id="edit-assignDecision"
|
||||
v-model="editForm.assign_decision_id"
|
||||
:options="decisionOptions.map(o=>o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select assign decision"
|
||||
:append-to-body="true"
|
||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
||||
/>
|
||||
<InputError :message="editForm.errors.assign_decision_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="edit-completeDecision" value="Complete Decision" />
|
||||
<multiselect
|
||||
id="edit-completeDecision"
|
||||
v-model="editForm.complete_decision_id"
|
||||
:options="decisionOptions.map(o=>o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select complete decision"
|
||||
:append-to-body="true"
|
||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
||||
/>
|
||||
<InputError :message="editForm.errors.complete_decision_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="edit-cancelDecision" value="Cancel Decision" />
|
||||
<multiselect
|
||||
id="edit-cancelDecision"
|
||||
v-model="editForm.cancel_decision_id"
|
||||
:options="decisionOptions.map(o=>o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select cancel decision (optional)"
|
||||
:append-to-body="true"
|
||||
:custom-label="(opt) => (decisionOptions.find(o=>o.id===opt)?.name || '')"
|
||||
/>
|
||||
<InputError :message="editForm.errors.cancel_decision_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="edit-returnSegment" value="Return Segment" />
|
||||
<multiselect
|
||||
id="edit-returnSegment"
|
||||
v-model="editForm.return_segment_id"
|
||||
:options="segmentOptions.map(o=>o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select return segment (optional)"
|
||||
:append-to-body="true"
|
||||
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
|
||||
/>
|
||||
<InputError :message="editForm.errors.return_segment_id" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<InputLabel for="edit-queueSegment" value="Queue Segment" />
|
||||
<multiselect
|
||||
id="edit-queueSegment"
|
||||
v-model="editForm.queue_segment_id"
|
||||
:options="segmentOptions.map(o=>o.id)"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
placeholder="Select queue segment (optional)"
|
||||
:append-to-body="true"
|
||||
:custom-label="(opt) => (segmentOptions.find(o=>o.id===opt)?.name || '')"
|
||||
/>
|
||||
<InputError :message="editForm.errors.queue_segment_id" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
<button type="button" @click="closeEdit" class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300">Cancel</button>
|
||||
<PrimaryButton :disabled="editForm.processing">Save</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</DialogModal>
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
|
|
@ -142,6 +351,10 @@ const store = () => {
|
|||
<th class="py-2 pr-4">Initial Decision</th>
|
||||
<th class="py-2 pr-4">Assign Decision</th>
|
||||
<th class="py-2 pr-4">Complete Decision</th>
|
||||
<th class="py-2 pr-4">Cancel Decision</th>
|
||||
<th class="py-2 pr-4">Return Segment</th>
|
||||
<th class="py-2 pr-4">Queue Segment</th>
|
||||
<th class="py-2 pr-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -149,8 +362,16 @@ const store = () => {
|
|||
<td class="py-2 pr-4">{{ row.id }}</td>
|
||||
<td class="py-2 pr-4">{{ row.segment?.name }}</td>
|
||||
<td class="py-2 pr-4">{{ row.initial_decision?.name || row.initialDecision?.name }}</td>
|
||||
<td class="py-2 pr-4">{{ row.asign_decision?.name || row.asignDecision?.name }}</td>
|
||||
<td class="py-2 pr-4">{{ row.assign_decision?.name || row.assignDecision?.name }}</td>
|
||||
<td class="py-2 pr-4">{{ row.complete_decision?.name || row.completeDecision?.name }}</td>
|
||||
<td class="py-2 pr-4">{{ row.cancel_decision?.name || row.cancelDecision?.name }}</td>
|
||||
<td class="py-2 pr-4">{{ row.return_segment?.name || row.returnSegment?.name }}</td>
|
||||
<td class="py-2 pr-4">{{ row.queue_segment?.name || row.queueSegment?.name }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<button @click="openEdit(row)" class="px-3 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700">
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
<?php
|
||||
|
||||
use App\Charts\ExampleChart;
|
||||
use App\Http\Controllers\PersonController;
|
||||
use App\Http\Controllers\CaseObjectController;
|
||||
use App\Http\Controllers\ClientCaseContoller;
|
||||
use App\Http\Controllers\ClientController;
|
||||
use App\Http\Controllers\ContractController;
|
||||
use App\Http\Controllers\SettingController;
|
||||
use App\Http\Controllers\WorkflowController;
|
||||
use App\Http\Controllers\SegmentController;
|
||||
use App\Http\Controllers\FieldJobSettingController;
|
||||
use App\Http\Controllers\ContractConfigController;
|
||||
use App\Http\Controllers\FieldJobController;
|
||||
use App\Http\Controllers\FieldJobSettingController;
|
||||
use App\Http\Controllers\ImportController;
|
||||
use App\Http\Controllers\ImportTemplateController;
|
||||
use App\Http\Controllers\CaseObjectController;
|
||||
use App\Http\Controllers\PersonController;
|
||||
use App\Http\Controllers\PhoneViewController;
|
||||
use App\Http\Controllers\SegmentController;
|
||||
use App\Http\Controllers\SettingController;
|
||||
use App\Http\Controllers\WorkflowController;
|
||||
use App\Models\Person\Person;
|
||||
use Illuminate\Http\Request;
|
||||
use ArielMejiaDev\LarapexCharts\LarapexChart;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use \Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
Route::redirect('/', 'login');
|
||||
|
||||
|
|
@ -28,10 +28,10 @@
|
|||
'verified',
|
||||
])->group(function () {
|
||||
Route::get('/dashboard', function () {
|
||||
$chart = new ExampleChart(new LarapexChart());
|
||||
$chart = new ExampleChart(new LarapexChart);
|
||||
$people = Person::with(['group', 'type', 'client', 'clientCase'])
|
||||
->where([
|
||||
['active','=',1],
|
||||
['active', '=', 1],
|
||||
])
|
||||
->limit(10)
|
||||
->orderByDesc('created_at')
|
||||
|
|
@ -47,25 +47,30 @@
|
|||
->get();
|
||||
|
||||
return Inertia::render(
|
||||
'Dashboard',
|
||||
'Dashboard',
|
||||
[
|
||||
'chart' => $chart->build(),
|
||||
'people' => $people,
|
||||
'terrain' => $terrain
|
||||
'terrain' => $terrain,
|
||||
]
|
||||
);
|
||||
})->name('dashboard');
|
||||
|
||||
Route::get('testing', function() {
|
||||
Route::get('testing', function () {
|
||||
return Inertia::render('Testing', []);
|
||||
});
|
||||
|
||||
Route::get('search', function(Request $request) {
|
||||
|
||||
if( !empty($request->input('query')) ) {
|
||||
|
||||
|
||||
// Phone page
|
||||
Route::get('phone', [PhoneViewController::class, 'index'])->name('phone.index');
|
||||
Route::get('phone/case/{client_case:uuid}', [PhoneViewController::class, 'showCase'])->name('phone.case');
|
||||
Route::post('phone/case/{client_case:uuid}/complete', [\App\Http\Controllers\FieldJobController::class, 'complete'])->name('phone.case.complete');
|
||||
|
||||
Route::get('search', function (Request $request) {
|
||||
|
||||
if (! empty($request->input('query'))) {
|
||||
|
||||
$clients = App\Models\Person\Person::search($request->input('query'))
|
||||
->query(function($builder) use($request): void {
|
||||
->query(function ($builder) use ($request): void {
|
||||
$builder->join('clients', 'person.id', '=', 'clients.person_id')
|
||||
->leftJoin('person_addresses', 'person.id', '=', 'person_addresses.person_id')
|
||||
->leftJoin('person_phones', 'person.id', '=', 'person_phones.person_id')
|
||||
|
|
@ -75,7 +80,7 @@
|
|||
->get();
|
||||
|
||||
$clientCases = App\Models\Person\Person::search($request->input('query'))
|
||||
->query(function($builder) use($request): void {
|
||||
->query(function ($builder) use ($request): void {
|
||||
$builder->join('client_cases', 'person.id', '=', 'client_cases.person_id')
|
||||
->leftJoin('person_addresses', 'person.id', '=', 'person_addresses.person_id')
|
||||
->leftJoin('person_phones', 'person.id', '=', 'person_phones.person_id')
|
||||
|
|
@ -83,19 +88,19 @@
|
|||
->limit($request->input('limit'));
|
||||
})
|
||||
->get();
|
||||
|
||||
|
||||
return [
|
||||
'clients' => $clients,
|
||||
'client_cases' => $clientCases,
|
||||
'query' => $request->input('query')
|
||||
'query' => $request->input('query'),
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
|
||||
return [];
|
||||
})->name('search');
|
||||
|
||||
//person
|
||||
// person
|
||||
Route::put('person/{person:uuid}', [PersonController::class, 'update'])->name('person.update');
|
||||
Route::post('person/{person:uuid}/address', [PersonController::class, 'createAddress'])->name('person.address.create');
|
||||
Route::put('person/{person:uuid}/address/{address_id}', [PersonController::class, 'updateAddress'])->name('person.address.update');
|
||||
|
|
@ -110,18 +115,18 @@
|
|||
Route::post('person/{person:uuid}/trr', [PersonController::class, 'createTrr'])->name('person.trr.create');
|
||||
Route::put('person/{person:uuid}/trr/{trr_id}', [PersonController::class, 'updateTrr'])->name('person.trr.update');
|
||||
Route::delete('person/{person:uuid}/trr/{trr_id}', [PersonController::class, 'deleteTrr'])->name('person.trr.delete');
|
||||
//client
|
||||
// client
|
||||
Route::get('clients', [ClientController::class, 'index'])->name('client');
|
||||
Route::get('clients/{client:uuid}', [ClientController::class, 'show'])->name('client.show');
|
||||
Route::post('clients', [ClientController::class, 'store'])->name('client.store');
|
||||
Route::put('clients/{client:uuid}', [ClientController::class, 'update'])->name('client.update');
|
||||
|
||||
//client-case
|
||||
// client-case
|
||||
Route::get('client-cases', [ClientCaseContoller::class, 'index'])->name('clientCase');
|
||||
Route::get('client-cases/{client_case:uuid}', [ClientCaseContoller::class, 'show'])->name('clientCase.show');
|
||||
Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/segment', [ClientCaseContoller::class, 'updateContractSegment'])->name('clientCase.contract.updateSegment');
|
||||
Route::post('client-cases', [ClientCaseContoller::class, 'store'])->name('clientCase.store');
|
||||
//client-case / contract
|
||||
// client-case / contract
|
||||
Route::post('client-cases/{client_case:uuid}/contract', [ClientCaseContoller::class, 'storeContract'])->name('clientCase.contract.store');
|
||||
Route::put('client-cases/{client_case:uuid}/contract/{uuid}', [ClientCaseContoller::class, 'updateContract'])->name('clientCase.contract.update');
|
||||
Route::delete('client-cases/{client_case:uuid}/contract/{uuid}', [ClientCaseContoller::class, 'deleteContract'])->name('clientCase.contract.delete');
|
||||
|
|
@ -129,22 +134,31 @@
|
|||
Route::post('client-cases/{client_case:uuid}/contract/{uuid}/objects', [CaseObjectController::class, 'store'])->name('clientCase.contract.object.store');
|
||||
Route::put('client-cases/{client_case:uuid}/objects/{id}', [CaseObjectController::class, 'update'])->name('clientCase.object.update');
|
||||
Route::delete('client-cases/{client_case:uuid}/objects/{id}', [CaseObjectController::class, 'destroy'])->name('clientCase.object.delete');
|
||||
//client-case / activity
|
||||
// client-case / activity
|
||||
Route::post('client-cases/{client_case:uuid}/activity', [ClientCaseContoller::class, 'storeActivity'])->name('clientCase.activity.store');
|
||||
// client-case / segments
|
||||
Route::post('client-cases/{client_case:uuid}/segments', [ClientCaseContoller::class, 'attachSegment'])->name('clientCase.segments.attach');
|
||||
//client-case / documents
|
||||
// client-case / documents
|
||||
Route::post('client-cases/{client_case:uuid}/documents', [ClientCaseContoller::class, 'storeDocument'])->name('clientCase.document.store');
|
||||
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewDocument'])->name('clientCase.document.view');
|
||||
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/download', [ClientCaseContoller::class, 'downloadDocument'])->name('clientCase.document.download');
|
||||
//settings
|
||||
// contract / documents (direct access by contract)
|
||||
Route::get('contracts/{contract:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewContractDocument'])->name('contract.document.view');
|
||||
Route::get('contracts/{contract:uuid}/documents/{document:uuid}/download', [ClientCaseContoller::class, 'downloadContractDocument'])->name('contract.document.download');
|
||||
// settings
|
||||
Route::get('settings', [SettingController::class, 'index'])->name('settings');
|
||||
Route::get('settings/segments', [SegmentController::class, 'settings'])->name('settings.segments');
|
||||
Route::post('settings/segments', [SegmentController::class, 'store'])->name('settings.segments.store');
|
||||
Route::put('settings/segments/{segment}', [SegmentController::class, 'update'])->name('settings.segments.update');
|
||||
Route::get('settings/workflow', [WorkflowController::class, 'index'])->name('settings.workflow');
|
||||
Route::get('settings/field-job', [FieldJobSettingController::class, 'index'])->name('settings.fieldjob.index');
|
||||
|
||||
// field jobs assignment
|
||||
Route::get('field-jobs', [FieldJobController::class, 'index'])->name('fieldjobs.index');
|
||||
Route::post('field-jobs/assign', [FieldJobController::class, 'assign'])->name('fieldjobs.assign');
|
||||
Route::post('field-jobs/cancel', [FieldJobController::class, 'cancel'])->name('fieldjobs.cancel');
|
||||
Route::post('settings/field-job', [FieldJobSettingController::class, 'store'])->name('settings.fieldjob.store');
|
||||
Route::put('settings/field-job/{setting}', [FieldJobSettingController::class, 'update'])->name('settings.fieldjob.update');
|
||||
// settings / contract-configs
|
||||
Route::get('settings/contract-configs', [ContractConfigController::class, 'index'])->name('settings.contractConfigs.index');
|
||||
Route::post('settings/contract-configs', [ContractConfigController::class, 'store'])->name('settings.contractConfigs.store');
|
||||
|
|
@ -181,14 +195,14 @@
|
|||
Route::delete('import-templates/{template:uuid}/mappings/{mapping}', [ImportTemplateController::class, 'deleteMapping'])->name('importTemplates.mappings.delete');
|
||||
Route::post('import-templates/{template:uuid}/mappings/reorder', [ImportTemplateController::class, 'reorderMappings'])->name('importTemplates.mappings.reorder');
|
||||
Route::post('import-templates/{template}/apply/{import}', [ImportTemplateController::class, 'applyToImport'])->name('importTemplates.apply');
|
||||
//Route::put()
|
||||
//types
|
||||
// Route::put()
|
||||
// types
|
||||
|
||||
Route::get('types/address', function(Request $request){
|
||||
Route::get('types/address', function (Request $request) {
|
||||
$types = App\Models\Person\AddressType::all();
|
||||
|
||||
return response()->json([
|
||||
'types' => $types
|
||||
'types' => $types,
|
||||
]);
|
||||
})->name('types.address');
|
||||
|
||||
|
|
|
|||
17
tests/CreatesApplication.php
Normal file
17
tests/CreatesApplication.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
|
||||
trait CreatesApplication
|
||||
{
|
||||
public function createApplication()
|
||||
{
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
161
tests/Feature/FieldJobIndexFilterTest.php
Normal file
161
tests/Feature/FieldJobIndexFilterTest.php
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Decision;
|
||||
use App\Models\FieldJobSetting;
|
||||
use App\Models\Segment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FieldJobIndexFilterTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_shows_only_contracts_in_primary_or_queue_segments(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$primary = Segment::factory()->create();
|
||||
$queue = Segment::factory()->create();
|
||||
|
||||
FieldJobSetting::query()->create([
|
||||
'segment_id' => $primary->id,
|
||||
'initial_decision_id' => Decision::factory()->create()->id,
|
||||
'assign_decision_id' => Decision::factory()->create()->id,
|
||||
'complete_decision_id' => Decision::factory()->create()->id,
|
||||
'queue_segment_id' => $queue->id,
|
||||
]);
|
||||
|
||||
$case = ClientCase::factory()->create();
|
||||
|
||||
$inPrimary = Contract::factory()->create(['client_case_id' => $case->id]);
|
||||
$inQueue = Contract::factory()->create(['client_case_id' => $case->id]);
|
||||
$inOther = Contract::factory()->create(['client_case_id' => $case->id]);
|
||||
|
||||
DB::table('contract_segment')->insert([
|
||||
['contract_id' => $inPrimary->id, 'segment_id' => $primary->id, 'active' => true, 'created_at' => now(), 'updated_at' => now()],
|
||||
['contract_id' => $inQueue->id, 'segment_id' => $queue->id, 'active' => true, 'created_at' => now(), 'updated_at' => now()],
|
||||
['contract_id' => $inOther->id, 'segment_id' => Segment::factory()->create()->id, 'active' => true, 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$response = $this->get(route('fieldjobs.index'));
|
||||
$response->assertStatus(200);
|
||||
|
||||
$response->assertInertia(function (Assert $page) use ($inPrimary, $inQueue, $inOther) {
|
||||
$contracts = collect($page->toArray()['props']['contracts']);
|
||||
$uuids = $contracts->pluck('uuid')->all();
|
||||
|
||||
$this->assertContains($inPrimary->uuid, $uuids);
|
||||
$this->assertContains($inQueue->uuid, $uuids);
|
||||
$this->assertNotContains($inOther->uuid, $uuids);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_returns_no_contracts_when_there_is_no_field_job_setting(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$case = ClientCase::factory()->create();
|
||||
$contract = Contract::factory()->create(['client_case_id' => $case->id]);
|
||||
|
||||
$seg = Segment::factory()->create();
|
||||
DB::table('contract_segment')->insert([
|
||||
'contract_id' => $contract->id, 'segment_id' => $seg->id, 'active' => true, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('fieldjobs.index'));
|
||||
$response->assertStatus(200);
|
||||
|
||||
$response->assertInertia(function (Assert $page) {
|
||||
$contracts = collect($page->toArray()['props']['contracts']);
|
||||
$this->assertTrue($contracts->isEmpty());
|
||||
});
|
||||
}
|
||||
|
||||
public function test_returns_no_contracts_when_setting_has_no_segments_configured(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
FieldJobSetting::query()->create([
|
||||
'segment_id' => null,
|
||||
'queue_segment_id' => null,
|
||||
'initial_decision_id' => Decision::factory()->create()->id,
|
||||
'assign_decision_id' => Decision::factory()->create()->id,
|
||||
'complete_decision_id' => Decision::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$case = ClientCase::factory()->create();
|
||||
$contract = Contract::factory()->create(['client_case_id' => $case->id]);
|
||||
|
||||
$seg = Segment::factory()->create();
|
||||
DB::table('contract_segment')->insert([
|
||||
'contract_id' => $contract->id, 'segment_id' => $seg->id, 'active' => true, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('fieldjobs.index'));
|
||||
$response->assertStatus(200);
|
||||
|
||||
$response->assertInertia(function (Assert $page) {
|
||||
$contracts = collect($page->toArray()['props']['contracts']);
|
||||
$this->assertTrue($contracts->isEmpty());
|
||||
});
|
||||
}
|
||||
|
||||
public function test_cancelling_moves_contract_to_queue_segment(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$primary = Segment::factory()->create();
|
||||
$queue = Segment::factory()->create();
|
||||
|
||||
$setting = FieldJobSetting::query()->create([
|
||||
'segment_id' => $primary->id,
|
||||
'queue_segment_id' => $queue->id,
|
||||
'initial_decision_id' => Decision::factory()->create()->id,
|
||||
'assign_decision_id' => Decision::factory()->create()->id,
|
||||
'complete_decision_id' => Decision::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$case = ClientCase::factory()->create();
|
||||
$contract = Contract::factory()->create(['client_case_id' => $case->id]);
|
||||
|
||||
// Ensure contract is in primary segment initially
|
||||
DB::table('contract_segment')->insert([
|
||||
'contract_id' => $contract->id, 'segment_id' => $primary->id, 'active' => true, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Create an active field job for the contract
|
||||
\App\Models\FieldJob::create([
|
||||
'field_job_setting_id' => $setting->id,
|
||||
'assigned_user_id' => $user->id,
|
||||
'contract_id' => $contract->id,
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
// Cancel via controller route
|
||||
$response = $this->post(route('fieldjobs.cancel'), [
|
||||
'contract_uuid' => $contract->uuid,
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors();
|
||||
|
||||
// After cancellation, the model hook should move the contract to queue segment (ensure pivot exists and active)
|
||||
$pivot = DB::table('contract_segment')
|
||||
->where('contract_id', $contract->id)
|
||||
->where('segment_id', $queue->id)
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($pivot, 'Queue segment pivot should be created/ensured');
|
||||
$this->assertTrue((bool) $pivot->active, 'Queue segment pivot should be active');
|
||||
}
|
||||
}
|
||||
75
tests/Feature/ImportEmailTest.php
Normal file
75
tests/Feature/ImportEmailTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Email;
|
||||
use App\Models\Import;
|
||||
use App\Models\Person\PersonType;
|
||||
use App\Models\User;
|
||||
use App\Services\ImportProcessor;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
it('imports email contact when only email mapping is present', function () {
|
||||
// Authenticate a user so Person::creating can set user_id
|
||||
$user = User::factory()->create();
|
||||
Auth::login($user);
|
||||
|
||||
// Minimal records for defaults used by ImportProcessor
|
||||
DB::table('person_groups')->insert([
|
||||
'name' => 'default',
|
||||
'description' => '',
|
||||
'color_tag' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
PersonType::firstOrCreate(['name' => 'default'], ['description' => '']);
|
||||
|
||||
// Put the sample CSV into the local storage the importer uses
|
||||
$uuid = (string) Str::uuid();
|
||||
$disk = 'local';
|
||||
$path = "imports/{$uuid}.csv";
|
||||
$content = file_get_contents(resource_path('examples/sample_import.csv'));
|
||||
Storage::disk($disk)->put($path, $content);
|
||||
|
||||
// Create the import record
|
||||
$import = Import::create([
|
||||
'uuid' => $uuid,
|
||||
'user_id' => null,
|
||||
'import_template_id' => null,
|
||||
'client_id' => null,
|
||||
'source_type' => 'csv',
|
||||
'file_name' => basename($path),
|
||||
'original_name' => 'sample_import.csv',
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'size' => strlen($content),
|
||||
'status' => 'parsed',
|
||||
'meta' => [
|
||||
'has_header' => true,
|
||||
'detected_delimiter' => ',',
|
||||
'columns' => ['reference', 'first name', 'last name', 'address', 'phone number', 'email', 'invoice date', 'due date', 'amount'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Attach a single mapping: source 'email' -> target 'email.value', trim, apply both
|
||||
DB::table('import_mappings')->insert([
|
||||
'import_id' => $import->id,
|
||||
'entity' => 'emails',
|
||||
'source_column' => 'email',
|
||||
'target_field' => 'email.value',
|
||||
'transform' => 'trim',
|
||||
'apply_mode' => 'both',
|
||||
'options' => null,
|
||||
'position' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Process the import
|
||||
$result = (new ImportProcessor)->process($import, null);
|
||||
expect($result['ok'])->toBeTrue();
|
||||
|
||||
// Assert that the first row's email exists in the database
|
||||
expect(Email::where('value', 'john.doe@example.com')->exists())->toBeTrue();
|
||||
});
|
||||
5
tests/Pest.php
Normal file
5
tests/Pest.php
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class)->in('Feature', 'Unit');
|
||||
|
|
@ -6,8 +6,5 @@
|
|||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
public function testBasicTest(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
use CreatesApplication;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user