changes 0230092025

This commit is contained in:
Simon Pocrnjič
2025-09-30 00:06:47 +02:00
parent 1fddf959f0
commit a2bb75fdcc
31 changed files with 2729 additions and 628 deletions
+5 -1
View File
@@ -171,10 +171,14 @@ const submitAttachSegment = () => {
<div
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-red-400"
>
<div class="mx-auto max-w-4x1 p-3">
<div class="mx-auto max-w-4x1 p-3 flex items-center justify-between">
<SectionTitle>
<template #title> Primer - oseba </template>
</SectionTitle>
<div v-if="client_case && client_case.client_ref" class="text-xs text-gray-600">
<span class="mr-1">Ref:</span>
<span class="inline-block px-2 py-0.5 rounded border bg-gray-50 font-mono text-gray-700">{{ client_case.client_ref }}</span>
</div>
</div>
</div>
</div>
+73 -80
View File
@@ -1,6 +1,6 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref, watch, computed } from 'vue';
import { ref, watch, computed, onMounted } from 'vue';
import { useForm, router } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
import axios from 'axios';
@@ -23,6 +23,53 @@ const selectedMappingsCount = computed(() => mappingRows.value.filter(r => !r.sk
const mappingError = ref('');
const savingMappings = ref(false);
// Dynamic entity definitions and suggestions from API
const entityDefs = ref([]);
const entityOptions = computed(() => entityDefs.value.map(e => ({ value: e.key, label: e.label || e.key })));
const fieldOptionsByEntity = computed(() => Object.fromEntries(entityDefs.value.map(e => [e.key, (e.fields || []).map(f => ({ value: f, label: f }))])));
const canonicalRootByKey = computed(() => Object.fromEntries(entityDefs.value.map(e => [e.key, e.canonical_root || e.key])));
const keyByCanonicalRoot = computed(() => {
const m = {};
for (const e of entityDefs.value) {
if (e.canonical_root) {
m[e.canonical_root] = e.key;
}
}
return m;
});
const suggestions = ref({});
async function loadEntityDefs() {
try {
const { data } = await axios.get('/api/import-entities');
entityDefs.value = data?.entities || [];
} catch (e) {
console.error('Failed to load import entity definitions', e);
}
}
async function refreshSuggestions(columns) {
const cols = Array.isArray(columns) ? columns : (detected.value.columns || []);
if (!cols || cols.length === 0) { return; }
try {
const { data } = await axios.post('/api/import-entities/suggest', { columns: cols });
suggestions.value = { ...suggestions.value, ...(data?.suggestions || {}) };
} catch (e) {
console.error('Failed to load suggestions', e);
}
}
function applySuggestionToRow(row) {
const s = suggestions.value[row.source_column];
if (!s) return false;
if (!fieldOptionsByEntity.value[s.entity]) return false;
row.entity = s.entity;
row.field = s.field;
// default transform on if missing
if (!row.transform) { row.transform = 'trim'; }
if (!row.apply_mode) { row.apply_mode = 'both'; }
row.skip = false;
return true;
}
const form = useForm({
client_uuid: null,
import_template_id: null,
@@ -120,6 +167,7 @@ async function fetchColumns() {
position: idx,
}));
}
await refreshSuggestions(detected.value.columns);
// If there are mappings already (template applied or saved), load them to auto-assign
await loadImportMappings();
}
@@ -203,9 +251,9 @@ async function loadImportMappings() {
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
const m = bySource.get(r.source_column);
if (!m) return r;
// Parse target_field like 'person.first_name' into UI entity/field
const [record, field] = String(m.target_field || '').split('.', 2);
const entity = recordToEntityKey(record);
// Parse target_field like 'person.first_name' into UI entity/field
const [record, field] = String(m.target_field || '').split('.', 2);
const entity = keyByCanonicalRoot.value[record] || record;
return {
...r,
entity,
@@ -244,61 +292,7 @@ async function processImport() {
}
}
const entityOptions = [
{ value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Address' },
{ value: 'person_phones', label: 'Person Phone' },
{ value: 'emails', label: 'Email' },
{ value: 'accounts', label: 'Account' },
{ value: 'contracts', label: 'Contract' },
];
const fieldOptionsByEntity = {
person: [
{ value: 'first_name', label: 'First name' },
{ value: 'last_name', label: 'Last name' },
{ value: 'full_name', label: 'Full name' },
{ value: 'tax_number', label: 'Tax number' },
{ value: 'social_security_number', label: 'SSN' },
{ value: 'birthday', label: 'Birthday' },
{ value: 'gender', label: 'Gender' },
{ value: 'description', label: 'Description' },
],
person_addresses: [
{ value: 'address', label: 'Address' },
{ value: 'country', label: 'Country' },
{ value: 'type_id', label: 'Address Type Id' },
{ value: 'description', label: 'Description' },
],
person_phones: [
{ value: 'nu', label: 'Phone number' },
{ value: 'country_code', label: 'Country code' },
{ value: 'type_id', label: 'Phone Type Id' },
{ value: 'description', label: 'Description' },
],
emails: [
{ value: 'value', label: 'Email address' },
{ value: 'label', label: 'Label' },
{ value: 'is_primary', label: 'Is primary' },
],
accounts: [
{ value: 'reference', label: 'Reference' },
{ value: 'description', label: 'Description' },
{ value: 'contract_id', label: 'Contract Id' },
{ value: 'contract_reference', label: 'Contract Reference' },
{ value: 'type_id', label: 'Account Type Id' },
{ value: 'active', label: 'Active' },
{ value: 'balance_amount', label: 'Balance Amount' },
],
contracts: [
{ value: 'reference', label: 'Reference' },
{ value: 'start_date', label: 'Start Date' },
{ value: 'end_date', label: 'End Date' },
{ value: 'description', label: 'Description' },
{ value: 'client_case_id', label: 'Client Case Id' },
{ value: 'type_id', label: 'Contract Type Id' },
],
};
// entity options and fields are dynamic from API
async function saveMappings() {
if (!importId.value) return;
@@ -307,7 +301,7 @@ async function saveMappings() {
.filter(r => !r.skip && r.entity && r.field)
.map(r => ({
source_column: r.source_column,
target_field: `${entityKeyToRecord(r.entity)}.${r.field}`,
target_field: `${(canonicalRootByKey.value[r.entity] || r.entity)}.${r.field}`,
transform: r.transform || null,
apply_mode: r.apply_mode || 'both',
options: null,
@@ -350,24 +344,9 @@ watch(mappingRows, () => {
mappingError.value = '';
}, { deep: true });
function entityKeyToRecord(key) {
// Map UI entities to record_type nouns used by processor
if (key === 'person_addresses') return 'address';
if (key === 'person_phones') return 'phone';
if (key === 'emails') return 'email';
if (key === 'accounts') return 'account';
if (key === 'contracts') return 'contract';
return 'person';
}
function recordToEntityKey(record) {
if (record === 'address') return 'person_addresses';
if (record === 'phone') return 'person_phones';
if (record === 'email') return 'emails';
if (record === 'account') return 'accounts';
if (record === 'contract') return 'contracts';
return 'person';
}
onMounted(async () => {
await loadEntityDefs();
});
</script>
@@ -472,7 +451,13 @@ function recordToEntityKey(record) {
</div>
<div v-if="detected.columns.length" class="pt-4">
<h3 class="font-semibold mb-2">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})</h3>
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})</h3>
<button
class="px-3 py-1.5 border rounded text-sm"
@click.prevent="(async () => { await refreshSuggestions(detected.columns); mappingRows.forEach(r => applySuggestionToRow(r)); })()"
>Auto map suggestions</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full border bg-white">
<thead>
@@ -487,7 +472,15 @@ function recordToEntityKey(record) {
</thead>
<tbody>
<tr v-for="(row, idx) in mappingRows" :key="idx" class="border-t">
<td class="p-2 border text-sm">{{ row.source_column }}</td>
<td class="p-2 border text-sm">
<div>{{ row.source_column }}</div>
<div class="text-xs mt-1" v-if="suggestions[row.source_column]">
<span class="text-gray-500">Suggest:</span>
<button class="ml-1 underline text-indigo-700 hover:text-indigo-900" @click.prevent="applySuggestionToRow(row)">
{{ suggestions[row.source_column].entity }}.{{ suggestions[row.source_column].field }}
</button>
</div>
</td>
<td class="p-2 border">
<select v-model="row.entity" class="border rounded p-1 w-full">
<option value=""></option>
File diff suppressed because it is too large Load Diff
@@ -3,9 +3,13 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
import { computed, watch } from 'vue';
const props = defineProps({
clients: Array,
segments: Array,
decisions: Array,
actions: Array,
});
const form = useForm({
@@ -16,6 +20,22 @@ const form = useForm({
is_active: true,
client_uuid: null,
entities: [],
meta: {
segment_id: null,
decision_id: null,
action_id: null,
delimiter: '',
},
});
const decisionsForSelectedAction = computed(() => {
const act = (props.actions || []).find(a => a.id === form.meta.action_id);
return act?.decisions || [];
});
watch(() => form.meta.action_id, () => {
// Clear decision when action changes to enforce valid pair
form.meta.decision_id = null;
});
function submit() {
@@ -75,6 +95,32 @@ function submit() {
<p class="text-xs text-gray-500 mt-1">Choose which tables this template targets. You can still define per-column mappings later.</p>
</div>
</div>
<!-- Defaults: Segment / Decision / Action -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Default Segment</label>
<select v-model="form.meta.segment_id" class="mt-1 block w-full border rounded p-2">
<option :value="null">(none)</option>
<option v-for="s in (props.segments || [])" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Default Decision</label>
<select v-model="form.meta.decision_id" class="mt-1 block w-full border rounded p-2" :disabled="!form.meta.action_id">
<option :value="null">(none)</option>
<option v-for="d in decisionsForSelectedAction" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
<p v-if="!form.meta.action_id" class="text-xs text-gray-500 mt-1">Select an Action to see its Decisions.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Default Action (for Activity)</label>
<select v-model="form.meta.action_id" class="mt-1 block w-full border rounded p-2">
<option :value="null">(none)</option>
<option v-for="a in (props.actions || [])" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<input v-model="form.name" type="text" class="mt-1 block w-full border rounded p-2" />
+138 -22
View File
@@ -1,12 +1,17 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref, computed } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
import axios from 'axios';
import { computed as vComputed, watch as vWatch } from 'vue';
const props = defineProps({
template: Object,
clients: Array,
segments: Array,
decisions: Array,
actions: Array,
});
const form = useForm({
@@ -24,6 +29,15 @@ const form = useForm({
},
});
const decisionsForSelectedAction = vComputed(() => {
const act = (props.actions || []).find(a => a.id === form.meta.action_id);
return act?.decisions || [];
});
vWatch(() => form.meta.action_id, () => {
form.meta.decision_id = null;
});
const entities = computed(() => (props.template.meta?.entities || []));
const hasMappings = computed(() => (props.template.mappings?.length || 0) > 0);
const canChangeClient = computed(() => !hasMappings.value); // guard reassignment when mappings exist (optional rule)
@@ -42,6 +56,31 @@ const unassignedSourceColumns = computed(() => {
});
const unassignedState = ref({});
// Dynamic Import Entity definitions and field options from API
const entityDefs = ref([]);
const entityOptions = computed(() => entityDefs.value.map(e => ({ key: e.key, label: e.label || e.key })));
const fieldOptions = computed(() => Object.fromEntries(entityDefs.value.map(e => [e.key, e.fields || []])));
const ENTITY_ALIASES = computed(() => {
const map = {};
for (const e of entityDefs.value) {
const aliases = Array.isArray(e.aliases) ? [...e.aliases] : [];
if (!aliases.includes(e.key)) {
aliases.push(e.key);
}
map[e.key] = aliases;
}
return map;
});
async function loadEntityDefs() {
try {
const { data } = await axios.get('/api/import-entities');
entityDefs.value = data?.entities || [];
} catch (e) {
console.error('Failed to load import entity definitions', e);
}
}
function saveUnassigned(m) {
const st = unassignedState.value[m.id] || {};
if (st.entity && st.field) {
@@ -52,25 +91,24 @@ function saveUnassigned(m) {
updateMapping(m);
}
const entityOptions = [
{ key: 'person', label: 'Person' },
{ key: 'person_addresses', label: 'Person Addresses' },
{ key: 'person_phones', label: 'Person Phones' },
{ key: 'emails', label: 'Emails' },
{ key: 'accounts', label: 'Accounts' },
{ key: 'contracts', label: 'Contracts' },
];
// Suggestions powered by backend API
const suggestions = ref({}); // { [sourceColumn]: { entity, field } }
async function refreshSuggestions(columns) {
const cols = Array.isArray(columns) ? columns : unassignedSourceColumns.value;
if (!cols || cols.length === 0) { return; }
try {
const { data } = await axios.post('/api/import-entities/suggest', { columns: cols });
suggestions.value = { ...suggestions.value, ...(data?.suggestions || {}) };
} catch (e) {
console.error('Failed to load suggestions', e);
}
}
const fieldOptions = {
person: [
'first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'
],
person_addresses: [ 'address', 'country', 'type_id', 'description' ],
person_phones: [ 'nu', 'country_code', 'type_id', 'description' ],
emails: [ 'email', 'is_primary' ],
accounts: [ 'reference', 'balance_amount', 'contract_id', 'contract_reference' ],
contracts: [ 'reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id' ],
};
// entityOptions and fieldOptions now come from API (see computed above)
// ENTITY_ALIASES computed above from API definitions
// UI_PREFERRED no longer needed; UI keys are already the desired keys
function toggle(entity) {
const el = document.getElementById(`acc-${entity}`);
@@ -116,7 +154,11 @@ function deleteMapping(m) {
function reorder(entity, direction, m) {
// Build new order across all mappings, swapping positions for this entity scope
const all = [...props.template.mappings];
const entityMaps = all.filter(x => x.target_field?.startsWith(entity + '.'));
const aliases = (ENTITY_ALIASES.value[entity] || [entity]).map(a => a + '.');
const entityMaps = all.filter(x => {
const tf = x.target_field || '';
return aliases.some(prefix => tf.startsWith(prefix));
});
const idx = entityMaps.findIndex(x => x.id === m.id);
if (idx < 0) return;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
@@ -158,6 +200,18 @@ function performDelete() {
onFinish: () => { deleteConfirmOpen.value = false; },
});
}
// Load entity definitions and initial suggestions
onMounted(async () => {
await loadEntityDefs();
await refreshSuggestions();
});
// Refresh suggestions when unassigned list changes
watch(() => unassignedSourceColumns.value.join('|'), async () => {
await refreshSuggestions();
});
</script>
<template>
@@ -223,6 +277,28 @@ function performDelete() {
</select>
<p class="text-xs text-gray-500 mt-1">Pusti prazno za samodejno zaznavo. Uporabi, ko zaznavanje ne deluje pravilno.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Privzeti Segment</label>
<select v-model="form.meta.segment_id" class="mt-1 block w-full border rounded p-2">
<option :value="null">(brez)</option>
<option v-for="s in (props.segments || [])" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Privzeta Odločitev</label>
<select v-model="form.meta.decision_id" class="mt-1 block w-full border rounded p-2" :disabled="!form.meta.action_id">
<option :value="null">(brez)</option>
<option v-for="d in decisionsForSelectedAction" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
<p v-if="!form.meta.action_id" class="text-xs text-gray-500 mt-1">Najprej izberi dejanje, nato odločitev.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Privzeto Dejanja (Activity)</label>
<select v-model="form.meta.action_id" class="mt-1 block w-full border rounded p-2">
<option :value="null">(brez)</option>
<option v-for="a in (props.actions || [])" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</div>
<div class="flex items-center gap-2">
<input id="is_active" v-model="form.is_active" type="checkbox" class="rounded" />
<label for="is_active" class="text-sm font-medium text-gray-700">Aktivna</label>
@@ -313,6 +389,13 @@ function performDelete() {
<div class="text-sm">
<div class="text-gray-500 text-xs">Source</div>
<div class="font-medium">{{ m.source_column }}</div>
<div class="mt-1 text-xs" v-if="suggestions[m.source_column]">
<span class="text-gray-500">Predlog:</span>
<button
class="ml-1 underline text-indigo-700 hover:text-indigo-900"
@click.prevent="(() => { const s = suggestions[m.source_column]; if (!s) return; (unassignedState[m.id] ||= {}).entity = s.entity; (unassignedState[m.id] ||= {}).field = s.field; saveUnassigned(m); })()"
>{{ suggestions[m.source_column].entity }}.{{ suggestions[m.source_column].field }}</button>
</div>
</div>
<div>
<label class="block text-xs text-gray-600">Entity</label>
@@ -364,7 +447,10 @@ function performDelete() {
<div class="mt-4 space-y-4">
<!-- Existing mappings for this entity -->
<div v-if="props.template.mappings && props.template.mappings.length" class="space-y-2">
<div v-for="m in props.template.mappings.filter(m=>m.target_field?.startsWith(entity + '.'))" :key="m.id" class="flex items-center justify-between p-2 border rounded gap-3">
<div v-for="m in props.template.mappings.filter(m => {
const aliases = ENTITY_ALIASES[entity] || [entity];
return aliases.some(a => m.target_field?.startsWith(a + '.'));
})" :key="m.id" 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">
<input v-model="m.source_column" class="border rounded p-2 text-sm" />
<input v-model="m.target_field" class="border rounded p-2 text-sm" />
@@ -470,9 +556,10 @@ function performDelete() {
@click.prevent="(() => {
const b = bulkRows[entity] ||= {};
if (!b.sources || !b.sources.trim()) return;
const eKey = entity;
useForm({
sources: b.sources,
entity,
entity: eKey,
default_field: b.default_field || null,
transform: b.transform || null,
apply_mode: b.apply_mode || 'both',
@@ -483,6 +570,35 @@ function performDelete() {
})()"
class="px-3 py-2 bg-indigo-600 text-white rounded"
>Dodaj več</button>
<button
class="ml-2 px-3 py-2 bg-emerald-600 text-white rounded"
@click.prevent="(async () => {
const b = bulkRows[entity] ||= {};
if (!b.sources || !b.sources.trim()) return;
const list = b.sources.split(/[\n,]+/).map(s=>s.trim()).filter(Boolean);
try {
const { data } = await axios.post('/api/import-entities/suggest', { columns: list });
const sugg = data?.suggestions || {};
for (const src of list) {
const s = sugg[src];
if (!s) continue;
const aliases = ENTITY_ALIASES.value[entity] || [entity];
if (!aliases.includes(s.entity)) continue; // only apply for this entity
const payload = {
source_column: src,
target_field: `${s.entity}.${s.field}`,
transform: b.transform || null,
apply_mode: b.apply_mode || 'both',
position: (props.template.mappings?.length || 0) + 1,
};
useForm(payload).post(route('importTemplates.mappings.add', { template: props.template.uuid }), { preserveScroll: true });
}
} catch (e) {
console.error('Failed to auto-add suggestions', e);
}
bulkRows[entity] = {};
})()"
>Auto iz predlog</button>
</div>
</div>
</div>
+1 -5
View File
@@ -46,11 +46,7 @@ defineProps({
<LogoutOtherBrowserSessionsForm :sessions="sessions" class="mt-10 sm:mt-0" />
<template v-if="$page.props.jetstream.hasAccountDeletionFeatures">
<SectionBorder />
<DeleteUserForm class="mt-10 sm:mt-0" />
</template>
</div>
</div>
</AppLayout>