Importer update add support for meta data and multiple inserts for some entities like addresses and phones, updated other things

This commit is contained in:
Simon Pocrnjič 2025-10-09 22:28:48 +02:00
parent c8029c9eb0
commit 0598261cdc
27 changed files with 2517 additions and 375 deletions

View File

@ -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

View File

@ -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)
{

View File

@ -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(),

View File

@ -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) {
$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'),
]);
}
}

View File

@ -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');

View File

@ -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',
];

View File

@ -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);
// 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;
}
}
if (! empty($mapped['address'] ?? [])) {
$r = $this->upsertAddress($personIdForRow, $mapped['address'], $mappings);
} else {
if (! empty($data)) {
$r = $this->{$method}($personIdForRow, $data, $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;
}
}
}
}
@ -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<base>[a-zA-Z_][a-zA-Z0-9_]*)(\[(?P<grp>[^\]]+)\])$/', $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<k>[^\]]+)\]$/', $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);
// 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->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<base>[a-zA-Z_][a-zA-Z0-9_]*)(\[(?P<grp>[^\]]+)\])$/', $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<string,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<string,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

View File

@ -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,6 +222,28 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
}
$reference = $val($rootKey.'.reference');
$identityCandidates = $this->genericIdentityCandidates($rootKey, $val);
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,
@ -173,6 +261,7 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
'action_label' => $translatedActions[$genericEntity['action']] ?? $genericEntity['action'],
];
}
}
// Attach chain entities (client_case, person) if contract already existed
if ($existingContract && isset($rowEntities['contract']['reference'])) {
@ -317,11 +406,25 @@ 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)) {
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
}
}
}
}
// Payment (affects account balance; may create implicit account)
if (isset($entityRoots['payment'])) {
@ -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;
}
}
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;
}
// Register grouped lookup per normalized root
if ($group !== null && $rest !== null) {
$normRoot = $this->normalizeRoot((string) $root);
if (! isset($grouped[$normRoot])) {
$grouped[$normRoot] = [];
}
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<int, 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

View File

@ -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('contracts', function (Blueprint $table) {
if (! Schema::hasColumn('contracts', 'meta')) {
$table->json('meta')->nullable()->after('description');
}
});
}
public function down(): void
{
Schema::table('contracts', function (Blueprint $table) {
if (Schema::hasColumn('contracts', 'meta')) {
$table->dropColumn('meta');
}
});
}
};

View File

@ -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('import_entities', function (Blueprint $table): void {
if (! Schema::hasColumn('import_entities', 'supports_multiple')) {
$table->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');
}
});
}
};

View File

@ -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('import_entities', function (Blueprint $table) {
if (! Schema::hasColumn('import_entities', 'meta')) {
$table->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');
}
});
}
};

View File

@ -0,0 +1,66 @@
<?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
{
public function up(): void
{
$driver = DB::getDriverName();
if ($driver === 'pgsql' || $driver === 'sqlite') {
// Partial unique indexes for non-deleted rows
DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS unique_emails_person_value_active ON emails(person_id, value) WHERE deleted_at IS NULL');
DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS unique_person_phones_person_nu_active ON person_phones(person_id, nu) WHERE deleted_at IS NULL');
DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS unique_person_addresses_person_address_active ON person_addresses(person_id, address) WHERE deleted_at IS NULL');
} elseif ($driver === 'mysql') {
// MySQL does not support partial indexes; use a generated stored column to represent active rows
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) {
$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`");
}
}
}
}
};

View File

@ -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'],

View File

@ -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,
]);
}
}
}

View File

@ -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);
<!-- Sidebar -->
<aside
:class="[
sidebarCollapsed ? 'w-16' : 'w-64',
widthClass,
'bg-white border-r border-gray-200 transition-all duration-200 z-50',
isMobile
? 'fixed inset-y-0 left-0 transform ' +
@ -124,18 +132,19 @@ const closeSearch = () => (searchOpen.value = false);
<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>
<span v-if="showLabels" class="text-sm font-semibold">Teren</span>
</Link>
</div>
<nav class="py-4">
<ul class="space-y-1">
<!-- Single phone link only -->
<!-- Assigned jobs link -->
<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.*')
route().current('phone.index') ||
(route().current('phone.case') && !isCompletedMode)
? 'bg-gray-100 text-gray-900'
: 'text-gray-600',
]"
@ -156,7 +165,38 @@ const closeSearch = () => (searchOpen.value = false);
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>
<span v-if="showLabels">Opravila</span>
</Link>
</li>
<!-- Completed today link -->
<li>
<Link
:href="route('phone.completed')"
:class="[
'flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100',
route().current('phone.completed') ||
(route().current('phone.case') && isCompletedMode)
? 'bg-gray-100 text-gray-900'
: 'text-gray-600',
]"
title="Zaključeno danes"
>
<!-- check-circle 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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span v-if="showLabels">Zaključeno danes</span>
</Link>
</li>
</ul>

View File

@ -24,6 +24,7 @@ import {
faBoxArchive,
faFileWord,
faSpinner,
faTags,
} from "@fortawesome/free-solid-svg-icons";
const props = defineProps({
@ -36,7 +37,10 @@ const props = defineProps({
// Debug: log incoming contract balances (remove after fix)
try {
console.debug('Contracts received (balances):', props.contracts.map(c => ({ ref: c.reference, bal: c?.account?.balance_amount })));
console.debug(
"Contracts received (balances):",
props.contracts.map((c) => ({ ref: c.reference, bal: c?.account?.balance_amount }))
);
} catch (e) {}
const emit = defineEmits(["edit", "delete", "add-activity"]);
@ -52,6 +56,107 @@ const hasDesc = (c) => {
return typeof d === "string" && d.trim().length > 0;
};
// Meta helpers
const formatMetaDate = (v) => {
if (!v) {
return "-";
}
const d = new Date(v);
if (isNaN(d.getTime())) {
return String(v);
}
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}`;
};
const formatMetaNumber = (v) => {
if (v === null || v === undefined || v === "") {
return "0";
}
let n = typeof v === "number" ? v : parseFloat(String(v).replace(",", "."));
if (isNaN(n)) {
return String(v);
}
const hasDecimal = Math.abs(n % 1) > 0;
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: hasDecimal ? 2 : 0,
maximumFractionDigits: hasDecimal ? 2 : 0,
}).format(n);
};
const formatMetaValue = (entry) => {
const value = entry?.value;
const type = entry?.type;
if (value === null || value === undefined || String(value).trim() === "") {
return "-";
}
if (type === "date") {
return formatMetaDate(value);
}
if (type === "number") {
return formatMetaNumber(value);
}
if (typeof value === "number") {
return formatMetaNumber(value);
}
if (typeof value === "string") {
// Try number
const n = parseFloat(value.replace(",", "."));
if (!isNaN(n)) {
return formatMetaNumber(n);
}
// Try date
const d = new Date(value);
if (!isNaN(d.getTime())) {
return formatMetaDate(value);
}
}
return String(value);
};
const getMetaEntries = (c) => {
const meta = c?.meta;
const results = [];
const visit = (node, keyName) => {
if (node === null || node === undefined) {
return;
}
if (Array.isArray(node)) {
node.forEach((el) => visit(el));
return;
}
if (typeof node === "object") {
const hasValue = Object.prototype.hasOwnProperty.call(node, "value");
const hasTitle = Object.prototype.hasOwnProperty.call(node, "title");
if (hasValue || hasTitle) {
const title =
(node.title || keyName || "").toString().trim() || keyName || "Meta";
results.push({ title, value: node.value, type: node.type });
return;
}
for (const [k, v] of Object.entries(node)) {
visit(v, k);
}
return;
}
if (keyName) {
results.push({ title: keyName, value: node });
}
};
visit(meta, undefined);
return results.filter(
(e) =>
e.title &&
e.value !== null &&
e.value !== undefined &&
String(e.value).trim() !== ""
);
};
const hasMeta = (c) => getMetaEntries(c).length > 0;
const onEdit = (c) => emit("edit", c);
const onDelete = (c) => emit("delete", c);
const onAddActivity = (c) => emit("add-activity", c);
@ -385,7 +490,7 @@ const closePaymentsDialog = () => {
}}</FwbTableCell>
<FwbTableCell class="text-center">
<div class="inline-flex items-center justify-center gap-0.5">
<Dropdown width="64" align="left">
<Dropdown align="right">
<template #trigger>
<button
type="button"
@ -414,8 +519,50 @@ const closePaymentsDialog = () => {
</template>
</Dropdown>
<!-- Meta data dropdown -->
<Dropdown align="right">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center h-5 w-5 rounded-full"
:title="'Pokaži meta'"
:disabled="!hasMeta(c)"
:class="
hasMeta(c)
? 'hover:bg-gray-100 focus:outline-none'
: 'text-gray-400'
"
>
<FontAwesomeIcon
:icon="faTags"
class="h-4 w-4"
:class="hasMeta(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">
<template v-if="hasMeta(c)">
<div
v-for="(m, idx) in getMetaEntries(c)"
:key="idx"
class="flex items-start gap-2 py-0.5"
>
<span class="text-gray-500 whitespace-nowrap"
>{{ m.title }}:</span
>
<span class="text-gray-800">{{ formatMetaValue(m) }}</span>
</div>
</template>
<template v-else>
<div class="text-gray-500">Ni meta podatkov.</div>
</template>
</div>
</template>
</Dropdown>
<!-- Promise date indicator -->
<Dropdown width="64" align="left">
<Dropdown align="right">
<template #trigger>
<button
type="button"

View File

@ -0,0 +1,250 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { ref } from "vue";
import { Link, router } from "@inertiajs/vue3";
import Pagination from "@/Components/Pagination.vue";
import PersonInfoGrid from "@/Components/PersonInfoGrid.vue";
import SectionTitle from "@/Components/SectionTitle.vue";
const props = defineProps({
client: Object,
contracts: Object,
filters: Object,
types: Object,
});
const fromDate = ref(props.filters?.from || "");
const toDate = ref(props.filters?.to || "");
const search = ref(props.filters?.search || "");
function applyDateFilter() {
const params = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
if (fromDate.value) {
params.from = fromDate.value;
} else {
delete params.from;
}
if (toDate.value) {
params.to = toDate.value;
} else {
delete params.to;
}
if (search.value && search.value.trim() !== "") {
params.search = search.value.trim();
} else {
delete params.search;
}
delete params.page;
router.get(route("client.contracts", { uuid: props.client.uuid }), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
}
function applySearch() {
const params = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
if (fromDate.value) {
params.from = fromDate.value;
} else {
delete params.from;
}
if (toDate.value) {
params.to = toDate.value;
} else {
delete params.to;
}
if (search.value && search.value.trim() !== "") {
params.search = search.value.trim();
} else {
delete params.search;
}
delete params.page;
router.get(route("client.contracts", { uuid: props.client.uuid }), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
}
// Build params for navigating to client case show, including active segment if available
function caseShowParams(contract) {
const params = { client_case: contract?.client_case?.uuid };
const segId = contract?.segments?.[0]?.id;
if (segId) {
params.segment = segId;
}
return params;
}
// Format YYYY-MM-DD (or ISO date) to dd.mm.yyyy
function formatDate(value) {
if (!value) return "-";
try {
const iso = String(value).split("T")[0];
const parts = iso.split("-");
if (parts.length !== 3) return value;
const [y, m, d] = parts;
return `${d.padStart(2, "0")}.${m.padStart(2, "0")}.${y}`;
} catch (e) {
return value;
}
}
</script>
<template>
<AppLayout title="Pogodbe">
<template #header></template>
<!-- Header card (matches Client/Show header style) -->
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
>
<div class="mx-auto max-w-4x1 p-3">
<div class="flex items-center justify-between">
<SectionTitle>
<template #title>
{{ client.person.full_name }}
</template>
</SectionTitle>
</div>
<div class="mt-2 flex items-center gap-3 text-sm">
<Link
:href="route('client.show', { uuid: client.uuid })"
class="px-2 py-1 rounded hover:underline"
>Primeri</Link
>
<span class="text-gray-300">|</span>
<Link
:href="route('client.contracts', { uuid: client.uuid })"
class="px-2 py-1 rounded text-indigo-600 hover:underline"
>Pogodbe</Link
>
</div>
</div>
</div>
</div>
</div>
<!-- Client details card (separate container) -->
<div class="pt-1">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
>
<div class="mx-auto max-w-4x1 px-2">
<PersonInfoGrid :types="types" :person="client.person" />
</div>
</div>
</div>
</div>
<!-- Contracts list card -->
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
<div class="mx-auto max-w-4x1 py-3">
<div
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
>
<div class="flex items-center gap-3 flex-wrap">
<label class="font-medium mr-2">Filter po datumu:</label>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">Od</span>
<input
type="date"
v-model="fromDate"
@change="applyDateFilter"
class="rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 text-sm"
/>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">Do</span>
<input
type="date"
v-model="toDate"
@change="applyDateFilter"
class="rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 text-sm"
/>
</div>
</div>
<div class="flex items-center gap-2">
<input
type="text"
v-model="search"
@keyup.enter="applySearch"
placeholder="Išči po referenci ali imenu"
class="rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 text-sm w-64"
/>
<button
type="button"
@click="applySearch"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded"
>
Išči
</button>
</div>
</div>
<div class="overflow-x-auto mt-3">
<table class="min-w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2 pr-4">Referenca</th>
<th class="py-2 pr-4">Stranka</th>
<th class="py-2 pr-4">Začetek</th>
<th class="py-2 pr-4">Segment</th>
<th class="py-2 pr-4 text-right">Stanje</th>
</tr>
</thead>
<tbody>
<tr
v-for="contract in contracts.data"
:key="contract.uuid"
class="border-b last:border-0"
>
<td class="py-2 pr-4">
<Link
:href="route('clientCase.show', caseShowParams(contract))"
class="text-indigo-600 hover:underline"
>
{{ contract.reference }}
</Link>
</td>
<td class="py-2 pr-4">
{{ contract.client_case?.person?.full_name || "-" }}
</td>
<td class="py-2 pr-4">
{{ formatDate(contract.start_date) }}
</td>
<td class="py-2 pr-4">{{ contract.segments?.[0]?.name || "-" }}</td>
<td class="py-2 pr-4 text-right">
{{
new Intl.NumberFormat("sl-SI", {
style: "currency",
currency: "EUR",
}).format(Number(contract.account?.balance_amount ?? 0))
}}
</td>
</tr>
<tr v-if="!contracts.data || contracts.data.length === 0">
<td colspan="5" class="py-4 text-gray-500">Ni zadetkov.</td>
</tr>
</tbody>
</table>
</div>
</div>
<Pagination
:links="contracts.links"
:from="contracts.from"
:to="contracts.to"
:total="contracts.total"
/>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@ -52,13 +52,28 @@ const openDrawerCreateCase = () => {
<div
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
>
<div class="mx-auto max-w-4x1 p-3 flex justify-between">
<div class="mx-auto max-w-4x1 p-3">
<div class="flex items-center justify-between">
<SectionTitle>
<template #title>
{{ client.person.full_name }}
</template>
</SectionTitle>
</div>
<div class="mt-2 flex items-center gap-3 text-sm">
<Link
:href="route('client.show', { uuid: client.uuid })"
class="px-2 py-1 rounded hover:underline"
>Primeri</Link
>
<span class="text-gray-300">|</span>
<Link
:href="route('client.contracts', { uuid: client.uuid })"
class="px-2 py-1 rounded text-indigo-600 hover:underline"
>Pogodbe</Link
>
</div>
</div>
</div>
</div>
</div>
@ -115,7 +130,6 @@ const openDrawerCreateCase = () => {
{{ c.person?.full_name || "-" }}
</Link>
</td>
<td class="py-2 pr-4">{{ c.person?.tax_number || "-" }}</td>
<td class="py-2 pr-4 text-right">
{{ c.active_contracts_count ?? 0 }}

View File

@ -130,6 +130,24 @@ function evaluateMappingSaved() {
persistedSignature.value = computeMappingSignature(mappingRows.value);
}
function normalizeOptions(val) {
if (!val) {
return {};
}
if (typeof val === "string") {
try {
const parsed = JSON.parse(val);
return parsed && typeof parsed === "object" ? parsed : {};
} catch (e) {
return {};
}
}
if (typeof val === "object") {
return val;
}
return {};
}
function computeMappingSignature(rows) {
return rows
.filter((r) => r && r.source_column)
@ -270,6 +288,7 @@ function defaultEntityDefs() {
"description",
"type_id",
"client_case_id",
"meta",
],
},
{
@ -359,6 +378,7 @@ const displayRows = computed(() => {
skip: false,
transform: "trim",
apply_mode: "both",
options: {},
position: idx,
}));
});
@ -570,6 +590,7 @@ async function fetchColumns() {
skip: false,
transform: "trim",
apply_mode: "both",
options: {},
position: idx,
}));
suppressMappingWatch = false;
@ -592,6 +613,7 @@ async function fetchColumns() {
skip: false,
transform: m.transform || "trim",
apply_mode: m.apply_mode || "both",
options: normalizeOptions(m.options),
position: idx,
};
});
@ -685,6 +707,7 @@ async function loadImportMappings() {
field,
transform: m.transform || "",
apply_mode: m.apply_mode || "both",
options: normalizeOptions(m.options) || r.options || {},
skip: false,
position: idx,
};
@ -738,7 +761,13 @@ async function saveMappings() {
target_field: `${entityKeyToRecord(r.entity)}.${r.field}`,
transform: r.transform || null,
apply_mode: r.apply_mode || "both",
options: null,
options:
r.field === "meta"
? {
key: r.options?.key ?? null,
type: r.options?.type ?? null,
}
: null,
}));
if (!mappings.length) {
mappingSaved.value = false;
@ -820,6 +849,7 @@ onMounted(async () => {
skip: false,
transform: "trim",
apply_mode: "both",
options: {},
position: idx,
};
});
@ -877,6 +907,7 @@ watch(
skip: false,
transform: "trim",
apply_mode: "both",
options: {},
position: idx,
};
});

View File

@ -35,6 +35,8 @@ function duplicateTarget(row){
<th class="p-2 border">Source column</th>
<th class="p-2 border">Entity</th>
<th class="p-2 border">Field</th>
<th class="p-2 border">Meta key</th>
<th class="p-2 border">Meta type</th>
<th class="p-2 border">Transform</th>
<th class="p-2 border">Apply mode</th>
<th class="p-2 border">Skip</th>
@ -55,6 +57,32 @@ function duplicateTarget(row){
<option v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</option>
</select>
</td>
<td class="p-2 border">
<input
v-if="row.field === 'meta'"
v-model="(row.options ||= {}).key"
type="text"
class="border rounded p-1 w-full"
placeholder="e.g. monthly_rent"
:disabled="isCompleted"
/>
<span v-else class="text-gray-400 text-xs"></span>
</td>
<td class="p-2 border">
<select
v-if="row.field === 'meta'"
v-model="(row.options ||= {}).type"
class="border rounded p-1 w-full"
:disabled="isCompleted"
>
<option :value="null">Default (string)</option>
<option value="string">string</option>
<option value="number">number</option>
<option value="date">date</option>
<option value="boolean">boolean</option>
</select>
<span v-else class="text-gray-400 text-xs"></span>
</td>
<td class="p-2 border">
<select v-model="row.transform" class="border rounded p-1 w-full" :disabled="isCompleted">
<option value="">None</option>

View File

@ -1,5 +1,5 @@
<script setup>
const props = defineProps({ mappings: Array })
const props = defineProps({ mappings: Array });
</script>
<template>
<div v-if="mappings?.length" class="pt-4">
@ -12,14 +12,30 @@ const props = defineProps({ mappings: Array })
<th class="p-2 border">Target field</th>
<th class="p-2 border">Transform</th>
<th class="p-2 border">Mode</th>
<th class="p-2 border">Options</th>
</tr>
</thead>
<tbody>
<tr v-for="m in mappings" :key="m.id || (m.source_column + m.target_field)" class="border-t">
<tr
v-for="m in mappings"
:key="m.id || m.source_column + m.target_field"
class="border-t"
>
<td class="p-2 border">{{ m.source_column }}</td>
<td class="p-2 border">{{ m.target_field }}</td>
<td class="p-2 border">{{ m.transform || '—' }}</td>
<td class="p-2 border">{{ m.apply_mode || 'both' }}</td>
<td class="p-2 border">{{ m.transform || "—" }}</td>
<td class="p-2 border">{{ m.apply_mode || "both" }}</td>
<td class="p-2 border">
<template v-if="m.options">
<span v-if="m.options.key" class="inline-block mr-2"
>key: <strong>{{ m.options.key }}</strong></span
>
<span v-if="m.options.type" class="inline-block"
>type: <strong>{{ m.options.type }}</strong></span
>
</template>
<span v-else></span>
</td>
</tr>
</tbody>
</table>

View File

@ -99,7 +99,28 @@ const entityStats = computed(() => {
if (!r.entities) continue;
for (const [k, ent] of Object.entries(r.entities)) {
if (!stats[k]) continue;
// Count one row per entity root
stats[k].total_rows++;
if (Array.isArray(ent)) {
for (const item of ent) {
switch (item.action) {
case "create":
stats[k].create++;
break;
case "update":
stats[k].update++;
break;
case "missing_ref":
stats[k].missing_ref++;
break;
case "invalid":
stats[k].invalid++;
break;
}
if (item.duplicate) stats[k].duplicate++;
if (item.duplicate_db) stats[k].duplicate_db++;
}
} else {
switch (ent.action) {
case "create":
stats[k].create++;
@ -118,6 +139,7 @@ const entityStats = computed(() => {
if (ent.duplicate_db) stats[k].duplicate_db++;
}
}
}
return stats;
});
const activeSummary = computed(() =>
@ -134,7 +156,9 @@ const visibleRows = computed(() => {
.filter((r) => {
if (!r.entities || !r.entities[activeEntity.value]) return false;
const ent = r.entities[activeEntity.value];
if (!Array.isArray(ent)) {
if (hideChain.value && ent.existing_chain) return false;
}
if (showOnlyChanged.value) {
// Define change criteria per entity
if (activeEntity.value === "account") {
@ -148,6 +172,9 @@ const visibleRows = computed(() => {
return ent.amount !== null && ent.amount !== undefined;
}
// Generic entities: any create/update considered change
if (Array.isArray(ent)) {
return ent.some((i) => i && (i.action === "create" || i.action === "update"));
}
if (ent.action === "create" || ent.action === "update") return true;
return false;
}
@ -371,16 +398,24 @@ function referenceOf(entityName, ent) {
class="font-semibold uppercase tracking-wide text-gray-600 mb-1 flex items-center justify-between"
>
<span>{{ activeEntity }}</span>
<template v-if="!Array.isArray(r.entities[activeEntity])">
<span
v-if="r.entities[activeEntity].action_label"
:class="[
:class="
[
'text-[10px] px-1 py-0.5 rounded',
r.entities[activeEntity].action === 'create' && 'bg-emerald-100 text-emerald-700',
r.entities[activeEntity].action === 'update' && 'bg-blue-100 text-blue-700',
r.entities[activeEntity].action === 'reactivate' && 'bg-purple-100 text-purple-700 font-semibold',
r.entities[activeEntity].action === 'skip' && 'bg-gray-100 text-gray-600',
r.entities[activeEntity].action === 'implicit' && 'bg-teal-100 text-teal-700'
].filter(Boolean)"
r.entities[activeEntity].action === 'create' &&
'bg-emerald-100 text-emerald-700',
r.entities[activeEntity].action === 'update' &&
'bg-blue-100 text-blue-700',
r.entities[activeEntity].action === 'reactivate' &&
'bg-purple-100 text-purple-700 font-semibold',
r.entities[activeEntity].action === 'skip' &&
'bg-gray-100 text-gray-600',
r.entities[activeEntity].action === 'implicit' &&
'bg-teal-100 text-teal-700',
].filter(Boolean)
"
>{{ r.entities[activeEntity].action_label }}</span
>
<span
@ -401,6 +436,7 @@ function referenceOf(entityName, ent) {
title="Implicitno"
>impl</span
>
</template>
</div>
<template v-if="activeEntity === 'account'">
@ -510,10 +546,13 @@ function referenceOf(entityName, ent) {
<div>
Akcija:
<span
:class="[
:class="
[
'font-medium inline-flex items-center gap-1',
r.entities[activeEntity].action === 'reactivate' && 'text-purple-700'
].filter(Boolean)"
r.entities[activeEntity].action === 'reactivate' &&
'text-purple-700',
].filter(Boolean)
"
>{{
r.entities[activeEntity].action_label ||
r.entities[activeEntity].action
@ -526,10 +565,144 @@ function referenceOf(entityName, ent) {
></span
>
</div>
<div v-if="r.entities[activeEntity].original_action === 'update' && r.entities[activeEntity].action === 'reactivate'" class="text-[10px] text-purple-600 mt-0.5">
<div
v-if="
r.entities[activeEntity].original_action === 'update' &&
r.entities[activeEntity].action === 'reactivate'
"
class="text-[10px] text-purple-600 mt-0.5"
>
(iz neaktivnega aktivno)
</div>
<div
v-if="r.entities[activeEntity].meta"
class="mt-1 text-[10px] text-gray-700"
>
<div class="font-semibold text-gray-600">Meta</div>
<div class="space-y-1">
<div
v-for="(entries, grp) in r.entities[activeEntity].meta"
:key="grp"
class="border rounded p-1 bg-white"
>
<div class="text-[9px] text-gray-500 mb-0.5">
skupina: {{ grp }}
</div>
<div
v-for="(entry, key) in entries"
:key="key"
class="flex items-center gap-2"
>
<span class="text-gray-500">{{ key }}:</span>
<span class="text-gray-800">{{ entry?.value ?? "—" }}</span>
<span class="text-gray-400">(iz: {{ entry?.title }})</span>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<!-- Multi-item rendering for grouped roots (email/phone/address) -->
<template v-if="Array.isArray(r.entities[activeEntity])">
<div class="space-y-1">
<div
v-for="(item, idx) in r.entities[activeEntity]"
:key="idx"
class="border rounded p-2 bg-white"
>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-1">
<span
v-if="
item.group !== undefined &&
item.group !== null &&
String(item.group) !== ''
"
class="text-[10px] px-1 py-0.5 rounded bg-gray-100 text-gray-700"
>skupina: {{ item.group }}</span
>
<span
v-if="item.identity_used"
class="px-1 py-0.5 rounded bg-indigo-50 text-indigo-700 text-[10px]"
title="Uporabljena identiteta"
>{{ item.identity_used }}</span
>
<span
v-if="item.duplicate"
class="px-1 py-0.5 rounded bg-amber-100 text-amber-700 text-[10px]"
title="Podvojen v tej seriji"
>duplikat</span
>
<span
v-if="item.duplicate_db"
class="px-1 py-0.5 rounded bg-amber-200 text-amber-800 text-[10px]"
title="Že obstaja v bazi"
>obstaja v bazi</span
>
</div>
<span
v-if="item.action_label"
:class="
[
'text-[10px] px-1 py-0.5 rounded',
item.action === 'create' &&
'bg-emerald-100 text-emerald-700',
item.action === 'update' &&
'bg-blue-100 text-blue-700',
item.action === 'skip' && 'bg-gray-100 text-gray-600',
].filter(Boolean)
"
>{{ item.action_label || item.action }}</span
>
</div>
<template v-if="activeEntity === 'email'">
<div class="text-[10px] text-gray-600">
Email:
<span class="font-medium">{{
referenceOf(activeEntity, item)
}}</span>
</div>
</template>
<template v-else-if="activeEntity === 'phone'">
<div class="text-[10px] text-gray-600">
Telefon:
<span class="font-medium">{{
referenceOf(activeEntity, item)
}}</span>
</div>
</template>
<template v-else-if="activeEntity === 'address'">
<div class="text-[10px] text-gray-600 space-y-0.5">
<div v-if="referenceOf(activeEntity, item) !== '—'">
Ref:
<span class="font-medium">{{
referenceOf(activeEntity, item)
}}</span>
</div>
<div v-if="item.address">
Naslov:
<span class="font-medium">{{ item.address }}</span>
</div>
<div v-if="item.postal_code || item.country">
Lokacija:
<span class="font-medium">{{
[item.postal_code, item.country]
.filter(Boolean)
.join(" ")
}}</span>
</div>
</div>
</template>
<template v-else>
<pre class="text-[10px] whitespace-pre-wrap">{{
item
}}</pre>
</template>
</div>
</div>
</template>
<!-- Single-item generic rendering (existing) -->
<template v-else>
<div class="flex flex-wrap gap-1 mb-1">
<span
@ -555,7 +728,8 @@ function referenceOf(entityName, ent) {
<div class="grid grid-cols-1 gap-0.5">
<div
v-if="
referenceOf(activeEntity, r.entities[activeEntity]) !== '—'
referenceOf(activeEntity, r.entities[activeEntity]) !==
'—'
"
class="text-[10px] text-gray-600"
>
@ -613,7 +787,9 @@ function referenceOf(entityName, ent) {
class="text-[10px] text-gray-600"
>
Identitete:
{{ r.entities[activeEntity].identity_candidates.join(", ") }}
{{
r.entities[activeEntity].identity_candidates.join(", ")
}}
</div>
</div>
</template>
@ -637,7 +813,8 @@ function referenceOf(entityName, ent) {
<div class="text-[10px] text-gray-600 space-y-0.5">
<div
v-if="
referenceOf(activeEntity, r.entities[activeEntity]) !== '—'
referenceOf(activeEntity, r.entities[activeEntity]) !==
'—'
"
>
Ref:
@ -673,7 +850,8 @@ function referenceOf(entityName, ent) {
<div class="text-[10px] text-gray-600 space-y-0.5">
<div
v-if="
referenceOf(activeEntity, r.entities[activeEntity]) !== '—'
referenceOf(activeEntity, r.entities[activeEntity]) !==
'—'
"
>
Ref:
@ -701,6 +879,7 @@ function referenceOf(entityName, ent) {
}}</pre>
</template>
</template>
</template>
</div>
</td>
<td class="p-2 border align-top text-[11px]">

View File

@ -55,6 +55,7 @@ const bulkGlobal = ref({
default_field: "",
transform: "",
apply_mode: "both",
group: "",
});
const unassigned = computed(() =>
(props.template.mappings || []).filter((m) => !m.target_field)
@ -104,6 +105,21 @@ function saveUnassigned(m) {
} else {
m.target_field = null;
}
if (st.group) {
m.options = m.options && typeof m.options === "object" ? m.options : {};
m.options.group = st.group;
}
// If targeting any .meta field, allow setting options.key via UI
if (st.field === "meta") {
if (st.metaKey && String(st.metaKey).trim() !== "") {
m.options = m.options && typeof m.options === "object" ? m.options : {};
m.options.key = String(st.metaKey).trim();
}
if (st.metaType && String(st.metaType).trim() !== "") {
m.options = m.options && typeof m.options === "object" ? m.options : {};
m.options.type = String(st.metaType).trim();
}
}
updateMapping(m);
}
@ -141,13 +157,22 @@ function addRow(entity) {
const row = newRows.value[entity];
if (!row || !row.source || !row.field) return;
const target_field = `${entity}.${row.field}`;
const opts = {};
if (row.group) opts.group = row.group;
if (entity === "contract" && row.field === "meta" && row.metaKey) {
opts.key = String(row.metaKey).trim();
}
const payload = {
source_column: row.source,
target_field,
transform: row.transform || null,
apply_mode: row.apply_mode || "both",
options: Object.keys(opts).length ? opts : null,
position: (props.template.mappings?.length || 0) + 1,
};
if (row.field === "meta" && row.metaType) {
opts.type = String(row.metaType).trim();
}
useForm(payload).post(
route("importTemplates.mappings.add", { template: props.template.uuid }),
{
@ -165,6 +190,7 @@ function updateMapping(m) {
target_field: m.target_field,
transform: m.transform,
apply_mode: m.apply_mode,
options: m.options || null,
position: m.position,
};
useForm(payload).put(
@ -602,6 +628,15 @@ watch(
<option value="update">update</option>
</select>
</div>
<div>
<label class="block text-xs text-indigo-900">Group (za vse)</label>
<input
v-model="bulkGlobal.group"
type="text"
class="mt-1 w-full border rounded p-2"
placeholder="1, 2, home, work"
/>
</div>
</div>
<div class="mt-3">
<button
@ -614,6 +649,7 @@ watch(
default_field: bulkGlobal.default_field || null,
transform: bulkGlobal.transform || null,
apply_mode: bulkGlobal.apply_mode || 'both',
group: bulkGlobal.group || '',
}).post(
route('importTemplates.mappings.bulk', {
template: props.template.uuid,
@ -626,6 +662,7 @@ watch(
bulkGlobal.default_field = '';
bulkGlobal.transform = '';
bulkGlobal.apply_mode = 'both';
bulkGlobal.group = '';
},
}
);
@ -710,6 +747,39 @@ watch(
</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Group</label>
<input
v-model="(unassignedState[m.id] ||= {}).group"
class="mt-1 w-full border rounded p-2"
type="text"
placeholder="1, 2, home, work"
/>
</div>
<div v-if="(unassignedState[m.id] || {}).field === 'meta'">
<label class="block text-xs text-gray-600">Meta key</label>
<input
v-model="(unassignedState[m.id] ||= {}).metaKey"
class="mt-1 w-full border rounded p-2"
type="text"
placeholder="npr.: note, category"
/>
<label class="block text-xs text-gray-600 mt-2">Meta type</label>
<select
v-model="(unassignedState[m.id] ||= {}).metaType"
class="mt-1 w-full border rounded p-2"
>
<option value="">(auto/string)</option>
<option value="string">string</option>
<option value="number">number</option>
<option value="date">date</option>
<option value="boolean">boolean</option>
</select>
<p class="text-[11px] text-gray-500 mt-1">
Če ne določiš, lahko uporabiš tudi zapis cilja kot
<code>contract.meta[key]</code>.
</p>
</div>
<div>
<label class="block text-xs text-gray-600">Transform</label>
<select v-model="m.transform" class="mt-1 w-full border rounded p-2">
@ -800,7 +870,7 @@ watch(
class="flex items-center justify-between p-2 border rounded gap-3"
>
<div
class="grid grid-cols-1 sm:grid-cols-5 gap-2 flex-1 items-center"
class="grid grid-cols-1 sm:grid-cols-6 gap-2 flex-1 items-center"
>
<input
v-model="m.source_column"
@ -822,6 +892,28 @@ watch(
<option value="update">update</option>
<option value="keyref">keyref (use as lookup key)</option>
</select>
<input
v-model="(m.options ||= {}).group"
class="border rounded p-2 text-sm"
placeholder="Group"
/>
<input
v-if="/^(contracts?\.meta)(\.|\[|$)/.test(m.target_field || '')"
v-model="(m.options ||= {}).key"
class="border rounded p-2 text-sm"
placeholder="Meta key"
/>
<select
v-if="/^(contracts?\.meta)(\.|\[|$)/.test(m.target_field || '')"
v-model="(m.options ||= {}).type"
class="border rounded p-2 text-sm"
>
<option value="">(auto/string)</option>
<option value="string">string</option>
<option value="number">number</option>
<option value="date">date</option>
<option value="boolean">boolean</option>
</select>
<div class="flex items-center gap-2">
<button
class="px-2 py-1 text-xs border rounded"
@ -859,7 +951,7 @@ watch(
<!-- Add new mapping row -->
<div class="p-3 bg-gray-50 rounded border">
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 items-end">
<div class="grid grid-cols-1 sm:grid-cols-6 gap-2 items-end">
<div>
<label class="block text-xs text-gray-600"
>Source column (ne-dodeljene)</label
@ -919,6 +1011,35 @@ watch(
<option value="keyref">keyref (use as lookup key)</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Group</label>
<input
v-model="(newRows[entity] ||= {}).group"
class="mt-1 w-full border rounded p-2"
type="text"
placeholder="1, 2, home, work"
/>
</div>
<div v-if="(newRows[entity] || {}).field === 'meta'">
<label class="block text-xs text-gray-600">Meta key</label>
<input
v-model="(newRows[entity] ||= {}).metaKey"
class="mt-1 w-full border rounded p-2"
type="text"
placeholder="npr.: note, category"
/>
<label class="block text-xs text-gray-600 mt-2">Meta type</label>
<select
v-model="(newRows[entity] ||= {}).metaType"
class="mt-1 w-full border rounded p-2"
>
<option value="">(auto/string)</option>
<option value="string">string</option>
<option value="number">number</option>
<option value="date">date</option>
<option value="boolean">boolean</option>
</select>
</div>
<div class="sm:col-span-1">
<button
@click.prevent="addRow(entity)"
@ -992,6 +1113,15 @@ watch(
<option value="keyref">keyref (use as lookup key)</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Group (za vse)</label>
<input
v-model="(bulkRows[entity] ||= {}).group"
class="mt-1 w-full border rounded p-2"
type="text"
placeholder="1, 2, home, work"
/>
</div>
</div>
<div class="mt-2">
<button
@ -1006,6 +1136,7 @@ watch(
default_field: b.default_field || null,
transform: b.transform || null,
apply_mode: b.apply_mode || 'both',
group: b.group || '',
}).post(
route('importTemplates.mappings.bulk', {
template: props.template.uuid,
@ -1051,6 +1182,7 @@ watch(
target_field: `${s.entity}.${s.field}`,
transform: b.transform || null,
apply_mode: b.apply_mode || 'both',
options: b.group ? { group: b.group } : null,
position: (props.template.mappings?.length || 0) + 1,
};
useForm(payload).post(

View File

@ -24,6 +24,7 @@ const props = defineProps({
types: Object,
actions: Array,
activities: Array,
completed_mode: { type: Boolean, default: false },
});
const viewer = reactive({ open: false, src: "", title: "" });
@ -206,7 +207,14 @@ const clientSummary = computed(() => {
</h2>
</div>
<div class="shrink-0">
<span
v-if="props.completed_mode"
class="inline-flex items-center px-2 py-1 rounded-full bg-emerald-100 text-emerald-700 text-xs font-medium"
>
Zaključeno danes
</span>
<button
v-else
type="button"
class="px-3 py-2 rounded bg-green-600 text-white hover:bg-green-700"
@click="confirmComplete = true"
@ -247,6 +255,12 @@ const clientSummary = computed(() => {
<span class="truncate">{{ client_case.person.full_name }}</span>
<span class="chip-base chip-indigo">Primer</span>
</h3>
<div
v-if="client_case?.person?.description"
class="mt-2 text-sm text-gray-700 whitespace-pre-line"
>
{{ client_case.person.description }}
</div>
<div class="mt-4 pt-4 border-t border-dashed">
<PersonDetailPhone
:types="types"

View File

@ -1,103 +1,215 @@
<script setup>
import AppPhoneLayout from '@/Layouts/AppPhoneLayout.vue';
import { computed, ref } from 'vue';
import AppPhoneLayout from "@/Layouts/AppPhoneLayout.vue";
import { computed, ref } from "vue";
const props = defineProps({
jobs: { type: Array, default: () => [] },
view_mode: { type: String, default: "assigned" }, // 'assigned' | 'completed-today'
});
const items = computed(() => props.jobs || []);
// Client filter options derived from jobs
const clientFilter = ref("");
const clientOptions = computed(() => {
const map = new Map();
for (const job of items.value) {
const client = job?.contract?.client_case?.client;
const uuid = client?.uuid;
const name = client?.person?.full_name;
if (uuid && name && !map.has(uuid)) {
map.set(uuid, { uuid, name });
}
}
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
});
// Search filter (contract reference or person full name)
const search = ref('');
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);
return items.value.filter((job) => {
// Filter by selected client (if any)
if (clientFilter.value) {
const juuid = job?.contract?.client_case?.client?.uuid;
if (juuid !== clientFilter.value) {
return false;
}
}
// Text search
if (!term) return true;
const refStr = (job.contract?.reference || job.contract?.uuid || "")
.toString()
.toLowerCase();
const nameStr = (job.contract?.client_case?.person?.full_name || "").toLowerCase();
const clientNameStr = (
job.contract?.client_case?.client?.person?.full_name || ""
).toLowerCase();
return (
refStr.includes(term) || nameStr.includes(term) || clientNameStr.includes(term)
);
});
});
function formatDateDMY(d) {
if (!d) return '-';
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';
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 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 (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 });
return num.toLocaleString("sl-SI", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
// Safely resolve a client case UUID for a job
function getCaseUuid(job) {
return (
job?.contract?.client_case?.uuid || job?.client_case?.uuid || job?.case_uuid || null
);
}
</script>
<template>
<AppPhoneLayout title="Phone">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Moja terenska opravila</h2>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{
props.view_mode === "completed-today"
? "Zaključena opravila danes"
: "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 class="mb-4 flex items-center gap-2 flex-wrap">
<select
v-model="clientFilter"
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="">Vsi naročniki</option>
<option v-for="c in clientOptions" :key="c.uuid" :value="c.uuid">
{{ c.name }}
</option>
</select>
<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
v-for="job in filteredJobs"
:key="job.id"
class="bg-white rounded-lg shadow border p-3 sm:p-4"
>
<div class="mb-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>
<a
v-if="getCaseUuid(job)"
:href="
route('phone.case', {
client_case: getCaseUuid(job),
completed: props.view_mode === 'completed-today' ? 1 : undefined,
})
"
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>
<button
v-else
type="button"
disabled
class="inline-flex-1 flex-1 text-center px-3 py-2 rounded-md bg-gray-300 text-gray-600 text-sm cursor-not-allowed"
>
Manjka primer
</button>
</div>
<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>
<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 || '—' }}
{{ job.contract?.client_case?.person?.full_name || "—" }}
</p>
<p class="text-sm text-gray-600 truncate">Kontrakt: {{ job.contract?.reference || job.contract?.uuid }}
<p class="text-sm text-gray-600">
Naročnik:
<span class="font-semibold text-gray-800">
{{ job.contract?.client_case?.client?.person?.full_name || "—" }}
</span>
</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">
<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 || '—' }}
{{ job.contract?.client_case?.person?.addresses?.[0]?.address || "—" }}
</p>
<p>
<span class="font-medium">Telefon:</span>
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || '—' }}
{{ job.contract?.client_case?.person?.phones?.[0]?.nu || "—" }}
</p>
</div>
</div>
</template>
<div v-else class="col-span-full bg-white rounded-lg shadow border p-6 text-center text-gray-600">
<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>

View File

@ -78,6 +78,7 @@
// Phone page
Route::get('phone', [PhoneViewController::class, 'index'])->name('phone.index');
Route::get('phone/completed', [PhoneViewController::class, 'completedToday'])->name('phone.completed');
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');
@ -206,6 +207,7 @@
// client
Route::get('clients', [ClientController::class, 'index'])->name('client');
Route::get('clients/{client:uuid}', [ClientController::class, 'show'])->name('client.show');
Route::get('clients/{client:uuid}/contracts', [ClientController::class, 'contracts'])->name('client.contracts');
Route::post('clients', [ClientController::class, 'store'])->name('client.store');
Route::put('clients/{client:uuid}', [ClientController::class, 'update'])->name('client.update');
Route::post('clients/{client:uuid}/emergency-person', [ClientController::class, 'emergencyCreatePerson'])->name('client.emergencyPerson');

View File

@ -0,0 +1,79 @@
<?php
use App\Models\ImportEntity;
use App\Services\ImportProcessor;
it('groups multiple items per root when supports_multiple is true', function (): void {
// Arrange: ensure ImportEntity supports_multiple for email
ImportEntity::updateOrCreate(['key' => 'emails'], [
'key' => 'emails',
'canonical_root' => 'email',
'label' => 'Emails',
'fields' => ['value'],
'aliases' => ['email', 'emails'],
'supports_multiple' => true,
]);
// Fake mappings: two email.value fields with groups 1 and 2
$mappings = collect([
(object) ['source_column' => 'Email 1', 'target_field' => 'email.value', 'transform' => null, 'apply_mode' => 'both', 'options' => ['group' => '1']],
(object) ['source_column' => 'Email 2', 'target_field' => 'email.value', 'transform' => null, 'apply_mode' => 'both', 'options' => ['group' => '2']],
]);
$proc = new class extends ImportProcessor
{
public function pubLoad(): array
{
return $this->loadImportEntityConfig();
}
public function pubApply(array $raw, $mappings, array $supports): array
{
return $this->applyMappings($raw, $mappings, $supports);
}
};
[, , , $supports] = $proc->pubLoad();
$raw = ['Email 1' => 'a@example.test', 'Email 2' => 'b@example.test'];
[, $mapped] = $proc->pubApply($raw, $mappings, $supports);
expect($mapped)
->toHaveKey('email')
->and($mapped['email'])
->toBeArray()
->and($mapped['email']['1']['value'] ?? null)->toBe('a@example.test')
->and($mapped['email']['2']['value'] ?? null)->toBe('b@example.test');
});
it('deduplicates grouped items within a single row', function (): void {
ImportEntity::updateOrCreate(['key' => 'emails'], [
'key' => 'emails', 'canonical_root' => 'email', 'label' => 'Emails', 'fields' => ['value'], 'aliases' => ['email', 'emails'], 'supports_multiple' => true,
]);
$mappings = collect([
(object) ['source_column' => 'Email 1', 'target_field' => 'email.value', 'transform' => null, 'apply_mode' => 'both', 'options' => ['group' => '1']],
(object) ['source_column' => 'Email 2', 'target_field' => 'email.value', 'transform' => null, 'apply_mode' => 'both', 'options' => ['group' => '2']],
]);
$proc = new class extends ImportProcessor
{
public function pubLoad(): array
{
return $this->loadImportEntityConfig();
}
public function pubApply(array $raw, $mappings, array $supports): array
{
return $this->applyMappings($raw, $mappings, $supports);
}
public function pubDedupe(array $grouped): array
{
return $this->dedupeGroupedItems($grouped, 'value', fn ($v) => is_null($v) ? null : mb_strtolower(trim((string) $v)));
}
};
[, , , $supports] = $proc->pubLoad();
$raw = ['Email 1' => 'a@example.test', 'Email 2' => 'A@EXAMPLE.TEST'];
[, $mapped] = $proc->pubApply($raw, $mappings, $supports);
$deduped = $proc->pubDedupe($mapped['email']);
expect($deduped)->toHaveCount(1);
});