From 79b3e20b02706d5ac0859f92537ad94553ae2320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Mon, 13 Oct 2025 21:14:10 +0200 Subject: [PATCH] Changes to import and notifications --- .../ActivityNotificationController.php | 41 ++ app/Http/Controllers/ClientCaseContoller.php | 2 +- app/Http/Controllers/ClientController.php | 6 +- app/Http/Controllers/ImportController.php | 114 +++++ .../Controllers/NotificationController.php | 75 ++++ app/Http/Middleware/HandleInertiaRequests.php | 41 +- app/Models/ActivityNotificationRead.php | 28 ++ app/Models/Import.php | 3 +- app/Services/ImportProcessor.php | 54 ++- app/Services/ImportSimulationService.php | 138 ++++++- ...0000_add_show_missing_to_imports_table.php | 26 ++ ...eate_activity_notification_reads_table.php | 33 ++ ...eate_activity_notification_reads_table.php | 34 ++ .../Components/DataTable/DataTableClient.vue | 375 +++++++++++++++++ .../Components/DataTable/DataTableServer.vue | 390 ++++++++++++++++++ .../js/Layouts/Partials/NotificationsBell.vue | 109 +++-- resources/js/Pages/Cases/Index.vue | 169 ++++---- .../js/Pages/Cases/Partials/ActivityTable.vue | 16 +- resources/js/Pages/Cases/Show.vue | 1 - resources/js/Pages/Client/Contracts.vue | 189 ++++----- resources/js/Pages/Client/Index.vue | 162 ++++---- resources/js/Pages/Client/Show.vue | 191 ++++----- resources/js/Pages/Imports/Import.vue | 158 +++++++ .../Imports/Partials/SimulationModal.vue | 34 +- resources/js/Pages/Notifications/Unread.vue | 102 +++++ resources/js/Pages/Testing/Index.vue | 93 ++++- routes/breadcrumbs.php | 19 +- routes/web.php | 8 + 28 files changed, 2173 insertions(+), 438 deletions(-) create mode 100644 app/Http/Controllers/ActivityNotificationController.php create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 app/Models/ActivityNotificationRead.php create mode 100644 database/migrations/2025_10_13_090000_add_show_missing_to_imports_table.php create mode 100644 database/migrations/2025_10_13_164417_create_activity_notification_reads_table.php create mode 100644 database/migrations/2025_10_13_164513_create_activity_notification_reads_table.php create mode 100644 resources/js/Components/DataTable/DataTableClient.vue create mode 100644 resources/js/Components/DataTable/DataTableServer.vue create mode 100644 resources/js/Pages/Notifications/Unread.vue diff --git a/app/Http/Controllers/ActivityNotificationController.php b/app/Http/Controllers/ActivityNotificationController.php new file mode 100644 index 0000000..4445385 --- /dev/null +++ b/app/Http/Controllers/ActivityNotificationController.php @@ -0,0 +1,41 @@ +validate([ + 'activity_id' => ['required', 'integer', 'exists:activities,id'], + ]); + + $userId = optional($request->user())->id; + if (! $userId) { + abort(403); + } + + $activity = Activity::query()->select(['id', 'due_date'])->findOrFail($request->integer('activity_id')); + $due = optional($activity->due_date) ? date('Y-m-d', strtotime($activity->due_date)) : now()->toDateString(); + + ActivityNotificationRead::query()->updateOrCreate( + [ + 'user_id' => $userId, + 'activity_id' => $activity->id, + 'due_date' => $due, + ], + [ + 'read_at' => now(), + ] + ); + + return response()->json(['status' => 'ok']); + } +} diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index cf027c0..8f72fa4 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -57,7 +57,7 @@ public function index(ClientCase $clientCase, Request $request) return Inertia::render('Cases/Index', [ 'client_cases' => $query - ->paginate(15, ['*'], 'client-cases-page') + ->paginate($request->integer('perPage', 15), ['*'], 'client-cases-page') ->withQueryString(), 'filters' => $request->only(['search']), ]); diff --git a/app/Http/Controllers/ClientController.php b/app/Http/Controllers/ClientController.php index 924f2be..ce2efd4 100644 --- a/app/Http/Controllers/ClientController.php +++ b/app/Http/Controllers/ClientController.php @@ -53,7 +53,7 @@ public function index(Client $client, Request $request) return Inertia::render('Client/Index', [ 'clients' => $query - ->paginate(15) + ->paginate($request->integer('perPage', 15)) ->withQueryString(), 'filters' => $request->only(['search']), ]); @@ -104,7 +104,7 @@ public function show(Client $client, Request $request) ]) ->where('active', 1) ->orderByDesc('created_at') - ->paginate(15) + ->paginate($request->integer('perPage', 15)) ->withQueryString(), 'types' => $types, 'filters' => $request->only(['search']), @@ -158,7 +158,7 @@ public function contracts(Client $client, Request $request) return Inertia::render('Client/Contracts', [ 'client' => $data, - 'contracts' => $contractsQuery->paginate(20)->withQueryString(), + 'contracts' => $contractsQuery->paginate($request->integer('perPage', 20))->withQueryString(), 'filters' => $request->only(['from', 'to', 'search']), 'types' => $types, ]); diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index 9c0a852..23aba38 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -146,6 +146,7 @@ public function store(Request $request) 'size' => $file->getSize(), 'sheet_name' => $validated['sheet_name'] ?? null, 'status' => 'uploaded', + 'show_missing' => false, 'meta' => [ 'has_header' => $validated['has_header'] ?? true, ], @@ -155,6 +156,7 @@ public function store(Request $request) 'id' => $import->id, 'uuid' => $import->uuid, 'status' => $import->status, + 'show_missing' => (bool) ($import->show_missing ?? false), ]); } @@ -354,6 +356,116 @@ public function getMappings(Import $import) return response()->json(['mappings' => $rows]); } + /** + * List active, non-archived contracts for the import's client that are NOT present + * in the processed import file (based on mapped contract.reference values). + * Only available when contract.reference mapping apply_mode is 'keyref'. + */ + public function missingContracts(Import $import) + { + // Ensure client context is available + if (empty($import->client_id)) { + return response()->json(['error' => 'Import has no client bound.'], 422); + } + + // Respect optional feature flag on import + if (! (bool) ($import->show_missing ?? false)) { + return response()->json(['error' => 'Missing contracts listing is disabled for this import.'], 422); + } + + // Check that this import's mappings set contract.reference to keyref mode + $mappings = \DB::table('import_mappings') + ->where('import_id', $import->id) + ->get(['target_field', 'apply_mode']); + $isKeyref = false; + foreach ($mappings as $map) { + $tf = strtolower((string) ($map->target_field ?? '')); + $am = strtolower((string) ($map->apply_mode ?? '')); + if (in_array($tf, ['contract.reference', 'contracts.reference'], true) && $am === 'keyref') { + $isKeyref = true; + break; + } + } + if (! $isKeyref) { + return response()->json(['error' => 'Missing contracts are only available for keyref mapping on contract.reference.'], 422); + } + + // Collect referenced contract references from processed rows + $present = []; + foreach (\App\Models\ImportRow::query()->where('import_id', $import->id)->get(['mapped_data']) as $row) { + $md = $row->mapped_data ?? []; + if (is_array($md) && isset($md['contract']['reference'])) { + $ref = (string) $md['contract']['reference']; + if ($ref !== '') { + $present[] = preg_replace('/\s+/', '', trim($ref)); + } + } + } + $present = array_values(array_unique(array_filter($present))); + + // Query active, non-archived contracts for this client that were not in import + // Include person full_name (owner of the client case) and aggregate active accounts' balance_amount + $contractsQ = \App\Models\Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->join('person', 'person.id', '=', 'client_cases.person_id') + ->leftJoin('accounts', function ($join) { + $join->on('accounts.contract_id', '=', 'contracts.id') + ->where('accounts.active', 1); + }) + ->where('client_cases.client_id', $import->client_id) + ->where('contracts.active', 1) + ->whereNull('contracts.deleted_at') + ->when(count($present) > 0, function ($q) use ($present) { + $q->whereNotIn('contracts.reference', $present); + }) + ->groupBy('contracts.uuid', 'contracts.reference', 'client_cases.uuid', 'person.full_name') + ->orderBy('contracts.reference') + ->get([ + 'contracts.uuid as uuid', + 'contracts.reference as reference', + 'client_cases.uuid as case_uuid', + 'person.full_name as full_name', + \DB::raw('COALESCE(SUM(accounts.balance_amount), 0) as balance_amount'), + ]); + + return response()->json([ + 'missing' => $contractsQ, + 'count' => $contractsQ->count(), + ]); + } + + /** + * Update import options (e.g., booleans like show_missing, reactivate) from the UI. + */ + public function updateOptions(Request $request, Import $import) + { + $data = $request->validate([ + 'show_missing' => 'nullable|boolean', + 'reactivate' => 'nullable|boolean', + ]); + + $payload = []; + if (array_key_exists('show_missing', $data)) { + $payload['show_missing'] = (bool) $data['show_missing']; + } + if (array_key_exists('reactivate', $data)) { + $payload['reactivate'] = (bool) $data['reactivate']; + } + if (! empty($payload)) { + $import->update($payload); + } + + return response()->json([ + 'ok' => true, + 'import' => [ + 'id' => $import->id, + 'uuid' => $import->uuid, + 'show_missing' => (bool) ($import->show_missing ?? false), + 'reactivate' => (bool) ($import->reactivate ?? false), + ], + ]); + } + // Fetch recent import events (logs) for an import public function getEvents(Import $import) { @@ -533,6 +645,8 @@ public function show(Import $import) 'client_id' => $import->client_id, 'client_uuid' => optional($client)->uuid, 'import_template_id' => $import->import_template_id, + 'show_missing' => (bool) ($import->show_missing ?? false), + 'reactivate' => (bool) ($import->reactivate ?? false), 'total_rows' => $import->total_rows, 'imported_rows' => $import->imported_rows, 'invalid_rows' => $import->invalid_rows, diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..19d8b64 --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,75 @@ +user(); + if (! $user) { + abort(403); + } + + $today = now()->toDateString(); + $perPage = max(1, min(100, (int) $request->integer('perPage', 15))); + $search = trim((string) $request->input('search', '')); + + $query = Activity::query() + ->select(['id', 'due_date', 'amount', 'contract_id', 'client_case_id', 'created_at']) + ->whereNotNull('due_date') + ->whereDate('due_date', '<=', $today) + ->whereNotExists(function ($q) use ($user) { + $q->from('activity_notification_reads as anr') + ->whereColumn('anr.activity_id', 'activities.id') + ->where('anr.user_id', $user->id) + ->whereColumn('anr.due_date', 'activities.due_date'); + }) + // allow simple search by contract reference or person name + ->when($search !== '', function ($q) use ($search) { + $s = mb_strtolower($search); + $q->leftJoin('contracts', 'contracts.id', '=', 'activities.contract_id') + ->leftJoin('client_cases', 'client_cases.id', '=', 'activities.client_case_id') + ->leftJoin('person', 'person.id', '=', 'client_cases.person_id') + ->where(function ($qq) use ($s) { + $qq->whereRaw('LOWER(COALESCE(contracts.reference, \'\')) LIKE ?', ['%'.$s.'%']) + ->orWhereRaw('LOWER(COALESCE(person.full_name, \'\')) LIKE ?', ['%'.$s.'%']); + }); + }) + ->with([ + 'contract' => function ($q) { + $q->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.client_case_id']) + ->with([ + 'clientCase' => function ($qq) { + $qq->select(['client_cases.id', 'client_cases.uuid']); + }, + 'account' => function ($qq) { + $qq->select(['accounts.id', 'accounts.contract_id', 'accounts.balance_amount', 'accounts.initial_amount']); + }, + ]); + }, + 'clientCase' => function ($q) { + $q->select(['client_cases.id', 'client_cases.uuid', 'client_cases.person_id']) + ->with([ + 'person' => function ($qq) { + $qq->select(['person.id', 'person.full_name']); + }, + ]); + }, + ]) + // force ordering by due_date DESC only + ->orderByDesc('activities.due_date'); + + // Use a custom page parameter name to match the frontend DataTableServer + $activities = $query->paginate($perPage, ['*'], 'unread-page')->withQueryString(); + + return Inertia::render('Notifications/Unread', [ + 'activities' => $activities, + 'today' => $today, + ]); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index e9f46db..f055877 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -66,21 +66,44 @@ public function share(Request $request): array } $today = now()->toDateString(); + + // Base fetch to avoid serialization issues; eager load relations afterwards $activities = \App\Models\Activity::query() - ->with([ - // Include contract uuid and reference, keep id for relation mapping, and client_case_id for nested eager load - 'contract:id,uuid,reference,client_case_id', - // Include client case uuid (id required for mapping, will be hidden in JSON) - 'contract.clientCase:id,uuid', - // Include account amounts; contract_id needed for relation mapping - 'contract.account:contract_id,balance_amount,initial_amount', - ]) + ->select(['id', 'due_date', 'amount', 'contract_id', 'client_case_id', 'created_at']) ->whereDate('due_date', $today) - ->where('user_id', $user->id) + ->whereNotExists(function ($q) use ($request) { + $q->from('activity_notification_reads as anr') + ->whereColumn('anr.activity_id', 'activities.id') + ->where('anr.user_id', optional($request->user())->id) + ->whereColumn('anr.due_date', 'activities.due_date'); + }) ->orderBy('created_at') ->limit(20) ->get(); + // Eager load needed relations (contracts and client cases) with qualified selects + $activities->load([ + 'contract' => function ($q) { + $q->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.client_case_id']) + ->with([ + 'clientCase' => function ($qq) { + $qq->select(['client_cases.id', 'client_cases.uuid']); + }, + 'account' => function ($qq) { + $qq->select(['accounts.id', 'accounts.contract_id', 'accounts.balance_amount', 'accounts.initial_amount']); + }, + ]); + }, + 'clientCase' => function ($q) { + $q->select(['client_cases.id', 'client_cases.uuid', 'client_cases.person_id']) + ->with([ + 'person' => function ($qq) { + $qq->select(['person.id', 'person.full_name']); + }, + ]); + }, + ]); + return [ 'dueToday' => [ 'count' => $activities->count(), diff --git a/app/Models/ActivityNotificationRead.php b/app/Models/ActivityNotificationRead.php new file mode 100644 index 0000000..8ca602d --- /dev/null +++ b/app/Models/ActivityNotificationRead.php @@ -0,0 +1,28 @@ + 'date', + 'read_at' => 'datetime', + ]; + + public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(User::class); + } + + public function activity(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(Activity::class); + } +} diff --git a/app/Models/Import.php b/app/Models/Import.php index 1072869..e2d051c 100644 --- a/app/Models/Import.php +++ b/app/Models/Import.php @@ -12,7 +12,7 @@ class Import extends Model use HasFactory; protected $fillable = [ - 'uuid', 'user_id', 'import_template_id', 'client_id', 'source_type', 'file_name', 'original_name', 'disk', 'path', 'size', 'sheet_name', 'status', 'reactivate', 'total_rows', 'valid_rows', 'invalid_rows', 'imported_rows', 'started_at', 'finished_at', 'failed_at', 'error_summary', 'meta', + 'uuid', 'user_id', 'import_template_id', 'client_id', 'source_type', 'file_name', 'original_name', 'disk', 'path', 'size', 'sheet_name', 'status', 'reactivate', 'show_missing', 'total_rows', 'valid_rows', 'invalid_rows', 'imported_rows', 'started_at', 'finished_at', 'failed_at', 'error_summary', 'meta', ]; protected $casts = [ @@ -22,6 +22,7 @@ class Import extends Model 'finished_at' => 'datetime', 'failed_at' => 'datetime', 'reactivate' => 'boolean', + 'show_missing' => 'boolean', ]; public function user(): BelongsTo diff --git a/app/Services/ImportProcessor.php b/app/Services/ImportProcessor.php index 6f99f5f..6b648cd 100644 --- a/app/Services/ImportProcessor.php +++ b/app/Services/ImportProcessor.php @@ -499,7 +499,7 @@ public function process(Import $import, ?Authenticatable $user = null): array $contractKeyMode = $tplMeta['contract_key_mode'] ?? null; if (! $accountIdForPayment && $paymentsImport && $contractKeyMode === 'reference') { $contractRef = $mapped['contract']['reference'] ?? null; - if ($contractRef) { + if (! $contractId) { $contract = \App\Models\Contract::query() ->when($import->client_id, function ($q, $clientId) { $q->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') @@ -508,9 +508,25 @@ public function process(Import $import, ?Authenticatable $user = null): array ->where('contracts.reference', $contractRef) ->select('contracts.id') ->first(); - if ($contract) { - $accountIdForPayment = \App\Models\Account::where('contract_id', $contract->id)->value('id'); + } elseif ($hasContractRoot) { + // If mapping for contract.reference is keyref, do NOT create contract – lookup only + $refMode = $this->mappingMode($mappings, 'contract.reference'); + if ($refMode === 'keyref') { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => null, + 'event' => 'row_skipped', + 'level' => 'warning', + 'message' => 'Contract reference '.$contractRef.' does not exist (keyref); row skipped.', + ]); + + return [ + 'action' => 'skipped', + 'message' => 'contract.reference keyref lookup failed: not found', + ]; } + /* Lines 1242-1269 omitted */ + $contractId = $createdContract->id; } } @@ -1583,6 +1599,20 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): } } + // If contract not found and contract.reference is keyref, skip without creating entities + $refMode = $this->mappingMode($mappings, 'contract.reference'); + if (! $existing && $refMode === 'keyref') { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => null, + 'event' => 'row_skipped', + 'level' => 'warning', + 'message' => 'Contract reference '.$reference.' does not exist (keyref); row skipped.', + ]); + + return ['action' => 'skipped', 'message' => 'contract.reference keyref lookup failed: not found']; + } + if ($existing) { // 1) Prepare contract field changes (non-null) $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); @@ -2040,6 +2070,24 @@ private function normalizeTargetField(string $target, array $rootAliasMap, array return $bracket !== null ? ($root.'['.$bracket.']') : $root; } + /** + * Get apply mode for a specific mapping target field, normalized via normalizeMappings beforehand. + * Returns lowercased mode string like 'insert', 'update', 'both', 'keyref', or null if not present. + */ + private function mappingMode($mappings, string $targetField): ?string + { + foreach ($mappings as $map) { + $target = (string) ($map->target_field ?? ''); + if ($target === $targetField) { + $mode = $map->apply_mode ?? null; + + return is_string($mode) ? strtolower($mode) : null; + } + } + + return null; + } + protected function loadImportEntityConfig(): array { $entities = ImportEntity::all(); diff --git a/app/Services/ImportSimulationService.php b/app/Services/ImportSimulationService.php index 7126d2b..713bb47 100644 --- a/app/Services/ImportSimulationService.php +++ b/app/Services/ImportSimulationService.php @@ -20,6 +20,11 @@ */ class ImportSimulationService { + /** + * Optional client scoping for lookups during a simulation run. + */ + private ?int $clientId = null; + /** * Public entry: simulate import applying mappings to first $limit rows. * Keeps existing machine keys for backward compatibility, but adds Slovenian @@ -27,6 +32,8 @@ class ImportSimulationService */ public function simulate(Import $import, int $limit = 100, bool $verbose = false): array { + // Store client context for the duration of this simulation + $this->clientId = $import->client_id ?: null; $meta = $import->meta ?? []; $hasHeader = (bool) ($meta['has_header'] ?? true); $delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ','; @@ -70,9 +77,14 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false $translatedStatuses = $this->statusTranslations(); $simRows = []; + // Determine keyref behavior for contract.reference from mappings/template + $tplMeta = optional($import->template)->meta ?? []; + $contractKeyModeTpl = $tplMeta['contract_key_mode'] ?? null; // e.g. 'reference' + $contractRefMode = $this->mappingModeForImport($import, 'contract.reference'); // e.g. 'keyref' foreach ($rows as $idx => $rawValues) { $assoc = $this->associateRow($columns, $rawValues); $rowEntities = []; + $keyrefSkipRow = false; // if true, downstream creations are skipped for this row // Reactivation intent detection (row > import > template) $rowReactivate = false; @@ -139,6 +151,24 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false $contractEntity['action'] = 'reactivate'; $contractEntity['reactivation'] = true; } + // Keyref enforcement: if mapping is keyref (or template says reference) and contract doesn't exist, skip row creations + $ref = $contractEntity['reference'] ?? null; + if (($contractRefMode === 'keyref' || $contractKeyModeTpl === 'reference') + && ($contractEntity['action'] === 'create') + ) { + // Adjust summaries: revert create -> invalid + if (isset($summaries['contract'])) { + if (($summaries['contract']['create'] ?? 0) > 0) { + $summaries['contract']['create']--; + } + $summaries['contract']['invalid'] = ($summaries['contract']['invalid'] ?? 0) + 1; + } + $contractEntity['original_action'] = 'create'; + $contractEntity['action'] = 'skip'; + $contractEntity['warning'] = 'Contract reference '.(string) $ref.' does not exist (keyref); row skipped.'; + $contractEntity['skipped_due_to_keyref'] = true; + $keyrefSkipRow = true; + } // Attach contract meta preview from mappings (group-aware) $metaGroups = []; // Grouped contract.meta.* via groupedLookup @@ -199,6 +229,19 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false } } [$accountEntity, $summaries, $accountCache] = $this->simulateAccount($val, $summaries, $accountCache, $rawAccountRef); + // If row is being skipped due to keyref missing contract, do not create accounts + if ($keyrefSkipRow && ($accountEntity['action'] ?? null) === 'create') { + if (isset($summaries['account'])) { + if (($summaries['account']['create'] ?? 0) > 0) { + $summaries['account']['create']--; + } + $summaries['account']['invalid'] = ($summaries['account']['invalid'] ?? 0) + 1; + } + $accountEntity['original_action'] = 'create'; + $accountEntity['action'] = 'skip'; + $accountEntity['warning'] = 'Skipped due to missing contract.reference in keyref mode.'; + $accountEntity['skipped_due_to_keyref'] = true; + } if ($inherited) { $accountEntity['inherited_reference'] = true; } @@ -212,6 +255,10 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false // Generic roots (person, address, email, phone, client_case, etc.) excluding already handled ones foreach (array_keys($entityRoots) as $rootKey) { + // If keyref skip is active for this row, suppress downstream creations (person, client_case, etc.) + if ($keyrefSkipRow && ! in_array($rootKey, ['contract', 'account', 'payment'], true)) { + continue; + } if (in_array($rootKey, ['contract', 'account', 'payment'], true)) { continue; // already simulated explicitly } @@ -220,6 +267,16 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false if ($existingContract && in_array($rootKey, ['person', 'client_case'], true)) { continue; } + // Special-case: when contract exists and email/phone/address mapping uses the same column as contract.reference, + // treat it as a root declaration only and defer to chain attachments instead of generic simulation. + if ($existingContract && in_array($rootKey, ['email', 'phone', 'address'], true)) { + $crSrc = $targetToSource['contract.reference'] ?? null; + $rk = $rootKey === 'email' ? 'email.value' : ($rootKey === 'phone' ? 'phone.nu' : 'address.address'); + $rkSrc = $targetToSource[$rk] ?? null; + if ($crSrc !== null && $rkSrc !== null && $crSrc === $rkSrc) { + continue; + } + } $reference = $val($rootKey.'.reference'); $identityCandidates = $this->genericIdentityCandidates($rootKey, $val); if (isset($multiRoots[$rootKey]) && $multiRoots[$rootKey] === true) { @@ -237,12 +294,18 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false $verbose, $targetToSource ); - // Add action labels and attach - $rowEntities[$rootKey] = array_map(function ($ent) use ($translatedActions) { + // Add action labels + $items = array_map(function ($ent) use ($translatedActions) { $ent['action_label'] = $translatedActions[$ent['action']] ?? $ent['action']; return $ent; }, $items); + // If only a single, ungrouped item, flatten to a single entity for convenience + if (count($items) === 1 && (($items[0]['group'] ?? '') === '')) { + $rowEntities[$rootKey] = $items[0]; + } else { + $rowEntities[$rootKey] = $items; + } } else { [$genericEntity, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities] = $this->simulateGenericRoot( @@ -491,6 +554,10 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false if (isset($rowEntities['payment']['status']) && $rowEntities['payment']['status'] !== 'ok') { $rowStatus = $rowEntities['payment']['status']; } + // If we skipped due to keyref, surface a warning status for the row if not already set + if ($rowStatus === 'ok' && $keyrefSkipRow) { + $rowStatus = 'warning'; + } $simRows[] = [ 'index' => $idx + 1, 'entities' => $rowEntities, @@ -806,7 +873,14 @@ private function simulateContract(callable $val, array $summaries, array $cache, if (array_key_exists($reference, $cache)) { $contract = $cache[$reference]; } else { - $contract = Contract::query()->where('reference', $reference)->first(['id', 'reference', 'client_case_id', 'active', 'deleted_at']); + $q = Contract::query()->where('reference', $reference); + // Scope to selected client when available + if (! is_null($this->clientId)) { + $q->whereHas('clientCase', function ($qq): void { + $qq->where('client_id', $this->clientId); + }); + } + $contract = $q->first(['id', 'reference', 'client_case_id', 'active', 'deleted_at']); $cache[$reference] = $contract; // may be null } } @@ -838,10 +912,16 @@ private function simulateAccount(callable $val, array $summaries, array $cache, if (array_key_exists($reference, $cache)) { $account = $cache[$reference]; } else { - $account = Account::query() + $q = Account::query() ->where('reference', $reference) - ->where('active', 1) - ->first(['id', 'reference', 'balance_amount']); + ->where('active', 1); + // Scope to selected client when available via contract -> clientCase + if (! is_null($this->clientId)) { + $q->whereHas('contract.clientCase', function ($qq): void { + $qq->where('client_id', $this->clientId); + }); + } + $account = $q->first(['id', 'reference', 'balance_amount']); $cache[$reference] = $account; } } @@ -897,6 +977,39 @@ private function simulateAccount(callable $val, array $summaries, array $cache, return [$entity, $summaries, $cache]; } + /** + * Lookup apply_mode for a mapping target field on this import. + * Returns lowercased mode like 'insert', 'update', 'both', 'keyref', or null if not found. + */ + private function mappingModeForImport(Import $import, string $targetField): ?string + { + $rows = \DB::table('import_mappings') + ->where('import_id', $import->id) + ->get(['target_field', 'apply_mode']); + foreach ($rows as $row) { + $tf = (string) ($row->target_field ?? ''); + if ($tf === '') { + continue; + } + // Normalize root part to match canonical keys like contract.reference + if (str_contains($tf, '.')) { + [$root, $rest] = explode('.', $tf, 2); + } else { + $root = $tf; + $rest = null; + } + $norm = $this->normalizeRoot($root); + $tfNorm = $rest !== null ? ($norm.'.'.$rest) : $norm; + if ($tfNorm === $targetField) { + $mode = $row->apply_mode ?? null; + + return is_string($mode) ? strtolower($mode) : null; + } + } + + return null; + } + private function simulateImplicitAccount(int $contractId, array $summaries, array $cache): array { $acct = Account::query()->where('contract_id', $contractId)->orderBy('id')->first(['id', 'reference', 'balance_amount']); @@ -1070,7 +1183,12 @@ private function simulateGenericRoot( // Try/catch to avoid issues if column doesn't exist try { if (\Schema::hasColumn((new $modelClass)->getTable(), 'reference')) { - $record = $modelClass::query()->where('reference', $reference)->first(['id', 'reference']); + $qb = $modelClass::query()->where('reference', $reference); + // Scope client_case lookups to selected client + if ($modelClass === \App\Models\ClientCase::class && ! is_null($this->clientId)) { + $qb->where('client_id', $this->clientId); + } + $record = $qb->first(['id', 'reference']); } } catch (\Throwable) { $record = null; @@ -1471,7 +1589,11 @@ private function simulateGenericRootMulti( } elseif ($modelClass && class_exists($modelClass)) { try { if (\Schema::hasColumn((new $modelClass)->getTable(), 'reference')) { - $record = $modelClass::query()->where('reference', $reference)->first(['id', 'reference']); + $qb = $modelClass::query()->where('reference', $reference); + if ($modelClass === \App\Models\ClientCase::class && ! is_null($this->clientId)) { + $qb->where('client_id', $this->clientId); + } + $record = $qb->first(['id', 'reference']); } } catch (\Throwable) { $record = null; diff --git a/database/migrations/2025_10_13_090000_add_show_missing_to_imports_table.php b/database/migrations/2025_10_13_090000_add_show_missing_to_imports_table.php new file mode 100644 index 0000000..eab1786 --- /dev/null +++ b/database/migrations/2025_10_13_090000_add_show_missing_to_imports_table.php @@ -0,0 +1,26 @@ +boolean('show_missing')->default(false)->after('reactivate'); + } + }); + } + + public function down(): void + { + Schema::table('imports', function (Blueprint $table) { + if (Schema::hasColumn('imports', 'show_missing')) { + $table->dropColumn('show_missing'); + } + }); + } +}; diff --git a/database/migrations/2025_10_13_164417_create_activity_notification_reads_table.php b/database/migrations/2025_10_13_164417_create_activity_notification_reads_table.php new file mode 100644 index 0000000..62b0640 --- /dev/null +++ b/database/migrations/2025_10_13_164417_create_activity_notification_reads_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('activity_id')->constrained()->cascadeOnDelete(); + $table->date('due_date'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'activity_id', 'due_date'], 'uniq_user_activity_due'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('activity_notification_reads'); + } +}; diff --git a/database/migrations/2025_10_13_164513_create_activity_notification_reads_table.php b/database/migrations/2025_10_13_164513_create_activity_notification_reads_table.php new file mode 100644 index 0000000..d6d0c0a --- /dev/null +++ b/database/migrations/2025_10_13_164513_create_activity_notification_reads_table.php @@ -0,0 +1,34 @@ +id(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Intentionally keep as-is to avoid dropping a table created by the primary migration + if (Schema::hasTable('activity_notification_reads')) { + Schema::dropIfExists('activity_notification_reads'); + } + } +}; diff --git a/resources/js/Components/DataTable/DataTableClient.vue b/resources/js/Components/DataTable/DataTableClient.vue new file mode 100644 index 0000000..f1b879c --- /dev/null +++ b/resources/js/Components/DataTable/DataTableClient.vue @@ -0,0 +1,375 @@ + + + diff --git a/resources/js/Components/DataTable/DataTableServer.vue b/resources/js/Components/DataTable/DataTableServer.vue new file mode 100644 index 0000000..5ca94b7 --- /dev/null +++ b/resources/js/Components/DataTable/DataTableServer.vue @@ -0,0 +1,390 @@ + + + diff --git a/resources/js/Layouts/Partials/NotificationsBell.vue b/resources/js/Layouts/Partials/NotificationsBell.vue index 75f5768..6ce9cf4 100644 --- a/resources/js/Layouts/Partials/NotificationsBell.vue +++ b/resources/js/Layouts/Partials/NotificationsBell.vue @@ -1,5 +1,5 @@