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:
parent
c8029c9eb0
commit
0598261cdc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -18,9 +18,15 @@ public function index(Request $request)
|
|||
->whereNull('cancelled_at')
|
||||
->with([
|
||||
'contract' => function ($q) {
|
||||
$q->with(['type:id,name', 'account', 'clientCase.person' => function ($pq) {
|
||||
$pq->with(['addresses', 'phones']);
|
||||
}]);
|
||||
$q->with([
|
||||
'type:id,name',
|
||||
'account',
|
||||
'clientCase.person' => function ($pq) {
|
||||
$pq->with(['addresses', 'phones']);
|
||||
},
|
||||
'clientCase.client:id,uuid,person_id',
|
||||
'clientCase.client.person:id,full_name',
|
||||
]);
|
||||
},
|
||||
])
|
||||
->orderByDesc('assigned_at')
|
||||
|
|
@ -29,6 +35,41 @@ public function index(Request $request)
|
|||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
'view_mode' => 'assigned',
|
||||
]);
|
||||
}
|
||||
|
||||
public function completedToday(Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
|
||||
$start = now()->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
|
||||
$jobs = FieldJob::query()
|
||||
->where('assigned_user_id', $userId)
|
||||
->whereNull('cancelled_at')
|
||||
->whereBetween('completed_at', [$start, $end])
|
||||
->with([
|
||||
'contract' => function ($q) {
|
||||
$q->with([
|
||||
'type:id,name',
|
||||
'account',
|
||||
'clientCase.person' => function ($pq) {
|
||||
$pq->with(['addresses', 'phones']);
|
||||
},
|
||||
'clientCase.client:id,uuid,person_id',
|
||||
'clientCase.client.person:id,full_name',
|
||||
]);
|
||||
},
|
||||
])
|
||||
->orderByDesc('completed_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
'view_mode' => 'completed-today',
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -136,6 +177,7 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
|||
'account_types' => \App\Models\AccountType::all(),
|
||||
'actions' => \App\Models\Action::with('decisions')->get(),
|
||||
'activities' => $activities,
|
||||
'completed_mode' => (bool) $request->boolean('completed'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']);
|
||||
|
||||
// Load dynamic entity config
|
||||
[$rootAliasMap, $fieldAliasMap, $validRoots] = $this->loadImportEntityConfig();
|
||||
[$rootAliasMap, $fieldAliasMap, $validRoots, $supportsMultiple] = $this->loadImportEntityConfig();
|
||||
// Normalize aliases (plural/legacy roots, field names) before validation
|
||||
$mappings = $this->normalizeMappings($mappings, $rootAliasMap, $fieldAliasMap);
|
||||
// Validate mapping roots early to avoid silent failures due to typos
|
||||
|
|
@ -219,9 +219,10 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
if ($isPg) {
|
||||
// No DB changes were made for this row; nothing to roll back explicitly.
|
||||
}
|
||||
|
||||
continue; // proceed to next CSV row
|
||||
}
|
||||
[$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings);
|
||||
[$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings, $supportsMultiple);
|
||||
|
||||
// Determine row-level reactivation intent: precedence row > import > template
|
||||
$rowReactivate = false;
|
||||
|
|
@ -751,9 +752,10 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
|
||||
// Resolve by contact values next
|
||||
if (! $personIdForRow) {
|
||||
$emailVal = trim((string) ($mapped['email']['value'] ?? ''));
|
||||
$phoneNu = trim((string) ($mapped['phone']['nu'] ?? ''));
|
||||
$addrLine = trim((string) ($mapped['address']['address'] ?? ''));
|
||||
// consider first values from multi groups if present
|
||||
$emailVal = trim((string) ($this->firstFromMulti($mapped, 'email', 'value') ?? ''));
|
||||
$phoneNu = trim((string) ($this->firstFromMulti($mapped, 'phone', 'nu') ?? ''));
|
||||
$addrLine = trim((string) ($this->firstFromMulti($mapped, 'address', 'address') ?? ''));
|
||||
|
||||
// Try to resolve by existing contacts first
|
||||
if ($emailVal !== '') {
|
||||
|
|
@ -814,22 +816,57 @@ public function process(Import $import, ?Authenticatable $user = null): array
|
|||
|
||||
$contactChanged = false;
|
||||
if ($personIdForRow) {
|
||||
if (! empty($mapped['email'] ?? [])) {
|
||||
$r = $this->upsertEmail($personIdForRow, $mapped['email'], $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
}
|
||||
}
|
||||
if (! empty($mapped['address'] ?? [])) {
|
||||
$r = $this->upsertAddress($personIdForRow, $mapped['address'], $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
}
|
||||
}
|
||||
if (! empty($mapped['phone'] ?? [])) {
|
||||
$r = $this->upsertPhone($personIdForRow, $mapped['phone'], $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
// Fan-out for multi-supported roots; backward compatible for single hashes
|
||||
foreach (['email' => 'upsertEmail', 'address' => 'upsertAddress', 'phone' => 'upsertPhone'] as $root => $method) {
|
||||
if (isset($mapped[$root]) && is_array($mapped[$root])) {
|
||||
// If it's a grouped map (supports multiple), iterate groups; else treat as single data hash
|
||||
$data = $mapped[$root];
|
||||
$isGrouped = $this->isGroupedMulti($data);
|
||||
if ($isGrouped) {
|
||||
// De-duplicate grouped items within the same row by their unique key per root
|
||||
$keyField = $root === 'email' ? 'value' : ($root === 'phone' ? 'nu' : 'address');
|
||||
$normalizer = function ($v) use ($root) {
|
||||
if ($v === null) {
|
||||
return null;
|
||||
}
|
||||
$s = trim((string) $v);
|
||||
if ($s === '') {
|
||||
return '';
|
||||
}
|
||||
if ($root === 'email') {
|
||||
return mb_strtolower($s);
|
||||
}
|
||||
if ($root === 'phone') {
|
||||
// Keep leading + and digits only for comparison
|
||||
$s = preg_replace('/[^0-9+]/', '', $s) ?? $s;
|
||||
// Collapse multiple + to single leading
|
||||
$s = ltrim($s, '+');
|
||||
|
||||
return '+'.$s;
|
||||
}
|
||||
// address: normalize whitespace and lowercase for comparison
|
||||
$s = preg_replace('/\s+/', ' ', $s) ?? $s;
|
||||
|
||||
return mb_strtolower(trim($s));
|
||||
};
|
||||
$data = $this->dedupeGroupedItems($data, $keyField, $normalizer);
|
||||
foreach ($data as $grp => $payload) {
|
||||
if (empty(array_filter($payload, fn ($v) => ! is_null($v) && trim((string) $v) !== ''))) {
|
||||
continue; // skip empty group
|
||||
}
|
||||
$r = $this->{$method}($personIdForRow, $payload, $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (! empty($data)) {
|
||||
$r = $this->{$method}($personIdForRow, $data, $mappings);
|
||||
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
|
||||
$contactChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -986,7 +1023,7 @@ private function buildRowAssoc(array $row, ?array $header): array
|
|||
return $assoc;
|
||||
}
|
||||
|
||||
private function applyMappings(array $raw, $mappings): array
|
||||
protected function applyMappings(array $raw, $mappings, array $supportsMultiple): array
|
||||
{
|
||||
$recordType = null;
|
||||
$mapped = [];
|
||||
|
|
@ -1025,11 +1062,135 @@ private function applyMappings(array $raw, $mappings): array
|
|||
|
||||
// detect record type from first segment, e.g., "account.balance_amount"
|
||||
$parts = explode('.', $target);
|
||||
if (! $recordType && isset($parts[0])) {
|
||||
$recordType = $parts[0];
|
||||
$rootWithBracket = $parts[0] ?? '';
|
||||
// Support bracket grouping like address[1].city
|
||||
$group = null;
|
||||
$root = $rootWithBracket;
|
||||
if (preg_match('/^(?P<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);
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
// Note: meta merging for contracts is handled in upsertContractChain, not here
|
||||
if (! empty($changes)) {
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
}
|
||||
|
||||
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
|
||||
if (array_key_exists('balance_amount', $changes)) {
|
||||
|
|
@ -1421,18 +1579,71 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
|||
}
|
||||
|
||||
if ($existing) {
|
||||
if (empty($applyUpdate)) {
|
||||
// Return existing contract reference even when skipped so callers can treat as resolved
|
||||
return ['action' => 'skipped', 'message' => 'No contract fields marked for update', 'contract' => $existing];
|
||||
}
|
||||
// 1) Prepare contract field changes (non-null)
|
||||
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
|
||||
if (empty($changes)) {
|
||||
return ['action' => 'skipped', 'message' => 'No non-null contract changes', 'contract' => $existing];
|
||||
|
||||
// 2) Prepare meta changes if provided via mapping
|
||||
$metaUpdated = false;
|
||||
$metaAppliedKeys = [];
|
||||
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
|
||||
// Flatten incoming grouped meta to key => {title, value}
|
||||
$incomingMeta = [];
|
||||
foreach ($contractData['meta'] as $grp => $entries) {
|
||||
if (! is_array($entries)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entries as $k => $v) {
|
||||
// v is expected as [title, value]
|
||||
$incomingMeta[$k] = $v;
|
||||
}
|
||||
}
|
||||
if (! empty($incomingMeta)) {
|
||||
$currentMeta = is_array($existing->meta ?? null) ? $existing->meta : (json_decode((string) $existing->meta, true) ?: []);
|
||||
foreach ($incomingMeta as $k => $entry) {
|
||||
$newVal = is_array($entry) && array_key_exists('value', $entry) ? $entry['value'] : $entry;
|
||||
$newTitle = is_array($entry) && array_key_exists('title', $entry) ? $entry['title'] : null;
|
||||
$newType = is_array($entry) && array_key_exists('type', $entry) ? $entry['type'] : null;
|
||||
$curEntry = $currentMeta[$k] ?? null;
|
||||
$curVal = is_array($curEntry) && array_key_exists('value', $curEntry) ? $curEntry['value'] : $curEntry;
|
||||
$curTitle = is_array($curEntry) && array_key_exists('title', $curEntry) ? $curEntry['title'] : null;
|
||||
$curType = is_array($curEntry) && array_key_exists('type', $curEntry) ? $curEntry['type'] : null;
|
||||
// Update when value differs, or title differs, or type differs
|
||||
$shouldUpdate = ($newVal !== $curVal) || ($newTitle !== null && $newTitle !== $curTitle) || ($newType !== null && $newType !== $curType);
|
||||
if ($shouldUpdate) {
|
||||
if (is_array($entry)) {
|
||||
$currentMeta[$k] = $entry;
|
||||
} else {
|
||||
$currentMeta[$k] = ['title' => (string) $k, 'value' => $newVal];
|
||||
}
|
||||
$metaUpdated = true;
|
||||
$metaAppliedKeys[] = $k;
|
||||
}
|
||||
}
|
||||
if ($metaUpdated) {
|
||||
$existing->meta = $currentMeta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($changes) && ! $metaUpdated) {
|
||||
// Nothing to change
|
||||
return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing];
|
||||
}
|
||||
|
||||
if (! empty($changes)) {
|
||||
$existing->fill($changes);
|
||||
}
|
||||
$existing->fill($changes);
|
||||
$existing->save();
|
||||
|
||||
return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $changes];
|
||||
// Build applied fields info, include meta keys if any
|
||||
$applied = $changes;
|
||||
if ($metaUpdated && ! empty($metaAppliedKeys)) {
|
||||
foreach ($metaAppliedKeys as $k) {
|
||||
$applied['meta:'.$k] = 'updated';
|
||||
}
|
||||
}
|
||||
|
||||
return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $applied];
|
||||
} else {
|
||||
if (empty($applyInsert)) {
|
||||
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
|
||||
|
|
@ -1459,6 +1670,21 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
|
|||
// ensure required defaults
|
||||
$data['start_date'] = $data['start_date'] ?? now()->toDateString();
|
||||
$data['type_id'] = $data['type_id'] ?? $this->getDefaultContractTypeId();
|
||||
// Merge meta for create if provided
|
||||
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
|
||||
$incomingMeta = [];
|
||||
foreach ($contractData['meta'] as $grp => $entries) {
|
||||
if (! is_array($entries)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entries as $k => $v) {
|
||||
$incomingMeta[$k] = $v;
|
||||
}
|
||||
}
|
||||
if (! empty($incomingMeta)) {
|
||||
$data['meta'] = $incomingMeta;
|
||||
}
|
||||
}
|
||||
$created = Contract::create($data);
|
||||
|
||||
return ['action' => 'inserted', 'contract' => $created, 'applied_fields' => $data];
|
||||
|
|
@ -1503,6 +1729,7 @@ private function normalizeDate(?string $raw): ?string
|
|||
if ($ts === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date('Y-m-d', $ts);
|
||||
}
|
||||
|
||||
|
|
@ -1712,6 +1939,7 @@ private function rowIsEffectivelyEmpty(array $rawAssoc): bool
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1775,7 +2003,15 @@ private function normalizeTargetField(string $target, array $rootAliasMap, array
|
|||
return $target;
|
||||
}
|
||||
$parts = explode('.', $target);
|
||||
$root = $parts[0] ?? '';
|
||||
$rootWithBracket = $parts[0] ?? '';
|
||||
// Extract optional bracket group from root (e.g., address[1]) but preserve it after aliasing
|
||||
$bracket = null;
|
||||
if (preg_match('/^(?P<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
|
||||
|
|
|
|||
|
|
@ -32,8 +32,9 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
|||
$delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ',';
|
||||
$columns = is_array($meta['columns'] ?? null) ? $meta['columns'] : [];
|
||||
|
||||
$targetToSource = $this->buildTargetLookup($import);
|
||||
if (! $targetToSource) {
|
||||
// Build both flat and grouped lookups
|
||||
[$targetToSource, $groupedLookup] = $this->buildTargetLookup($import);
|
||||
if (! $targetToSource && ! $groupedLookup) {
|
||||
return $this->errorPayload('Ni shranjenih mapiranj za ta uvoz.');
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +48,8 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
|||
|
||||
// Discover mapped entity roots and then filter by supported list with safe fallbacks
|
||||
$detectedRoots = $this->detectEntityRoots($targetToSource);
|
||||
// Roots that support multiple grouped entries
|
||||
$multiRoots = $this->loadSupportsMultipleRoots();
|
||||
$supported = $this->loadSupportedEntityRoots();
|
||||
$entityRoots = $this->filterEntityRoots($detectedRoots, $supported, $targetToSource);
|
||||
$summaries = $this->initSummaries($entityRoots);
|
||||
|
|
@ -104,6 +107,26 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
|||
return null;
|
||||
};
|
||||
|
||||
// Grouped values accessor: returns [group => value] for given root.field
|
||||
$groupVals = function (string $root, string $field) use ($assoc, $groupedLookup, $targetToSource) {
|
||||
$out = [];
|
||||
$key = $root.'.'.$field;
|
||||
if (isset($groupedLookup[$root])) {
|
||||
foreach ($groupedLookup[$root] as $g => $fields) {
|
||||
if (isset($fields[$field])) {
|
||||
$col = $fields[$field];
|
||||
$out[$g] = $assoc[$col] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also include ungrouped flat mapping as default group when no explicit group exists
|
||||
if (isset($targetToSource[$key]) && ! isset($out[''])) {
|
||||
$out[''] = $assoc[$targetToSource[$key]] ?? null;
|
||||
}
|
||||
|
||||
return $out;
|
||||
};
|
||||
|
||||
// Contract
|
||||
if (isset($entityRoots['contract'])) {
|
||||
[$contractEntity, $summaries, $contractCache] = $this->simulateContract($val, $summaries, $contractCache, $val('contract.reference'));
|
||||
|
|
@ -116,6 +139,49 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
|||
$contractEntity['action'] = 'reactivate';
|
||||
$contractEntity['reactivation'] = true;
|
||||
}
|
||||
// Attach contract meta preview from mappings (group-aware)
|
||||
$metaGroups = [];
|
||||
// Grouped contract.meta.* via groupedLookup
|
||||
if (isset($groupedLookup['contract'])) {
|
||||
foreach ($groupedLookup['contract'] as $g => $fields) {
|
||||
foreach ($fields as $f => $srcCol) {
|
||||
if (str_starts_with($f, 'meta.')) {
|
||||
$key = substr($f, strlen('meta.'));
|
||||
if ($key !== '') {
|
||||
if (! isset($metaGroups[$g])) {
|
||||
$metaGroups[$g] = [];
|
||||
}
|
||||
$metaGroups[$g][$key] = [
|
||||
'title' => $srcCol,
|
||||
'value' => $assoc[$srcCol] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flat contract.meta.* (no group): assign to group '1'
|
||||
foreach ($targetToSource as $tf => $srcCol) {
|
||||
if (str_starts_with($tf, 'contract.meta.')) {
|
||||
$key = substr($tf, strlen('contract.meta.'));
|
||||
if ($key !== '') {
|
||||
$g = '1';
|
||||
if (! isset($metaGroups[$g])) {
|
||||
$metaGroups[$g] = [];
|
||||
}
|
||||
// Do not override grouped if already present
|
||||
if (! isset($metaGroups[$g][$key])) {
|
||||
$metaGroups[$g][$key] = [
|
||||
'title' => $srcCol,
|
||||
'value' => $assoc[$srcCol] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (! empty($metaGroups)) {
|
||||
$contractEntity['meta'] = $metaGroups;
|
||||
}
|
||||
$rowEntities['contract'] = $contractEntity + [
|
||||
'action_label' => $translatedActions[$contractEntity['action']] ?? $contractEntity['action'],
|
||||
];
|
||||
|
|
@ -156,22 +222,45 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
|||
}
|
||||
$reference = $val($rootKey.'.reference');
|
||||
$identityCandidates = $this->genericIdentityCandidates($rootKey, $val);
|
||||
[$genericEntity, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities]
|
||||
= $this->simulateGenericRoot(
|
||||
$rootKey,
|
||||
$val,
|
||||
$summaries,
|
||||
$genericCaches,
|
||||
$reference,
|
||||
$identityCandidates,
|
||||
$genericExistingIdentities,
|
||||
$genericSeenIdentities,
|
||||
$verbose,
|
||||
$targetToSource,
|
||||
);
|
||||
$rowEntities[$rootKey] = $genericEntity + [
|
||||
'action_label' => $translatedActions[$genericEntity['action']] ?? $genericEntity['action'],
|
||||
];
|
||||
if (isset($multiRoots[$rootKey]) && $multiRoots[$rootKey] === true) {
|
||||
// Multi-item simulation per group
|
||||
[$items, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities]
|
||||
= $this->simulateGenericRootMulti(
|
||||
$rootKey,
|
||||
$val,
|
||||
$groupVals,
|
||||
$summaries,
|
||||
$genericCaches,
|
||||
$identityCandidates,
|
||||
$genericExistingIdentities,
|
||||
$genericSeenIdentities,
|
||||
$verbose,
|
||||
$targetToSource
|
||||
);
|
||||
// Add action labels and attach
|
||||
$rowEntities[$rootKey] = array_map(function ($ent) use ($translatedActions) {
|
||||
$ent['action_label'] = $translatedActions[$ent['action']] ?? $ent['action'];
|
||||
|
||||
return $ent;
|
||||
}, $items);
|
||||
} else {
|
||||
[$genericEntity, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities]
|
||||
= $this->simulateGenericRoot(
|
||||
$rootKey,
|
||||
$val,
|
||||
$summaries,
|
||||
$genericCaches,
|
||||
$reference,
|
||||
$identityCandidates,
|
||||
$genericExistingIdentities,
|
||||
$genericSeenIdentities,
|
||||
$verbose,
|
||||
$targetToSource,
|
||||
);
|
||||
$rowEntities[$rootKey] = $genericEntity + [
|
||||
'action_label' => $translatedActions[$genericEntity['action']] ?? $genericEntity['action'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Attach chain entities (client_case, person) if contract already existed
|
||||
|
|
@ -317,8 +406,22 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
|||
// If existing contract: upgrade generic email/phone/address entities (already simulated) to mark as chain if corresponding person attached
|
||||
if ($existingContract && isset($rowEntities['person']['id'])) {
|
||||
foreach (['email', 'phone', 'address'] as $gRoot) {
|
||||
if (isset($rowEntities[$gRoot]) && ! ($rowEntities[$gRoot]['existing_chain'] ?? false)) {
|
||||
$rowEntities[$gRoot]['existing_chain'] = true; // mark for UI toggle
|
||||
if (! isset($rowEntities[$gRoot])) {
|
||||
continue;
|
||||
}
|
||||
$valRef = $rowEntities[$gRoot];
|
||||
// If multi items array, set flag on each item
|
||||
if (is_array($valRef) && isset($valRef[0]) && is_array($valRef[0])) {
|
||||
foreach ($rowEntities[$gRoot] as &$it) {
|
||||
if (! ($it['existing_chain'] ?? false)) {
|
||||
$it['existing_chain'] = true;
|
||||
}
|
||||
}
|
||||
unset($it);
|
||||
} else {
|
||||
if (! ($rowEntities[$gRoot]['existing_chain'] ?? false)) {
|
||||
$rowEntities[$gRoot]['existing_chain'] = true; // mark for UI toggle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -352,7 +455,21 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
|||
if ($verbose) {
|
||||
foreach ($rowEntities as $eroot => &$ent) {
|
||||
$tf = $eroot.'.reference';
|
||||
if (isset($targetToSource[$tf])) {
|
||||
if (! isset($targetToSource[$tf])) {
|
||||
continue;
|
||||
}
|
||||
if (is_array($ent) && isset($ent[0]) && is_array($ent[0])) {
|
||||
foreach ($ent as &$item) {
|
||||
$item['sources'] = $item['sources'] ?? [];
|
||||
if (! isset($item['sources'][$tf])) {
|
||||
$item['sources'][$tf] = [
|
||||
'source_column' => $targetToSource[$tf],
|
||||
'value' => $val($tf),
|
||||
];
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
} else {
|
||||
$ent['sources'] = $ent['sources'] ?? [];
|
||||
if (! isset($ent['sources'][$tf])) {
|
||||
$ent['sources'][$tf] = [
|
||||
|
|
@ -404,7 +521,6 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
|||
foreach (['reference', 'identity_used', 'identity_candidates', 'full_name', 'first_name', 'last_name', 'address', 'country', 'nu', 'value'] as $k) {
|
||||
if (isset($ent[$k]) && $ent[$k]) {
|
||||
$hasData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Some entities (e.g. payment) do not have 'action'; treat them as non-empty if they have data or status
|
||||
|
|
@ -455,47 +571,88 @@ private function buildTargetLookup(Import $import): array
|
|||
$mappings = \DB::table('import_mappings')
|
||||
->where('import_id', $import->id)
|
||||
->orderBy('position')
|
||||
->get(['source_column', 'target_field']);
|
||||
->get(['source_column', 'target_field', 'options']);
|
||||
|
||||
$lookup = [];
|
||||
$grouped = [];
|
||||
foreach ($mappings as $m) {
|
||||
$target = trim((string) $m->target_field);
|
||||
$source = trim((string) $m->source_column);
|
||||
if ($target === '' || $source === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse grouping and field
|
||||
$group = null;
|
||||
$root = null;
|
||||
$rest = null;
|
||||
$restEffective = null;
|
||||
$opts = [];
|
||||
if (preg_match('/^([a-zA-Z0-9_]+)\\[([^\]]+)\\]\\.([a-zA-Z0-9_]+)$/', $target, $mm)) {
|
||||
$root = $mm[1];
|
||||
$group = (string) $mm[2];
|
||||
$rest = $mm[3];
|
||||
$restEffective = $rest;
|
||||
} else {
|
||||
if (str_contains($target, '.')) {
|
||||
[$root, $rest] = explode('.', $target, 2);
|
||||
}
|
||||
try {
|
||||
$opts = is_array($m->options) ? $m->options : (json_decode((string) $m->options, true) ?: []);
|
||||
} catch (\Throwable) {
|
||||
$opts = [];
|
||||
}
|
||||
if (is_array($opts) && array_key_exists('group', $opts) && $opts['group'] !== '' && $opts['group'] !== null) {
|
||||
$group = (string) $opts['group'];
|
||||
}
|
||||
$restEffective = $rest;
|
||||
// Alias meta with options.key => meta.{key}
|
||||
if ($rest === 'meta' && is_array($opts) && ! empty($opts['key'])) {
|
||||
$restEffective = 'meta'.'.'.(string) $opts['key'];
|
||||
}
|
||||
}
|
||||
|
||||
// Register flat lookups
|
||||
if (! isset($lookup[$target])) {
|
||||
$lookup[$target] = $source;
|
||||
}
|
||||
// If mapping uses *.client_ref, also register *.reference alias for simulation reference purposes
|
||||
if ($rest !== null) {
|
||||
$normRoot = $this->normalizeRoot((string) $root);
|
||||
$tfNorm = $normRoot.'.'.$restEffective;
|
||||
if (! isset($lookup[$tfNorm])) {
|
||||
$lookup[$tfNorm] = $source;
|
||||
}
|
||||
if (str_ends_with((string) $root, 's')) {
|
||||
$sing = substr((string) $root, 0, -1);
|
||||
if ($sing) {
|
||||
$tfSing = $sing.'.'.$restEffective;
|
||||
if (! isset($lookup[$tfSing])) {
|
||||
$lookup[$tfSing] = $source;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (str_ends_with($target, '.client_ref')) {
|
||||
$alias = substr($target, 0, -strlen('.client_ref')).'.reference';
|
||||
if (! isset($lookup[$alias])) {
|
||||
$lookup[$alias] = $source;
|
||||
}
|
||||
}
|
||||
if (str_contains($target, '.')) {
|
||||
[$root, $rest] = explode('.', $target, 2);
|
||||
$norm = $this->normalizeRoot($root);
|
||||
if ($norm !== $root) {
|
||||
$alt = $norm.'.'.$rest;
|
||||
if (! isset($lookup[$alt])) {
|
||||
$lookup[$alt] = $source;
|
||||
}
|
||||
|
||||
// Register grouped lookup per normalized root
|
||||
if ($group !== null && $rest !== null) {
|
||||
$normRoot = $this->normalizeRoot((string) $root);
|
||||
if (! isset($grouped[$normRoot])) {
|
||||
$grouped[$normRoot] = [];
|
||||
}
|
||||
if (str_ends_with($root, 's')) {
|
||||
$sing = substr($root, 0, -1);
|
||||
if ($sing && $sing !== $root) {
|
||||
$alt2 = $sing.'.'.$rest;
|
||||
if (! isset($lookup[$alt2])) {
|
||||
$lookup[$alt2] = $source;
|
||||
}
|
||||
}
|
||||
if (! isset($grouped[$normRoot][$group])) {
|
||||
$grouped[$normRoot][$group] = [];
|
||||
}
|
||||
$grouped[$normRoot][$group][$restEffective] = $source;
|
||||
}
|
||||
}
|
||||
|
||||
return $lookup;
|
||||
return [$lookup, $grouped];
|
||||
}
|
||||
|
||||
private function readFileRows(Import $import, bool $hasHeader, string $delimiter, array $columns, int $limit): array
|
||||
|
|
@ -1173,6 +1330,235 @@ private function loadSupportedEntityRoots(): array
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load which canonical roots support multiple items (grouped) from import_entities.supports_multiple.
|
||||
* Falls back to known defaults if table/column is unavailable.
|
||||
*/
|
||||
private function loadSupportsMultipleRoots(): array
|
||||
{
|
||||
$map = [];
|
||||
try {
|
||||
if (\Schema::hasTable('import_entities') && \Schema::hasColumn('import_entities', 'supports_multiple')) {
|
||||
$rows = \App\Models\ImportEntity::query()->get(['key', 'canonical_root', 'supports_multiple']);
|
||||
foreach ($rows as $r) {
|
||||
$root = $r->canonical_root ?: $r->key;
|
||||
if (! $root) {
|
||||
continue;
|
||||
}
|
||||
$norm = $this->normalizeRoot($root);
|
||||
$map[$norm] = (bool) $r->supports_multiple;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// ignore and fallback
|
||||
}
|
||||
if (empty($map)) {
|
||||
// Conservative defaults: only known contact types are multi
|
||||
$map = [
|
||||
'email' => true,
|
||||
'phone' => true,
|
||||
'address' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate generic root that supports multiple items (grouped). Returns array of entities per group.
|
||||
* Mirrors simulateGenericRoot per item while grouping by provided group values.
|
||||
*
|
||||
* @return array{0: array<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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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`");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ public function run(): void
|
|||
'description' => 'Basic person import: name, email, phone, address',
|
||||
'source_type' => 'csv',
|
||||
'default_record_type' => 'person',
|
||||
'sample_headers' => ['first_name','last_name','email','phone','address','city','postal_code','country'],
|
||||
'sample_headers' => ['first_name', 'last_name', 'email', 'phone', 'address', 'city', 'postal_code', 'country'],
|
||||
'is_active' => true,
|
||||
'meta' => [
|
||||
'delimiter' => ',',
|
||||
|
|
@ -47,5 +47,113 @@ public function run(): void
|
|||
'position' => $map['position'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Multi-contacts template demonstrating group support for emails/phones/addresses
|
||||
$multi = ImportTemplate::query()->firstOrCreate([
|
||||
'name' => 'Person CSV (multi contacts)',
|
||||
], [
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'description' => 'Person import with multiple emails/phones/addresses per row using group 1 and 2',
|
||||
'source_type' => 'csv',
|
||||
'default_record_type' => 'person',
|
||||
'sample_headers' => [
|
||||
'first_name', 'last_name', 'Email 1', 'Email 2', 'Phone 1', 'Phone 2',
|
||||
'Address 1', 'City 1', 'Postal 1', 'Country 1',
|
||||
'Address 2', 'City 2', 'Postal 2', 'Country 2',
|
||||
],
|
||||
'is_active' => true,
|
||||
'meta' => [
|
||||
'delimiter' => ',',
|
||||
'enclosure' => '"',
|
||||
'escape' => '\\',
|
||||
],
|
||||
]);
|
||||
|
||||
$multiMappings = [
|
||||
// Person identity
|
||||
['source_column' => 'first_name', 'target_field' => 'person.first_name', 'position' => 1],
|
||||
['source_column' => 'last_name', 'target_field' => 'person.last_name', 'position' => 2],
|
||||
|
||||
// Emails (groups 1, 2)
|
||||
['source_column' => 'Email 1', 'target_field' => 'email.value', 'position' => 3, 'options' => ['group' => '1']],
|
||||
['source_column' => 'Email 2', 'target_field' => 'email.value', 'position' => 4, 'options' => ['group' => '2']],
|
||||
|
||||
// Phones (groups 1, 2)
|
||||
['source_column' => 'Phone 1', 'target_field' => 'phone.nu', 'position' => 5, 'options' => ['group' => '1']],
|
||||
['source_column' => 'Phone 2', 'target_field' => 'phone.nu', 'position' => 6, 'options' => ['group' => '2']],
|
||||
|
||||
// Address group 1
|
||||
['source_column' => 'Address 1', 'target_field' => 'address.address', 'position' => 7, 'options' => ['group' => '1']],
|
||||
['source_column' => 'City 1', 'target_field' => 'address.city', 'position' => 8, 'options' => ['group' => '1']],
|
||||
['source_column' => 'Postal 1', 'target_field' => 'address.postal_code', 'position' => 9, 'options' => ['group' => '1']],
|
||||
['source_column' => 'Country 1', 'target_field' => 'address.country', 'position' => 10, 'options' => ['group' => '1']],
|
||||
|
||||
// Address group 2
|
||||
['source_column' => 'Address 2', 'target_field' => 'address.address', 'position' => 11, 'options' => ['group' => '2']],
|
||||
['source_column' => 'City 2', 'target_field' => 'address.city', 'position' => 12, 'options' => ['group' => '2']],
|
||||
['source_column' => 'Postal 2', 'target_field' => 'address.postal_code', 'position' => 13, 'options' => ['group' => '2']],
|
||||
['source_column' => 'Country 2', 'target_field' => 'address.country', 'position' => 14, 'options' => ['group' => '2']],
|
||||
];
|
||||
|
||||
foreach ($multiMappings as $map) {
|
||||
ImportTemplateMapping::firstOrCreate([
|
||||
'import_template_id' => $multi->id,
|
||||
'source_column' => $map['source_column'],
|
||||
], [
|
||||
'target_field' => $map['target_field'],
|
||||
'position' => $map['position'],
|
||||
'options' => $map['options'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Contracts with Meta (groups + keys) demo
|
||||
$contractsMeta = ImportTemplate::query()->firstOrCreate([
|
||||
'name' => 'Contracts CSV (meta groups demo)',
|
||||
], [
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'description' => 'Contracts import demonstrating contract.meta mappings using groups and keys.',
|
||||
'source_type' => 'csv',
|
||||
'default_record_type' => 'contract',
|
||||
'sample_headers' => [
|
||||
'Reference', 'Start', 'End',
|
||||
'Note', 'Category', 'Custom ID',
|
||||
'Legal Note', 'Legal ID',
|
||||
],
|
||||
'is_active' => true,
|
||||
'meta' => [
|
||||
'delimiter' => ',',
|
||||
'enclosure' => '"',
|
||||
'escape' => '\\',
|
||||
],
|
||||
]);
|
||||
|
||||
$contractsMetaMappings = [
|
||||
// Core contract fields
|
||||
['source_column' => 'Reference', 'target_field' => 'contract.reference', 'position' => 1],
|
||||
['source_column' => 'Start', 'target_field' => 'contract.start_date', 'position' => 2],
|
||||
['source_column' => 'End', 'target_field' => 'contract.end_date', 'position' => 3],
|
||||
|
||||
// Meta group 1 using explicit options.key
|
||||
['source_column' => 'Note', 'target_field' => 'contract.meta', 'position' => 4, 'options' => ['group' => '1', 'key' => 'note']],
|
||||
['source_column' => 'Category', 'target_field' => 'contract.meta', 'position' => 5, 'options' => ['group' => '1', 'key' => 'category']],
|
||||
|
||||
// Meta group 1 using bracket key syntax (no options.key needed)
|
||||
['source_column' => 'Custom ID', 'target_field' => 'contract.meta[custom_id]', 'position' => 6, 'options' => ['group' => '1']],
|
||||
|
||||
// Meta group 2 examples
|
||||
['source_column' => 'Legal Note', 'target_field' => 'contract.meta', 'position' => 7, 'options' => ['group' => '2', 'key' => 'note']],
|
||||
['source_column' => 'Legal ID', 'target_field' => 'contract.meta[legal_id]', 'position' => 8, 'options' => ['group' => '2']],
|
||||
];
|
||||
|
||||
foreach ($contractsMetaMappings as $map) {
|
||||
ImportTemplateMapping::firstOrCreate([
|
||||
'import_template_id' => $contractsMeta->id,
|
||||
'source_column' => $map['source_column'],
|
||||
], [
|
||||
'target_field' => $map['target_field'],
|
||||
'position' => $map['position'],
|
||||
'options' => $map['options'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
250
resources/js/Pages/Client/Contracts.vue
Normal file
250
resources/js/Pages/Client/Contracts.vue
Normal 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>
|
||||
|
|
@ -52,12 +52,27 @@ 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">
|
||||
<SectionTitle>
|
||||
<template #title>
|
||||
{{ client.person.full_name }}
|
||||
</template>
|
||||
</SectionTitle>
|
||||
<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>
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -99,23 +99,45 @@ 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++;
|
||||
switch (ent.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 (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++;
|
||||
break;
|
||||
case "update":
|
||||
stats[k].update++;
|
||||
break;
|
||||
case "missing_ref":
|
||||
stats[k].missing_ref++;
|
||||
break;
|
||||
case "invalid":
|
||||
stats[k].invalid++;
|
||||
break;
|
||||
}
|
||||
if (ent.duplicate) stats[k].duplicate++;
|
||||
if (ent.duplicate_db) stats[k].duplicate_db++;
|
||||
}
|
||||
if (ent.duplicate) stats[k].duplicate++;
|
||||
if (ent.duplicate_db) stats[k].duplicate_db++;
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
|
|
@ -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 (hideChain.value && ent.existing_chain) return false;
|
||||
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,36 +398,45 @@ function referenceOf(entityName, ent) {
|
|||
class="font-semibold uppercase tracking-wide text-gray-600 mb-1 flex items-center justify-between"
|
||||
>
|
||||
<span>{{ activeEntity }}</span>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].action_label"
|
||||
: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_label }}</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].existing_chain"
|
||||
class="ml-1 text-[9px] px-1 py-0.5 rounded bg-indigo-100 text-indigo-700"
|
||||
title="Iz obstoječe verige (contract → client_case → person)"
|
||||
>chain</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].inherited_reference"
|
||||
class="ml-1 text-[9px] px-1 py-0.5 rounded bg-amber-100 text-amber-700"
|
||||
title="Referenca podedovana"
|
||||
>inh</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].action === 'implicit'"
|
||||
class="ml-1 text-[9px] px-1 py-0.5 rounded bg-teal-100 text-teal-700"
|
||||
title="Implicitno"
|
||||
>impl</span
|
||||
>
|
||||
<template v-if="!Array.isArray(r.entities[activeEntity])">
|
||||
<span
|
||||
v-if="r.entities[activeEntity].action_label"
|
||||
: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_label }}</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].existing_chain"
|
||||
class="ml-1 text-[9px] px-1 py-0.5 rounded bg-indigo-100 text-indigo-700"
|
||||
title="Iz obstoječe verige (contract → client_case → person)"
|
||||
>chain</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].inherited_reference"
|
||||
class="ml-1 text-[9px] px-1 py-0.5 rounded bg-amber-100 text-amber-700"
|
||||
title="Referenca podedovana"
|
||||
>inh</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].action === 'implicit'"
|
||||
class="ml-1 text-[9px] px-1 py-0.5 rounded bg-teal-100 text-teal-700"
|
||||
title="Implicitno"
|
||||
>impl</span
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-if="activeEntity === 'account'">
|
||||
|
|
@ -510,10 +546,13 @@ function referenceOf(entityName, ent) {
|
|||
<div>
|
||||
Akcija:
|
||||
<span
|
||||
:class="[
|
||||
'font-medium inline-flex items-center gap-1',
|
||||
r.entities[activeEntity].action === 'reactivate' && 'text-purple-700'
|
||||
].filter(Boolean)"
|
||||
:class="
|
||||
[
|
||||
'font-medium inline-flex items-center gap-1',
|
||||
r.entities[activeEntity].action === 'reactivate' &&
|
||||
'text-purple-700',
|
||||
].filter(Boolean)
|
||||
"
|
||||
>{{
|
||||
r.entities[activeEntity].action_label ||
|
||||
r.entities[activeEntity].action
|
||||
|
|
@ -526,179 +565,319 @@ 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>
|
||||
<div class="flex flex-wrap gap-1 mb-1">
|
||||
<span
|
||||
v-if="r.entities[activeEntity].identity_used"
|
||||
class="px-1 py-0.5 rounded bg-indigo-50 text-indigo-700 text-[10px]"
|
||||
title="Uporabljena identiteta"
|
||||
>{{ r.entities[activeEntity].identity_used }}</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].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="r.entities[activeEntity].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>
|
||||
<template v-if="activeEntity === 'person'">
|
||||
<div class="grid grid-cols-1 gap-0.5">
|
||||
<!-- Multi-item rendering for grouped roots (email/phone/address) -->
|
||||
<template v-if="Array.isArray(r.entities[activeEntity])">
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-if="
|
||||
referenceOf(activeEntity, r.entities[activeEntity]) !== '—'
|
||||
"
|
||||
class="text-[10px] text-gray-600"
|
||||
v-for="(item, idx) in r.entities[activeEntity]"
|
||||
:key="idx"
|
||||
class="border rounded p-2 bg-white"
|
||||
>
|
||||
Ref:
|
||||
<span class="font-medium text-gray-800">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].full_name"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Ime:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].full_name
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
r.entities[activeEntity].first_name ||
|
||||
r.entities[activeEntity].last_name
|
||||
"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Ime:
|
||||
<span class="font-medium">{{
|
||||
[
|
||||
r.entities[activeEntity].first_name,
|
||||
r.entities[activeEntity].last_name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].birthday"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Rojstvo:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].birthday
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].description"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Opis:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].description
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].identity_candidates?.length"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Identitete:
|
||||
{{ r.entities[activeEntity].identity_candidates.join(", ") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeEntity === 'email'"
|
||||
><div class="text-[10px] text-gray-600">
|
||||
Email:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div></template
|
||||
>
|
||||
<template v-else-if="activeEntity === 'phone'"
|
||||
><div class="text-[10px] text-gray-600">
|
||||
Telefon:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</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, r.entities[activeEntity]) !== '—'
|
||||
"
|
||||
>
|
||||
Ref:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="r.entities[activeEntity].address">
|
||||
Naslov:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].address
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
r.entities[activeEntity].postal_code ||
|
||||
r.entities[activeEntity].country
|
||||
"
|
||||
>
|
||||
Lokacija:
|
||||
<span class="font-medium">{{
|
||||
[
|
||||
r.entities[activeEntity].postal_code,
|
||||
r.entities[activeEntity].country,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeEntity === 'client_case'">
|
||||
<div class="text-[10px] text-gray-600 space-y-0.5">
|
||||
<div
|
||||
v-if="
|
||||
referenceOf(activeEntity, r.entities[activeEntity]) !== '—'
|
||||
"
|
||||
>
|
||||
Ref:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="r.entities[activeEntity].title">
|
||||
Naslov:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].title
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="r.entities[activeEntity].status">
|
||||
Status:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].status
|
||||
}}</span>
|
||||
<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>
|
||||
<pre class="text-[10px] whitespace-pre-wrap">{{
|
||||
r.entities[activeEntity]
|
||||
}}</pre>
|
||||
<div class="flex flex-wrap gap-1 mb-1">
|
||||
<span
|
||||
v-if="r.entities[activeEntity].identity_used"
|
||||
class="px-1 py-0.5 rounded bg-indigo-50 text-indigo-700 text-[10px]"
|
||||
title="Uporabljena identiteta"
|
||||
>{{ r.entities[activeEntity].identity_used }}</span
|
||||
>
|
||||
<span
|
||||
v-if="r.entities[activeEntity].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="r.entities[activeEntity].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>
|
||||
<template v-if="activeEntity === 'person'">
|
||||
<div class="grid grid-cols-1 gap-0.5">
|
||||
<div
|
||||
v-if="
|
||||
referenceOf(activeEntity, r.entities[activeEntity]) !==
|
||||
'—'
|
||||
"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Ref:
|
||||
<span class="font-medium text-gray-800">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].full_name"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Ime:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].full_name
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
r.entities[activeEntity].first_name ||
|
||||
r.entities[activeEntity].last_name
|
||||
"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Ime:
|
||||
<span class="font-medium">{{
|
||||
[
|
||||
r.entities[activeEntity].first_name,
|
||||
r.entities[activeEntity].last_name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].birthday"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Rojstvo:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].birthday
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].description"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Opis:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].description
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="r.entities[activeEntity].identity_candidates?.length"
|
||||
class="text-[10px] text-gray-600"
|
||||
>
|
||||
Identitete:
|
||||
{{
|
||||
r.entities[activeEntity].identity_candidates.join(", ")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeEntity === 'email'"
|
||||
><div class="text-[10px] text-gray-600">
|
||||
Email:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div></template
|
||||
>
|
||||
<template v-else-if="activeEntity === 'phone'"
|
||||
><div class="text-[10px] text-gray-600">
|
||||
Telefon:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</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, r.entities[activeEntity]) !==
|
||||
'—'
|
||||
"
|
||||
>
|
||||
Ref:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="r.entities[activeEntity].address">
|
||||
Naslov:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].address
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
r.entities[activeEntity].postal_code ||
|
||||
r.entities[activeEntity].country
|
||||
"
|
||||
>
|
||||
Lokacija:
|
||||
<span class="font-medium">{{
|
||||
[
|
||||
r.entities[activeEntity].postal_code,
|
||||
r.entities[activeEntity].country,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeEntity === 'client_case'">
|
||||
<div class="text-[10px] text-gray-600 space-y-0.5">
|
||||
<div
|
||||
v-if="
|
||||
referenceOf(activeEntity, r.entities[activeEntity]) !==
|
||||
'—'
|
||||
"
|
||||
>
|
||||
Ref:
|
||||
<span class="font-medium">{{
|
||||
referenceOf(activeEntity, r.entities[activeEntity])
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="r.entities[activeEntity].title">
|
||||
Naslov:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].title
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="r.entities[activeEntity].status">
|
||||
Status:
|
||||
<span class="font-medium">{{
|
||||
r.entities[activeEntity].status
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<pre class="text-[10px] whitespace-pre-wrap">{{
|
||||
r.entities[activeEntity]
|
||||
}}</pre>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
79
tests/Unit/ImportProcessorMultiTest.php
Normal file
79
tests/Unit/ImportProcessorMultiTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user