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