diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index d3cd2c7..09f520b 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -1059,7 +1059,7 @@ public function show(ClientCase $clientCase) // Only apply active/inactive filtering IF a segment filter is provided. $contractsQuery = $case->contracts() // Only select lean columns to avoid oversize JSON / headers (include description for UI display) - ->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'active', 'type_id', 'client_case_id', 'created_at']) + ->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at']) ->with([ 'type:id,name', // Use closure for account to avoid ambiguous column names with latestOfMany join diff --git a/app/Http/Controllers/ClientController.php b/app/Http/Controllers/ClientController.php index 4a6da78..4e90441 100644 --- a/app/Http/Controllers/ClientController.php +++ b/app/Http/Controllers/ClientController.php @@ -78,8 +78,7 @@ public function show(Client $client, Request $request) ->when($request->input('search'), fn ($que, $search) => $que->whereHas( 'person', fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%') - ) - ) + )) ->addSelect([ 'active_contracts_count' => \DB::query() ->from('contracts') @@ -112,6 +111,59 @@ public function show(Client $client, Request $request) ]); } + public function contracts(Client $client, Request $request) + { + $data = $client->load(['person' => fn ($q) => $q->with(['addresses', 'phones', 'bankAccounts', 'emails'])]); + + $from = $request->input('from'); + $to = $request->input('to'); + $search = $request->input('search'); + + $contractsQuery = \App\Models\Contract::query() + ->whereHas('clientCase', function ($q) use ($client) { + $q->where('client_id', $client->id); + }) + ->with([ + 'clientCase:id,uuid,person_id', + 'clientCase.person:id,full_name', + 'segments' => function ($q) { + $q->wherePivot('active', true)->select('segments.id', 'segments.name'); + }, + 'account:id,accounts.contract_id,balance_amount', + ]) + ->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id']) + ->whereNull('deleted_at') + ->when($from || $to, function ($q) use ($from, $to) { + if (! empty($from)) { + $q->whereDate('start_date', '>=', $from); + } + if (! empty($to)) { + $q->whereDate('start_date', '<=', $to); + } + }) + ->when($search, function ($q) use ($search) { + $q->where(function ($inner) use ($search) { + $inner->where('reference', 'ilike', '%'.$search.'%') + ->orWhereHas('clientCase.person', function ($p) use ($search) { + $p->where('full_name', 'ilike', '%'.$search.'%'); + }); + }); + }) + ->orderByDesc('start_date'); + + $types = [ + 'address_types' => \App\Models\Person\AddressType::all(), + 'phone_types' => \App\Models\Person\PhoneType::all(), + ]; + + return Inertia::render('Client/Contracts', [ + 'client' => $data, + 'contracts' => $contractsQuery->paginate(20)->withQueryString(), + 'filters' => $request->only(['from', 'to', 'search']), + 'types' => $types, + ]); + } + public function store(Request $request) { diff --git a/app/Http/Controllers/ImportTemplateController.php b/app/Http/Controllers/ImportTemplateController.php index ca390db..c82b8f3 100644 --- a/app/Http/Controllers/ImportTemplateController.php +++ b/app/Http/Controllers/ImportTemplateController.php @@ -385,6 +385,8 @@ public function bulkAddMappings(Request $request, ImportTemplate $template) 'default_field' => 'nullable|string', // if provided, used as the field name for all entries 'apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'transform' => 'nullable|string|in:trim,upper,lower', + 'options' => 'nullable|array', + 'group' => 'nullable|string|max:50', // convenience: will be wrapped into options.group ])->validate(); // Accept commas, semicolons, and newlines; strip surrounding quotes/apostrophes and whitespace @@ -408,9 +410,18 @@ public function bulkAddMappings(Request $request, ImportTemplate $template) $entity = $data['entity'] ?? null; $defaultField = $data['default_field'] ?? null; // allows forcing a specific field for all + // Build options payload once + $opts = []; + if (isset($data['options']) && is_array($data['options'])) { + $opts = $data['options']; + } + if (! empty($data['group'])) { + $opts['group'] = (string) $data['group']; + } + $created = 0; $updated = 0; - DB::transaction(function () use ($template, $list, $apply, $transform, $entity, $defaultField, $basePosition, &$created, &$updated) { + DB::transaction(function () use ($template, $list, $apply, $transform, $entity, $defaultField, $basePosition, $opts, &$created, &$updated) { foreach ($list as $idx => $source) { $targetField = null; if ($defaultField) { @@ -429,7 +440,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template) 'entity' => $entity ?? $existing->entity, 'transform' => $transform ?? $existing->transform, 'apply_mode' => $apply ?? $existing->apply_mode ?? 'both', - 'options' => $existing->options, + 'options' => empty($opts) ? $existing->options : $opts, // keep existing position ]); $updated++; @@ -441,7 +452,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template) 'target_field' => $targetField, 'transform' => $transform, 'apply_mode' => $apply, - 'options' => null, + 'options' => empty($opts) ? null : $opts, 'position' => $basePosition + $idx + 1, ]); $created++; @@ -546,6 +557,10 @@ public function applyToImport(Request $request, ImportTemplate $template, Import $rows = $template->mappings()->orderBy('position')->get(); foreach ($rows as $row) { + $options = $row->options; + if (is_array($options) || $options instanceof \JsonSerializable || $options instanceof \stdClass) { + $options = json_encode($options); + } \DB::table('import_mappings')->insert([ 'import_id' => $import->id, 'entity' => $row->entity, @@ -553,7 +568,7 @@ public function applyToImport(Request $request, ImportTemplate $template, Import 'target_field' => $row->target_field, 'transform' => $row->transform, 'apply_mode' => $row->apply_mode ?? 'both', - 'options' => $row->options, + 'options' => $options, 'position' => $row->position ?? null, 'created_at' => now(), 'updated_at' => now(), diff --git a/app/Http/Controllers/PhoneViewController.php b/app/Http/Controllers/PhoneViewController.php index ada4523..44dc3ff 100644 --- a/app/Http/Controllers/PhoneViewController.php +++ b/app/Http/Controllers/PhoneViewController.php @@ -18,9 +18,15 @@ public function index(Request $request) ->whereNull('cancelled_at') ->with([ 'contract' => function ($q) { - $q->with(['type:id,name', 'account', 'clientCase.person' => function ($pq) { - $pq->with(['addresses', 'phones']); - }]); + $q->with([ + 'type:id,name', + 'account', + 'clientCase.person' => function ($pq) { + $pq->with(['addresses', 'phones']); + }, + 'clientCase.client:id,uuid,person_id', + 'clientCase.client.person:id,full_name', + ]); }, ]) ->orderByDesc('assigned_at') @@ -29,6 +35,41 @@ public function index(Request $request) return Inertia::render('Phone/Index', [ 'jobs' => $jobs, + 'view_mode' => 'assigned', + ]); + } + + public function completedToday(Request $request) + { + $userId = $request->user()->id; + + $start = now()->startOfDay(); + $end = now()->endOfDay(); + + $jobs = FieldJob::query() + ->where('assigned_user_id', $userId) + ->whereNull('cancelled_at') + ->whereBetween('completed_at', [$start, $end]) + ->with([ + 'contract' => function ($q) { + $q->with([ + 'type:id,name', + 'account', + 'clientCase.person' => function ($pq) { + $pq->with(['addresses', 'phones']); + }, + 'clientCase.client:id,uuid,person_id', + 'clientCase.client.person:id,full_name', + ]); + }, + ]) + ->orderByDesc('completed_at') + ->limit(100) + ->get(); + + return Inertia::render('Phone/Index', [ + 'jobs' => $jobs, + 'view_mode' => 'completed-today', ]); } @@ -136,6 +177,7 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request) 'account_types' => \App\Models\AccountType::all(), 'actions' => \App\Models\Action::with('decisions')->get(), 'activities' => $activities, + 'completed_mode' => (bool) $request->boolean('completed'), ]); } } diff --git a/app/Models/Contract.php b/app/Models/Contract.php index 2fca9ad..3c46a87 100644 --- a/app/Models/Contract.php +++ b/app/Models/Contract.php @@ -27,6 +27,7 @@ class Contract extends Model 'client_case_id', 'type_id', 'description', + 'meta', ]; protected $hidden = [ @@ -35,6 +36,13 @@ class Contract extends Model 'type_id', ]; + protected function casts(): array + { + return [ + 'meta' => 'array', + ]; + } + public function type(): BelongsTo { return $this->belongsTo(\App\Models\ContractType::class, 'type_id'); diff --git a/app/Models/ImportEntity.php b/app/Models/ImportEntity.php index 6ac281d..d8b26c7 100644 --- a/app/Models/ImportEntity.php +++ b/app/Models/ImportEntity.php @@ -13,6 +13,8 @@ class ImportEntity extends Model 'fields', 'field_aliases', 'aliases', + 'supports_multiple', + 'meta', 'rules', 'ui', ]; @@ -21,6 +23,8 @@ class ImportEntity extends Model 'fields' => 'array', 'field_aliases' => 'array', 'aliases' => 'array', + 'supports_multiple' => 'boolean', + 'meta' => 'boolean', 'rules' => 'array', 'ui' => 'array', ]; diff --git a/app/Services/ImportProcessor.php b/app/Services/ImportProcessor.php index 82508de..d5233db 100644 --- a/app/Services/ImportProcessor.php +++ b/app/Services/ImportProcessor.php @@ -62,7 +62,7 @@ public function process(Import $import, ?Authenticatable $user = null): array ->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']); // Load dynamic entity config - [$rootAliasMap, $fieldAliasMap, $validRoots] = $this->loadImportEntityConfig(); + [$rootAliasMap, $fieldAliasMap, $validRoots, $supportsMultiple] = $this->loadImportEntityConfig(); // Normalize aliases (plural/legacy roots, field names) before validation $mappings = $this->normalizeMappings($mappings, $rootAliasMap, $fieldAliasMap); // Validate mapping roots early to avoid silent failures due to typos @@ -219,9 +219,10 @@ public function process(Import $import, ?Authenticatable $user = null): array if ($isPg) { // No DB changes were made for this row; nothing to roll back explicitly. } + continue; // proceed to next CSV row } - [$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings); + [$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings, $supportsMultiple); // Determine row-level reactivation intent: precedence row > import > template $rowReactivate = false; @@ -751,9 +752,10 @@ public function process(Import $import, ?Authenticatable $user = null): array // Resolve by contact values next if (! $personIdForRow) { - $emailVal = trim((string) ($mapped['email']['value'] ?? '')); - $phoneNu = trim((string) ($mapped['phone']['nu'] ?? '')); - $addrLine = trim((string) ($mapped['address']['address'] ?? '')); + // consider first values from multi groups if present + $emailVal = trim((string) ($this->firstFromMulti($mapped, 'email', 'value') ?? '')); + $phoneNu = trim((string) ($this->firstFromMulti($mapped, 'phone', 'nu') ?? '')); + $addrLine = trim((string) ($this->firstFromMulti($mapped, 'address', 'address') ?? '')); // Try to resolve by existing contacts first if ($emailVal !== '') { @@ -814,22 +816,57 @@ public function process(Import $import, ?Authenticatable $user = null): array $contactChanged = false; if ($personIdForRow) { - if (! empty($mapped['email'] ?? [])) { - $r = $this->upsertEmail($personIdForRow, $mapped['email'], $mappings); - if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { - $contactChanged = true; - } - } - if (! empty($mapped['address'] ?? [])) { - $r = $this->upsertAddress($personIdForRow, $mapped['address'], $mappings); - if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { - $contactChanged = true; - } - } - if (! empty($mapped['phone'] ?? [])) { - $r = $this->upsertPhone($personIdForRow, $mapped['phone'], $mappings); - if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { - $contactChanged = true; + // Fan-out for multi-supported roots; backward compatible for single hashes + foreach (['email' => 'upsertEmail', 'address' => 'upsertAddress', 'phone' => 'upsertPhone'] as $root => $method) { + if (isset($mapped[$root]) && is_array($mapped[$root])) { + // If it's a grouped map (supports multiple), iterate groups; else treat as single data hash + $data = $mapped[$root]; + $isGrouped = $this->isGroupedMulti($data); + if ($isGrouped) { + // De-duplicate grouped items within the same row by their unique key per root + $keyField = $root === 'email' ? 'value' : ($root === 'phone' ? 'nu' : 'address'); + $normalizer = function ($v) use ($root) { + if ($v === null) { + return null; + } + $s = trim((string) $v); + if ($s === '') { + return ''; + } + if ($root === 'email') { + return mb_strtolower($s); + } + if ($root === 'phone') { + // Keep leading + and digits only for comparison + $s = preg_replace('/[^0-9+]/', '', $s) ?? $s; + // Collapse multiple + to single leading + $s = ltrim($s, '+'); + + return '+'.$s; + } + // address: normalize whitespace and lowercase for comparison + $s = preg_replace('/\s+/', ' ', $s) ?? $s; + + return mb_strtolower(trim($s)); + }; + $data = $this->dedupeGroupedItems($data, $keyField, $normalizer); + foreach ($data as $grp => $payload) { + if (empty(array_filter($payload, fn ($v) => ! is_null($v) && trim((string) $v) !== ''))) { + continue; // skip empty group + } + $r = $this->{$method}($personIdForRow, $payload, $mappings); + if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { + $contactChanged = true; + } + } + } else { + if (! empty($data)) { + $r = $this->{$method}($personIdForRow, $data, $mappings); + if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { + $contactChanged = true; + } + } + } } } } @@ -986,7 +1023,7 @@ private function buildRowAssoc(array $row, ?array $header): array return $assoc; } - private function applyMappings(array $raw, $mappings): array + protected function applyMappings(array $raw, $mappings, array $supportsMultiple): array { $recordType = null; $mapped = []; @@ -1025,11 +1062,135 @@ private function applyMappings(array $raw, $mappings): array // detect record type from first segment, e.g., "account.balance_amount" $parts = explode('.', $target); - if (! $recordType && isset($parts[0])) { - $recordType = $parts[0]; + $rootWithBracket = $parts[0] ?? ''; + // Support bracket grouping like address[1].city + $group = null; + $root = $rootWithBracket; + if (preg_match('/^(?P[a-zA-Z_][a-zA-Z0-9_]*)(\[(?P[^\]]+)\])$/', $rootWithBracket, $m)) { + $root = $m['base']; + $group = $m['grp']; + } + if (! $recordType && isset($root)) { + $recordType = $root; + } + + // If this root supports multiple, determine group id: prefer mapping options.group, else bracket, else '1' + $supportsMulti = $supportsMultiple[$root] ?? false; + // Special handling for meta mappings: contract.meta.key (supports options.key and options.type) + if ($root === 'contract' && isset($parts[1]) && str_starts_with($parts[1], 'meta')) { + // Path could be meta.key or meta[key] + $metaKey = null; + $metaType = null; + // support dot path contract.meta.someKey + if (isset($parts[2])) { + $metaKey = $parts[2]; + } else { + // support contract.meta[someKey] + if (preg_match('/^meta\[(?P[^\]]+)\]$/', $parts[1], $mm)) { + $metaKey = $mm['k']; + } + } + if ($metaKey === null || $metaKey === '') { + // fallback: read key from mapping options.key if present + $opts = $map->options ?? null; + if (is_string($opts)) { + $opts = json_decode($opts, true) ?: []; + } + if (is_array($opts) && ! empty($opts['key'])) { + $metaKey = (string) $opts['key']; + } + if (is_array($opts) && ! empty($opts['type'])) { + $metaType = is_string($opts['type']) ? strtolower($opts['type']) : null; + } + } else { + // we still may have options.type + $opts = $map->options ?? null; + if (is_string($opts)) { + $opts = json_decode($opts, true) ?: []; + } + if (is_array($opts) && ! empty($opts['type'])) { + $metaType = is_string($opts['type']) ? strtolower($opts['type']) : null; + } + } + if ($metaKey !== null && $metaKey !== '') { + // group-aware bucket for meta entries + $groupOpt = $this->mappingOptionGroup($map); + $grp = ($groupOpt !== null && $groupOpt !== '') ? (string) $groupOpt : ($group ?? '1'); + if (! isset($mapped['contract'])) { + $mapped['contract'] = []; + } + if (! isset($mapped['contract']['meta']) || ! is_array($mapped['contract']['meta'])) { + $mapped['contract']['meta'] = []; + } + if (! isset($mapped['contract']['meta'][$grp])) { + $mapped['contract']['meta'][$grp] = []; + } + // Optionally coerce the value based on provided type + $coerced = $value; + $metaType = in_array($metaType, ['string', 'number', 'date', 'boolean'], true) ? $metaType : null; + if ($metaType === 'number') { + if (is_string($coerced)) { + $norm = $this->normalizeDecimal($coerced); + $coerced = is_numeric($norm) ? (float) $norm : $coerced; + } + } elseif ($metaType === 'boolean') { + if (is_string($coerced)) { + $lc = strtolower(trim($coerced)); + if (in_array($lc, ['1', 'true', 'yes', 'y'], true)) { + $coerced = true; + } elseif (in_array($lc, ['0', 'false', 'no', 'n'], true)) { + $coerced = false; + } + } else { + $coerced = (bool) $coerced; + } + } elseif ($metaType === 'date') { + $coerced = is_scalar($coerced) ? $this->normalizeDate((string) $coerced) : null; + } else { + // string or unspecified: cast scalars to string for consistency + if (is_scalar($coerced)) { + $coerced = (string) $coerced; + } + } + // Store as structure with title, value and optional type + $entry = [ + 'title' => is_string($src) ? $src : (string) $src, + 'value' => $coerced, + ]; + if ($metaType !== null) { + $entry['type'] = $metaType; + } + $mapped['contract']['meta'][$grp][$metaKey] = $entry; + + continue; + } + } + if ($supportsMulti) { + $groupOpt = $this->mappingOptionGroup($map); + $grp = ($groupOpt !== null && $groupOpt !== '') ? (string) $groupOpt : ($group ?? '1'); + // rebuild target path to exclude bracket part + $field = $parts[1] ?? null; + if ($field !== null) { + if (! isset($mapped[$root]) || ! is_array($mapped[$root])) { + $mapped[$root] = []; + } + if (! isset($mapped[$root][$grp]) || ! is_array($mapped[$root][$grp])) { + $mapped[$root][$grp] = []; + } + $mapped[$root][$grp][$field] = $value; + } + } else { + // single item root: assign field or root as appropriate + $field = $parts[1] ?? null; + if ($field !== null) { + if (! isset($mapped[$root]) || ! is_array($mapped[$root])) { + $mapped[$root] = []; + } + $mapped[$root][$field] = $value; + } else { + $mapped[$root] = $value; + } } - // build nested array by dot notation - $this->arraySetDot($mapped, $target, $value); } return [$recordType, $mapped]; @@ -1190,18 +1351,15 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array } if ($existing) { - if (empty($applyUpdate)) { - return ['action' => 'skipped', 'message' => 'No fields marked for update']; - } - // Only update fields that are set; skip nulls to avoid wiping unintentionally + // Build non-null changes for account fields $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); - if (empty($changes)) { - return ['action' => 'skipped', 'message' => 'No non-null changes']; - } // Track balance change $oldBalance = (float) ($existing->balance_amount ?? 0); - $existing->fill($changes); - $existing->save(); + // Note: meta merging for contracts is handled in upsertContractChain, not here + if (! empty($changes)) { + $existing->fill($changes); + $existing->save(); + } // If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after if (array_key_exists('balance_amount', $changes)) { @@ -1421,18 +1579,71 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): } if ($existing) { - if (empty($applyUpdate)) { - // Return existing contract reference even when skipped so callers can treat as resolved - return ['action' => 'skipped', 'message' => 'No contract fields marked for update', 'contract' => $existing]; - } + // 1) Prepare contract field changes (non-null) $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); - if (empty($changes)) { - return ['action' => 'skipped', 'message' => 'No non-null contract changes', 'contract' => $existing]; + + // 2) Prepare meta changes if provided via mapping + $metaUpdated = false; + $metaAppliedKeys = []; + if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) { + // Flatten incoming grouped meta to key => {title, value} + $incomingMeta = []; + foreach ($contractData['meta'] as $grp => $entries) { + if (! is_array($entries)) { + continue; + } + foreach ($entries as $k => $v) { + // v is expected as [title, value] + $incomingMeta[$k] = $v; + } + } + if (! empty($incomingMeta)) { + $currentMeta = is_array($existing->meta ?? null) ? $existing->meta : (json_decode((string) $existing->meta, true) ?: []); + foreach ($incomingMeta as $k => $entry) { + $newVal = is_array($entry) && array_key_exists('value', $entry) ? $entry['value'] : $entry; + $newTitle = is_array($entry) && array_key_exists('title', $entry) ? $entry['title'] : null; + $newType = is_array($entry) && array_key_exists('type', $entry) ? $entry['type'] : null; + $curEntry = $currentMeta[$k] ?? null; + $curVal = is_array($curEntry) && array_key_exists('value', $curEntry) ? $curEntry['value'] : $curEntry; + $curTitle = is_array($curEntry) && array_key_exists('title', $curEntry) ? $curEntry['title'] : null; + $curType = is_array($curEntry) && array_key_exists('type', $curEntry) ? $curEntry['type'] : null; + // Update when value differs, or title differs, or type differs + $shouldUpdate = ($newVal !== $curVal) || ($newTitle !== null && $newTitle !== $curTitle) || ($newType !== null && $newType !== $curType); + if ($shouldUpdate) { + if (is_array($entry)) { + $currentMeta[$k] = $entry; + } else { + $currentMeta[$k] = ['title' => (string) $k, 'value' => $newVal]; + } + $metaUpdated = true; + $metaAppliedKeys[] = $k; + } + } + if ($metaUpdated) { + $existing->meta = $currentMeta; + } + } + } + + if (empty($changes) && ! $metaUpdated) { + // Nothing to change + return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing]; + } + + if (! empty($changes)) { + $existing->fill($changes); } - $existing->fill($changes); $existing->save(); - return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $changes]; + // Build applied fields info, include meta keys if any + $applied = $changes; + if ($metaUpdated && ! empty($metaAppliedKeys)) { + foreach ($metaAppliedKeys as $k) { + $applied['meta:'.$k] = 'updated'; + } + } + + return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $applied]; } else { if (empty($applyInsert)) { return ['action' => 'skipped', 'message' => 'No contract fields marked for insert']; @@ -1459,6 +1670,21 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): // ensure required defaults $data['start_date'] = $data['start_date'] ?? now()->toDateString(); $data['type_id'] = $data['type_id'] ?? $this->getDefaultContractTypeId(); + // Merge meta for create if provided + if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) { + $incomingMeta = []; + foreach ($contractData['meta'] as $grp => $entries) { + if (! is_array($entries)) { + continue; + } + foreach ($entries as $k => $v) { + $incomingMeta[$k] = $v; + } + } + if (! empty($incomingMeta)) { + $data['meta'] = $incomingMeta; + } + } $created = Contract::create($data); return ['action' => 'inserted', 'contract' => $created, 'applied_fields' => $data]; @@ -1503,6 +1729,7 @@ private function normalizeDate(?string $raw): ?string if ($ts === false) { return null; } + return date('Y-m-d', $ts); } @@ -1712,6 +1939,7 @@ private function rowIsEffectivelyEmpty(array $rawAssoc): bool } } } + return true; } @@ -1775,7 +2003,15 @@ private function normalizeTargetField(string $target, array $rootAliasMap, array return $target; } $parts = explode('.', $target); - $root = $parts[0] ?? ''; + $rootWithBracket = $parts[0] ?? ''; + // Extract optional bracket group from root (e.g., address[1]) but preserve it after aliasing + $bracket = null; + if (preg_match('/^(?P[a-zA-Z_][a-zA-Z0-9_]*)(\[(?P[^\]]+)\])$/', $rootWithBracket, $m)) { + $root = $m['base']; + $bracket = $m['grp']; + } else { + $root = $rootWithBracket; + } $field = $parts[1] ?? null; // Root aliases (plural to canonical) from DB @@ -1791,18 +2027,21 @@ private function normalizeTargetField(string $target, array $rootAliasMap, array // Rebuild if ($field !== null) { - return $root.'.'.$field; + $rootOut = $bracket !== null ? ($root.'['.$bracket.']') : $root; + + return $rootOut.'.'.$field; } - return $root; + return $bracket !== null ? ($root.'['.$bracket.']') : $root; } - private function loadImportEntityConfig(): array + protected function loadImportEntityConfig(): array { $entities = ImportEntity::all(); $rootAliasMap = []; $fieldAliasMap = []; $validRoots = []; + $supportsMultiple = []; foreach ($entities as $ent) { $canonical = $ent->canonical_root; $validRoots[] = $canonical; @@ -1817,13 +2056,108 @@ private function loadImportEntityConfig(): array $aliases['__default'] = $aliases['__default'] ?? null; } $fieldAliasMap[$canonical] = $aliases; + $supportsMultiple[$canonical] = (bool) ($ent->supports_multiple ?? false); } // sensible defaults when DB empty if (empty($validRoots)) { $validRoots = ['person', 'contract', 'account', 'address', 'phone', 'email', 'client_case']; + $supportsMultiple = [ + 'address' => true, + 'phone' => true, + 'email' => true, + ]; } - return [$rootAliasMap, $fieldAliasMap, $validRoots]; + return [$rootAliasMap, $fieldAliasMap, $validRoots, $supportsMultiple]; + } + + /** + * Get mapping options.group if provided. + */ + private function mappingOptionGroup(object $map): ?string + { + $raw = $map->options ?? null; + if (is_string($raw)) { + try { + $json = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + + return isset($json['group']) ? (string) $json['group'] : null; + } catch (\Throwable $e) { + return null; + } + } + if (is_array($raw)) { + return isset($raw['group']) ? (string) $raw['group'] : null; + } + + return null; + } + + /** + * Determine if a mapped root is a grouped multi structure. + */ + private function isGroupedMulti(mixed $data): bool + { + if (! is_array($data)) { + return false; + } + // Consider grouped if first element is itself an array + foreach ($data as $k => $v) { + return is_array($v); + } + + return false; + } + + /** + * Read first value from a multi-group or single map for a given root/field. + */ + private function firstFromMulti(array $mapped, string $root, string $field): mixed + { + if (! isset($mapped[$root])) { + return null; + } + $data = $mapped[$root]; + if ($this->isGroupedMulti($data)) { + foreach ($data as $grp => $payload) { + if (isset($payload[$field]) && $payload[$field] !== null && trim((string) $payload[$field]) !== '') { + return $payload[$field]; + } + } + + return null; + } + + return $data[$field] ?? null; + } + + /** + * Remove duplicates from grouped items by comparing a key field across groups after normalization. + * Keeps the first occurrence and drops later duplicates. Empty/blank keys are kept only once. + * + * @param array $grouped e.g. ['1' => ['value' => 'a'], '2' => ['value' => 'a']] + * @param string $keyField e.g. 'value' for email, 'nu' for phone, 'address' for address + * @param callable|null $normalizer function(string|null): string|null normalizes comparison key + * @return array + */ + protected function dedupeGroupedItems(array $grouped, string $keyField, ?callable $normalizer = null): array + { + $seen = []; + $out = []; + foreach ($grouped as $grp => $payload) { + $raw = $payload[$keyField] ?? null; + $key = $normalizer ? $normalizer($raw) : (is_null($raw) ? null : trim((string) $raw)); + $key = $key === '' ? '' : $key; // ensure empty string stays empty + $finger = is_null($key) ? '__NULL__' : (string) $key; + if (array_key_exists($finger, $seen)) { + // duplicate => skip + continue; + } + $seen[$finger] = true; + $out[$grp] = $payload; + } + + return $out; } private function findOrCreatePersonId(array $p): ?int diff --git a/app/Services/ImportSimulationService.php b/app/Services/ImportSimulationService.php index fde9b30..7126d2b 100644 --- a/app/Services/ImportSimulationService.php +++ b/app/Services/ImportSimulationService.php @@ -32,8 +32,9 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false $delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ','; $columns = is_array($meta['columns'] ?? null) ? $meta['columns'] : []; - $targetToSource = $this->buildTargetLookup($import); - if (! $targetToSource) { + // Build both flat and grouped lookups + [$targetToSource, $groupedLookup] = $this->buildTargetLookup($import); + if (! $targetToSource && ! $groupedLookup) { return $this->errorPayload('Ni shranjenih mapiranj za ta uvoz.'); } @@ -47,6 +48,8 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false // Discover mapped entity roots and then filter by supported list with safe fallbacks $detectedRoots = $this->detectEntityRoots($targetToSource); + // Roots that support multiple grouped entries + $multiRoots = $this->loadSupportsMultipleRoots(); $supported = $this->loadSupportedEntityRoots(); $entityRoots = $this->filterEntityRoots($detectedRoots, $supported, $targetToSource); $summaries = $this->initSummaries($entityRoots); @@ -104,6 +107,26 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false return null; }; + // Grouped values accessor: returns [group => value] for given root.field + $groupVals = function (string $root, string $field) use ($assoc, $groupedLookup, $targetToSource) { + $out = []; + $key = $root.'.'.$field; + if (isset($groupedLookup[$root])) { + foreach ($groupedLookup[$root] as $g => $fields) { + if (isset($fields[$field])) { + $col = $fields[$field]; + $out[$g] = $assoc[$col] ?? null; + } + } + } + // Also include ungrouped flat mapping as default group when no explicit group exists + if (isset($targetToSource[$key]) && ! isset($out[''])) { + $out[''] = $assoc[$targetToSource[$key]] ?? null; + } + + return $out; + }; + // Contract if (isset($entityRoots['contract'])) { [$contractEntity, $summaries, $contractCache] = $this->simulateContract($val, $summaries, $contractCache, $val('contract.reference')); @@ -116,6 +139,49 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false $contractEntity['action'] = 'reactivate'; $contractEntity['reactivation'] = true; } + // Attach contract meta preview from mappings (group-aware) + $metaGroups = []; + // Grouped contract.meta.* via groupedLookup + if (isset($groupedLookup['contract'])) { + foreach ($groupedLookup['contract'] as $g => $fields) { + foreach ($fields as $f => $srcCol) { + if (str_starts_with($f, 'meta.')) { + $key = substr($f, strlen('meta.')); + if ($key !== '') { + if (! isset($metaGroups[$g])) { + $metaGroups[$g] = []; + } + $metaGroups[$g][$key] = [ + 'title' => $srcCol, + 'value' => $assoc[$srcCol] ?? null, + ]; + } + } + } + } + } + // Flat contract.meta.* (no group): assign to group '1' + foreach ($targetToSource as $tf => $srcCol) { + if (str_starts_with($tf, 'contract.meta.')) { + $key = substr($tf, strlen('contract.meta.')); + if ($key !== '') { + $g = '1'; + if (! isset($metaGroups[$g])) { + $metaGroups[$g] = []; + } + // Do not override grouped if already present + if (! isset($metaGroups[$g][$key])) { + $metaGroups[$g][$key] = [ + 'title' => $srcCol, + 'value' => $assoc[$srcCol] ?? null, + ]; + } + } + } + } + if (! empty($metaGroups)) { + $contractEntity['meta'] = $metaGroups; + } $rowEntities['contract'] = $contractEntity + [ 'action_label' => $translatedActions[$contractEntity['action']] ?? $contractEntity['action'], ]; @@ -156,22 +222,45 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false } $reference = $val($rootKey.'.reference'); $identityCandidates = $this->genericIdentityCandidates($rootKey, $val); - [$genericEntity, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities] - = $this->simulateGenericRoot( - $rootKey, - $val, - $summaries, - $genericCaches, - $reference, - $identityCandidates, - $genericExistingIdentities, - $genericSeenIdentities, - $verbose, - $targetToSource, - ); - $rowEntities[$rootKey] = $genericEntity + [ - 'action_label' => $translatedActions[$genericEntity['action']] ?? $genericEntity['action'], - ]; + if (isset($multiRoots[$rootKey]) && $multiRoots[$rootKey] === true) { + // Multi-item simulation per group + [$items, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities] + = $this->simulateGenericRootMulti( + $rootKey, + $val, + $groupVals, + $summaries, + $genericCaches, + $identityCandidates, + $genericExistingIdentities, + $genericSeenIdentities, + $verbose, + $targetToSource + ); + // Add action labels and attach + $rowEntities[$rootKey] = array_map(function ($ent) use ($translatedActions) { + $ent['action_label'] = $translatedActions[$ent['action']] ?? $ent['action']; + + return $ent; + }, $items); + } else { + [$genericEntity, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities] + = $this->simulateGenericRoot( + $rootKey, + $val, + $summaries, + $genericCaches, + $reference, + $identityCandidates, + $genericExistingIdentities, + $genericSeenIdentities, + $verbose, + $targetToSource, + ); + $rowEntities[$rootKey] = $genericEntity + [ + 'action_label' => $translatedActions[$genericEntity['action']] ?? $genericEntity['action'], + ]; + } } // Attach chain entities (client_case, person) if contract already existed @@ -317,8 +406,22 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false // If existing contract: upgrade generic email/phone/address entities (already simulated) to mark as chain if corresponding person attached if ($existingContract && isset($rowEntities['person']['id'])) { foreach (['email', 'phone', 'address'] as $gRoot) { - if (isset($rowEntities[$gRoot]) && ! ($rowEntities[$gRoot]['existing_chain'] ?? false)) { - $rowEntities[$gRoot]['existing_chain'] = true; // mark for UI toggle + if (! isset($rowEntities[$gRoot])) { + continue; + } + $valRef = $rowEntities[$gRoot]; + // If multi items array, set flag on each item + if (is_array($valRef) && isset($valRef[0]) && is_array($valRef[0])) { + foreach ($rowEntities[$gRoot] as &$it) { + if (! ($it['existing_chain'] ?? false)) { + $it['existing_chain'] = true; + } + } + unset($it); + } else { + if (! ($rowEntities[$gRoot]['existing_chain'] ?? false)) { + $rowEntities[$gRoot]['existing_chain'] = true; // mark for UI toggle + } } } } @@ -352,7 +455,21 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false if ($verbose) { foreach ($rowEntities as $eroot => &$ent) { $tf = $eroot.'.reference'; - if (isset($targetToSource[$tf])) { + if (! isset($targetToSource[$tf])) { + continue; + } + if (is_array($ent) && isset($ent[0]) && is_array($ent[0])) { + foreach ($ent as &$item) { + $item['sources'] = $item['sources'] ?? []; + if (! isset($item['sources'][$tf])) { + $item['sources'][$tf] = [ + 'source_column' => $targetToSource[$tf], + 'value' => $val($tf), + ]; + } + } + unset($item); + } else { $ent['sources'] = $ent['sources'] ?? []; if (! isset($ent['sources'][$tf])) { $ent['sources'][$tf] = [ @@ -404,7 +521,6 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false foreach (['reference', 'identity_used', 'identity_candidates', 'full_name', 'first_name', 'last_name', 'address', 'country', 'nu', 'value'] as $k) { if (isset($ent[$k]) && $ent[$k]) { $hasData = true; - break; } } // Some entities (e.g. payment) do not have 'action'; treat them as non-empty if they have data or status @@ -455,47 +571,88 @@ private function buildTargetLookup(Import $import): array $mappings = \DB::table('import_mappings') ->where('import_id', $import->id) ->orderBy('position') - ->get(['source_column', 'target_field']); + ->get(['source_column', 'target_field', 'options']); $lookup = []; + $grouped = []; foreach ($mappings as $m) { $target = trim((string) $m->target_field); $source = trim((string) $m->source_column); if ($target === '' || $source === '') { continue; } + + // Parse grouping and field + $group = null; + $root = null; + $rest = null; + $restEffective = null; + $opts = []; + if (preg_match('/^([a-zA-Z0-9_]+)\\[([^\]]+)\\]\\.([a-zA-Z0-9_]+)$/', $target, $mm)) { + $root = $mm[1]; + $group = (string) $mm[2]; + $rest = $mm[3]; + $restEffective = $rest; + } else { + if (str_contains($target, '.')) { + [$root, $rest] = explode('.', $target, 2); + } + try { + $opts = is_array($m->options) ? $m->options : (json_decode((string) $m->options, true) ?: []); + } catch (\Throwable) { + $opts = []; + } + if (is_array($opts) && array_key_exists('group', $opts) && $opts['group'] !== '' && $opts['group'] !== null) { + $group = (string) $opts['group']; + } + $restEffective = $rest; + // Alias meta with options.key => meta.{key} + if ($rest === 'meta' && is_array($opts) && ! empty($opts['key'])) { + $restEffective = 'meta'.'.'.(string) $opts['key']; + } + } + + // Register flat lookups if (! isset($lookup[$target])) { $lookup[$target] = $source; } - // If mapping uses *.client_ref, also register *.reference alias for simulation reference purposes + if ($rest !== null) { + $normRoot = $this->normalizeRoot((string) $root); + $tfNorm = $normRoot.'.'.$restEffective; + if (! isset($lookup[$tfNorm])) { + $lookup[$tfNorm] = $source; + } + if (str_ends_with((string) $root, 's')) { + $sing = substr((string) $root, 0, -1); + if ($sing) { + $tfSing = $sing.'.'.$restEffective; + if (! isset($lookup[$tfSing])) { + $lookup[$tfSing] = $source; + } + } + } + } if (str_ends_with($target, '.client_ref')) { $alias = substr($target, 0, -strlen('.client_ref')).'.reference'; if (! isset($lookup[$alias])) { $lookup[$alias] = $source; } } - if (str_contains($target, '.')) { - [$root, $rest] = explode('.', $target, 2); - $norm = $this->normalizeRoot($root); - if ($norm !== $root) { - $alt = $norm.'.'.$rest; - if (! isset($lookup[$alt])) { - $lookup[$alt] = $source; - } + + // Register grouped lookup per normalized root + if ($group !== null && $rest !== null) { + $normRoot = $this->normalizeRoot((string) $root); + if (! isset($grouped[$normRoot])) { + $grouped[$normRoot] = []; } - if (str_ends_with($root, 's')) { - $sing = substr($root, 0, -1); - if ($sing && $sing !== $root) { - $alt2 = $sing.'.'.$rest; - if (! isset($lookup[$alt2])) { - $lookup[$alt2] = $source; - } - } + if (! isset($grouped[$normRoot][$group])) { + $grouped[$normRoot][$group] = []; } + $grouped[$normRoot][$group][$restEffective] = $source; } } - return $lookup; + return [$lookup, $grouped]; } private function readFileRows(Import $import, bool $hasHeader, string $delimiter, array $columns, int $limit): array @@ -1173,6 +1330,235 @@ private function loadSupportedEntityRoots(): array } } + /** + * Load which canonical roots support multiple items (grouped) from import_entities.supports_multiple. + * Falls back to known defaults if table/column is unavailable. + */ + private function loadSupportsMultipleRoots(): array + { + $map = []; + try { + if (\Schema::hasTable('import_entities') && \Schema::hasColumn('import_entities', 'supports_multiple')) { + $rows = \App\Models\ImportEntity::query()->get(['key', 'canonical_root', 'supports_multiple']); + foreach ($rows as $r) { + $root = $r->canonical_root ?: $r->key; + if (! $root) { + continue; + } + $norm = $this->normalizeRoot($root); + $map[$norm] = (bool) $r->supports_multiple; + } + } + } catch (\Throwable) { + // ignore and fallback + } + if (empty($map)) { + // Conservative defaults: only known contact types are multi + $map = [ + 'email' => true, + 'phone' => true, + 'address' => true, + ]; + } + + return $map; + } + + /** + * Simulate generic root that supports multiple items (grouped). Returns array of entities per group. + * Mirrors simulateGenericRoot per item while grouping by provided group values. + * + * @return array{0: array, 1: array, 2: array, 3: array, 4: array} + */ + private function simulateGenericRootMulti( + string $root, + callable $val, + callable $groupVals, + array $summaries, + array $genericCaches, + array $identityCandidatesBase, + array $genericExistingIdentities, + array $genericSeenIdentities, + bool $verbose = false, + array $targetToSource = [], + ): array { + // Build per-group entities + $items = []; + + // Determine fields per root to preview + $previewFields = []; + if ($root === 'email') { + $previewFields = ['value']; + } elseif ($root === 'phone') { + $previewFields = ['nu']; + } elseif ($root === 'address') { + $previewFields = ['address', 'country']; // postal_code not present + } + + // Collect groups seen for this root + $groups = []; + foreach ($previewFields as $pf) { + $map = $groupVals($root, $pf); + foreach ($map as $g => $v) { + $groups[$g] = true; + } + } + // If no groups but flat mapping exists, still simulate single default item + if (empty($groups)) { + $groups[''] = true; + } + + // Ensure summary bucket exists and count total_rows once per row (not per item) + if (! isset($summaries[$root])) { + $summaries[$root] = [ + 'root' => $root, + 'total_rows' => 0, + 'create' => 0, + 'update' => 0, + 'missing_ref' => 0, + 'invalid' => 0, + 'duplicate' => 0, + 'duplicate_db' => 0, + ]; + } + $summaries[$root]['total_rows']++; + + // For each group, produce an item entity similar to simulateGenericRoot + foreach (array_keys($groups) as $g) { + // Build reference and identity candidates per group + $reference = null; + if ($root === 'email') { + $reference = $groupVals('email', 'value')[$g] ?? null; + } elseif ($root === 'phone') { + $reference = $groupVals('phone', 'nu')[$g] ?? null; + } elseif ($root === 'address') { + $reference = $groupVals('address', 'address')[$g] ?? null; + } + + $identityCandidates = $identityCandidatesBase; + // Override with group-specific identity when present + if ($root === 'email') { + $v = $groupVals('email', 'value')[$g] ?? null; + if ($v) { + $identityCandidates = ['value:'.mb_strtolower(trim((string) $v))]; + } + } elseif ($root === 'phone') { + $nu = $groupVals('phone', 'nu')[$g] ?? null; + if ($nu) { + $norm = preg_replace('/\D+/', '', (string) $nu) ?? ''; + if ($norm) { + $identityCandidates = ['nu:'.$norm]; + } + } + } elseif ($root === 'address') { + $addr = $groupVals('address', 'address')[$g] ?? null; + $country = $groupVals('address', 'country')[$g] ?? null; + if ($addr || $country) { + $key = mb_strtolower(trim((string) ($addr ?? ''))).'|'.mb_strtolower(trim((string) ($country ?? ''))); + $identityCandidates = ['addr:'.$key]; + } + } + + // Query existing by reference when available + $modelClass = $this->modelClassForGeneric($root); + $record = null; + if ($reference) { + if (! isset($genericCaches[$root])) { + $genericCaches[$root] = []; + } + if (array_key_exists($reference, $genericCaches[$root])) { + $record = $genericCaches[$root][$reference]; + } elseif ($modelClass && class_exists($modelClass)) { + try { + if (\Schema::hasColumn((new $modelClass)->getTable(), 'reference')) { + $record = $modelClass::query()->where('reference', $reference)->first(['id', 'reference']); + } + } catch (\Throwable) { + $record = null; + } + $genericCaches[$root][$reference] = $record; // may be null + } + } + + $entity = [ + 'reference' => $reference, + 'id' => $record?->id, + 'exists' => (bool) $record, + 'action' => $reference ? ($record ? 'update' : 'create') : 'skip', + 'group' => $g, + 'identity_candidates' => $identityCandidates, + ]; + + // Previews + if ($root === 'email') { + $entity['value'] = $groupVals('email', 'value')[$g] ?? null; + } elseif ($root === 'phone') { + $entity['nu'] = $groupVals('phone', 'nu')[$g] ?? null; + } elseif ($root === 'address') { + $entity['address'] = $groupVals('address', 'address')[$g] ?? null; + $entity['country'] = $groupVals('address', 'country')[$g] ?? null; + } + + if ($verbose) { + $srcs = []; + foreach ($targetToSource as $tf => $col) { + if (str_starts_with($tf, $root.'.')) { + // only include fields that match this group if grouped mapping exists + // We can't easily map back to group here without reverse index; include anyway for now + $srcs[$tf] = [ + 'source_column' => $col, + 'value' => $val($tf), + ]; + } + } + if ($srcs) { + $entity['sources'] = $entity['sources'] ?? []; + $entity['sources'] += $srcs; + } + } + + if (! $reference) { + $summaries[$root]['missing_ref']++; + } elseif ($record) { + $summaries[$root]['update']++; + } else { + $summaries[$root]['create']++; + } + + // Duplicate detection per root across items in stream + foreach ($identityCandidates as $identity) { + if ($identity === null || $identity === '') { + continue; + } + if (! isset($genericExistingIdentities[$root])) { + $genericExistingIdentities[$root] = $this->loadExistingGenericIdentities($root); + } + if (isset($genericExistingIdentities[$root][$identity])) { + $entity['duplicate_db'] = true; + $entity['identity_used'] = $identity; + $summaries[$root]['duplicate_db']++; + break; + } + if (! isset($genericSeenIdentities[$root])) { + $genericSeenIdentities[$root] = []; + } + if (isset($genericSeenIdentities[$root][$identity])) { + $entity['duplicate'] = true; + $entity['identity_used'] = $identity; + $summaries[$root]['duplicate']++; + break; + } + $genericSeenIdentities[$root][$identity] = true; + $entity['identity_used'] = $identity; + break; + } + + $items[] = $entity; + } + + return [$items, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities]; + } + /* ------------------------------- Localization ------------------------------- */ private function actionTranslations(): array diff --git a/database/migrations/2025_10_09_000001_add_meta_to_contracts_table.php b/database/migrations/2025_10_09_000001_add_meta_to_contracts_table.php new file mode 100644 index 0000000..5d6c6a8 --- /dev/null +++ b/database/migrations/2025_10_09_000001_add_meta_to_contracts_table.php @@ -0,0 +1,26 @@ +json('meta')->nullable()->after('description'); + } + }); + } + + public function down(): void + { + Schema::table('contracts', function (Blueprint $table) { + if (Schema::hasColumn('contracts', 'meta')) { + $table->dropColumn('meta'); + } + }); + } +}; diff --git a/database/migrations/2025_10_09_000001_add_supports_multiple_to_import_entities_table.php b/database/migrations/2025_10_09_000001_add_supports_multiple_to_import_entities_table.php new file mode 100644 index 0000000..9049f98 --- /dev/null +++ b/database/migrations/2025_10_09_000001_add_supports_multiple_to_import_entities_table.php @@ -0,0 +1,26 @@ +boolean('supports_multiple')->default(false)->after('aliases'); + } + }); + } + + public function down(): void + { + Schema::table('import_entities', function (Blueprint $table): void { + if (Schema::hasColumn('import_entities', 'supports_multiple')) { + $table->dropColumn('supports_multiple'); + } + }); + } +}; diff --git a/database/migrations/2025_10_09_000002_add_meta_flag_to_import_entities_table.php b/database/migrations/2025_10_09_000002_add_meta_flag_to_import_entities_table.php new file mode 100644 index 0000000..998a430 --- /dev/null +++ b/database/migrations/2025_10_09_000002_add_meta_flag_to_import_entities_table.php @@ -0,0 +1,26 @@ +boolean('meta')->default(false)->after('supports_multiple'); + } + }); + } + + public function down(): void + { + Schema::table('import_entities', function (Blueprint $table) { + if (Schema::hasColumn('import_entities', 'meta')) { + $table->dropColumn('meta'); + } + }); + } +}; diff --git a/database/migrations/2025_10_09_000002_add_unique_indexes_on_contacts.php b/database/migrations/2025_10_09_000002_add_unique_indexes_on_contacts.php new file mode 100644 index 0000000..3dcfddd --- /dev/null +++ b/database/migrations/2025_10_09_000002_add_unique_indexes_on_contacts.php @@ -0,0 +1,66 @@ + 'emails', 'col' => 'value', 'idx' => 'unique_emails_person_value_active'], + ['table' => 'person_phones', 'col' => 'nu', 'idx' => 'unique_person_phones_person_nu_active'], + ['table' => 'person_addresses', 'col' => 'address', 'idx' => 'unique_person_addresses_person_address_active'], + ] as $cfg) { + $table = $cfg['table']; + $indexName = $cfg['idx']; + if (! Schema::hasColumn($table, 'active_flag')) { + // Use STORED generated column so it can be indexed + DB::statement("ALTER TABLE `{$table}` ADD COLUMN `active_flag` TINYINT(1) GENERATED ALWAYS AS (CASE WHEN `deleted_at` IS NULL THEN 1 ELSE 0 END) STORED"); + } + // Create unique index on (person_id, key, active_flag) + Schema::table($table, function (Blueprint $t) use ($cfg, $indexName): void { + $t->unique(['person_id', $cfg['col'], 'active_flag'], $indexName); + }); + } + } + } + + public function down(): void + { + $driver = DB::getDriverName(); + + if ($driver === 'pgsql' || $driver === 'sqlite') { + DB::statement('DROP INDEX IF EXISTS unique_emails_person_value_active'); + DB::statement('DROP INDEX IF EXISTS unique_person_phones_person_nu_active'); + DB::statement('DROP INDEX IF EXISTS unique_person_addresses_person_address_active'); + } elseif ($driver === 'mysql') { + foreach ([ + ['table' => 'emails', 'col' => 'value', 'idx' => 'unique_emails_person_value_active'], + ['table' => 'person_phones', 'col' => 'nu', 'idx' => 'unique_person_phones_person_nu_active'], + ['table' => 'person_addresses', 'col' => 'address', 'idx' => 'unique_person_addresses_person_address_active'], + ] as $cfg) { + Schema::table($cfg['table'], function (Blueprint $t) use ($cfg): void { + $t->dropUnique($cfg['idx']); + }); + } + // Drop generated column + foreach (['emails', 'person_phones', 'person_addresses'] as $table) { + if (Schema::hasColumn($table, 'active_flag')) { + DB::statement("ALTER TABLE `{$table}` DROP COLUMN `active_flag`"); + } + } + } + } +}; diff --git a/database/seeders/ImportEntitySeeder.php b/database/seeders/ImportEntitySeeder.php index 5367898..541c4fb 100644 --- a/database/seeders/ImportEntitySeeder.php +++ b/database/seeders/ImportEntitySeeder.php @@ -38,6 +38,7 @@ public function run(): void 'canonical_root' => 'address', 'label' => 'Person Addresses', 'fields' => ['address', 'city', 'postal_code', 'country', 'type_id', 'description'], + 'supports_multiple' => true, 'field_aliases' => [ 'ulica' => 'address', 'naslov' => 'address', @@ -64,6 +65,7 @@ public function run(): void 'canonical_root' => 'phone', 'label' => 'Person Phones', 'fields' => ['nu', 'country_code', 'type_id', 'description'], + 'supports_multiple' => true, 'field_aliases' => ['number' => 'nu'], 'aliases' => ['phone', 'person_phones'], 'rules' => [ @@ -76,6 +78,7 @@ public function run(): void 'canonical_root' => 'email', 'label' => 'Emails', 'fields' => ['value', 'is_primary', 'label'], + 'supports_multiple' => true, 'field_aliases' => ['email' => 'value'], 'aliases' => ['email', 'emails'], 'rules' => [ @@ -87,8 +90,10 @@ public function run(): void 'key' => 'contracts', 'canonical_root' => 'contract', 'label' => 'Contracts', - 'fields' => ['reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id'], + // Include 'meta' so the UI can select contract.meta as a field target + 'fields' => ['reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id', 'meta'], 'aliases' => ['contract', 'contracts', 'contracs'], + 'meta' => true, 'rules' => [ ['pattern' => '/^(sklic|reference|ref)\b/i', 'field' => 'reference'], ['pattern' => '/^(od|from|start|zacetek|začetek)\b/i', 'field' => 'start_date'], diff --git a/database/seeders/ImportTemplateSeeder.php b/database/seeders/ImportTemplateSeeder.php index 4c1adf0..90db8bc 100644 --- a/database/seeders/ImportTemplateSeeder.php +++ b/database/seeders/ImportTemplateSeeder.php @@ -18,7 +18,7 @@ public function run(): void 'description' => 'Basic person import: name, email, phone, address', 'source_type' => 'csv', 'default_record_type' => 'person', - 'sample_headers' => ['first_name','last_name','email','phone','address','city','postal_code','country'], + 'sample_headers' => ['first_name', 'last_name', 'email', 'phone', 'address', 'city', 'postal_code', 'country'], 'is_active' => true, 'meta' => [ 'delimiter' => ',', @@ -47,5 +47,113 @@ public function run(): void 'position' => $map['position'], ]); } + + // Multi-contacts template demonstrating group support for emails/phones/addresses + $multi = ImportTemplate::query()->firstOrCreate([ + 'name' => 'Person CSV (multi contacts)', + ], [ + 'uuid' => (string) Str::uuid(), + 'description' => 'Person import with multiple emails/phones/addresses per row using group 1 and 2', + 'source_type' => 'csv', + 'default_record_type' => 'person', + 'sample_headers' => [ + 'first_name', 'last_name', 'Email 1', 'Email 2', 'Phone 1', 'Phone 2', + 'Address 1', 'City 1', 'Postal 1', 'Country 1', + 'Address 2', 'City 2', 'Postal 2', 'Country 2', + ], + 'is_active' => true, + 'meta' => [ + 'delimiter' => ',', + 'enclosure' => '"', + 'escape' => '\\', + ], + ]); + + $multiMappings = [ + // Person identity + ['source_column' => 'first_name', 'target_field' => 'person.first_name', 'position' => 1], + ['source_column' => 'last_name', 'target_field' => 'person.last_name', 'position' => 2], + + // Emails (groups 1, 2) + ['source_column' => 'Email 1', 'target_field' => 'email.value', 'position' => 3, 'options' => ['group' => '1']], + ['source_column' => 'Email 2', 'target_field' => 'email.value', 'position' => 4, 'options' => ['group' => '2']], + + // Phones (groups 1, 2) + ['source_column' => 'Phone 1', 'target_field' => 'phone.nu', 'position' => 5, 'options' => ['group' => '1']], + ['source_column' => 'Phone 2', 'target_field' => 'phone.nu', 'position' => 6, 'options' => ['group' => '2']], + + // Address group 1 + ['source_column' => 'Address 1', 'target_field' => 'address.address', 'position' => 7, 'options' => ['group' => '1']], + ['source_column' => 'City 1', 'target_field' => 'address.city', 'position' => 8, 'options' => ['group' => '1']], + ['source_column' => 'Postal 1', 'target_field' => 'address.postal_code', 'position' => 9, 'options' => ['group' => '1']], + ['source_column' => 'Country 1', 'target_field' => 'address.country', 'position' => 10, 'options' => ['group' => '1']], + + // Address group 2 + ['source_column' => 'Address 2', 'target_field' => 'address.address', 'position' => 11, 'options' => ['group' => '2']], + ['source_column' => 'City 2', 'target_field' => 'address.city', 'position' => 12, 'options' => ['group' => '2']], + ['source_column' => 'Postal 2', 'target_field' => 'address.postal_code', 'position' => 13, 'options' => ['group' => '2']], + ['source_column' => 'Country 2', 'target_field' => 'address.country', 'position' => 14, 'options' => ['group' => '2']], + ]; + + foreach ($multiMappings as $map) { + ImportTemplateMapping::firstOrCreate([ + 'import_template_id' => $multi->id, + 'source_column' => $map['source_column'], + ], [ + 'target_field' => $map['target_field'], + 'position' => $map['position'], + 'options' => $map['options'] ?? null, + ]); + } + + // Contracts with Meta (groups + keys) demo + $contractsMeta = ImportTemplate::query()->firstOrCreate([ + 'name' => 'Contracts CSV (meta groups demo)', + ], [ + 'uuid' => (string) Str::uuid(), + 'description' => 'Contracts import demonstrating contract.meta mappings using groups and keys.', + 'source_type' => 'csv', + 'default_record_type' => 'contract', + 'sample_headers' => [ + 'Reference', 'Start', 'End', + 'Note', 'Category', 'Custom ID', + 'Legal Note', 'Legal ID', + ], + 'is_active' => true, + 'meta' => [ + 'delimiter' => ',', + 'enclosure' => '"', + 'escape' => '\\', + ], + ]); + + $contractsMetaMappings = [ + // Core contract fields + ['source_column' => 'Reference', 'target_field' => 'contract.reference', 'position' => 1], + ['source_column' => 'Start', 'target_field' => 'contract.start_date', 'position' => 2], + ['source_column' => 'End', 'target_field' => 'contract.end_date', 'position' => 3], + + // Meta group 1 using explicit options.key + ['source_column' => 'Note', 'target_field' => 'contract.meta', 'position' => 4, 'options' => ['group' => '1', 'key' => 'note']], + ['source_column' => 'Category', 'target_field' => 'contract.meta', 'position' => 5, 'options' => ['group' => '1', 'key' => 'category']], + + // Meta group 1 using bracket key syntax (no options.key needed) + ['source_column' => 'Custom ID', 'target_field' => 'contract.meta[custom_id]', 'position' => 6, 'options' => ['group' => '1']], + + // Meta group 2 examples + ['source_column' => 'Legal Note', 'target_field' => 'contract.meta', 'position' => 7, 'options' => ['group' => '2', 'key' => 'note']], + ['source_column' => 'Legal ID', 'target_field' => 'contract.meta[legal_id]', 'position' => 8, 'options' => ['group' => '2']], + ]; + + foreach ($contractsMetaMappings as $map) { + ImportTemplateMapping::firstOrCreate([ + 'import_template_id' => $contractsMeta->id, + 'source_column' => $map['source_column'], + ], [ + 'target_field' => $map['target_field'], + 'position' => $map['position'], + 'options' => $map['options'] ?? null, + ]); + } } } diff --git a/resources/js/Layouts/AppPhoneLayout.vue b/resources/js/Layouts/AppPhoneLayout.vue index 12fcb47..e1c153d 100644 --- a/resources/js/Layouts/AppPhoneLayout.vue +++ b/resources/js/Layouts/AppPhoneLayout.vue @@ -70,6 +70,14 @@ const logout = () => { // Flash toast notifications (same as AppLayout for consistency) const page = usePage(); const flash = computed(() => page.props.flash || {}); +const isCompletedMode = computed(() => !!page.props.completed_mode); + +// On mobile, always show labels in the overlay menu, regardless of collapsed state +const showLabels = computed(() => !sidebarCollapsed.value || isMobile.value); +// On mobile, force full width for the slide-out menu +const widthClass = computed(() => + isMobile.value ? "w-64" : sidebarCollapsed.value ? "w-16" : "w-64" +); const showToast = ref(false); const toastMessage = ref(""); const toastType = ref("success"); @@ -113,7 +121,7 @@ const closeSearch = () => (searchOpen.value = false);