Teren-app/resources/js/Pages/Imports/Templates/Edit.vue
Simon Pocrnjič 7227c888d4 Mager updated
2025-09-27 17:45:55 +02:00

496 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref, computed } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
const props = defineProps({
template: Object,
clients: Array,
});
const form = useForm({
name: props.template.name,
description: props.template.description,
source_type: props.template.source_type,
default_record_type: props.template.default_record_type || '',
is_active: props.template.is_active,
client_uuid: props.template.client_uuid || 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)
// Local state for adding a new mapping row per entity accordion
const newRows = ref({});
const bulkRows = ref({}); // per-entity textarea and options
const bulkGlobal = ref({ entity: '', sources: '', default_field: '', transform: '', apply_mode: 'both' });
const unassigned = computed(() => (props.template.mappings || []).filter(m => !m.target_field));
const unassignedSourceColumns = computed(() => {
const set = new Set();
for (const m of unassigned.value) {
if (m.source_column) set.add(m.source_column);
}
return Array.from(set).sort((a,b)=>a.localeCompare(b));
});
const unassignedState = ref({});
function saveUnassigned(m) {
const st = unassignedState.value[m.id] || {};
if (st.entity && st.field) {
m.target_field = `${st.entity}.${st.field}`;
} else {
m.target_field = null;
}
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' },
];
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' ],
};
function toggle(entity) {
const el = document.getElementById(`acc-${entity}`);
if (el) el.open = !el.open;
}
function addRow(entity) {
const row = newRows.value[entity];
if (!row || !row.source || !row.field) return;
const target_field = `${entity}.${row.field}`;
const payload = {
source_column: row.source,
target_field,
transform: row.transform || null,
apply_mode: row.apply_mode || 'both',
position: (props.template.mappings?.length || 0) + 1,
};
useForm(payload).post(route('importTemplates.mappings.add', { template: props.template.uuid }), {
preserveScroll: true,
onSuccess: () => { newRows.value[entity] = {}; },
});
}
function updateMapping(m) {
const payload = {
source_column: m.source_column,
target_field: m.target_field,
transform: m.transform,
apply_mode: m.apply_mode,
position: m.position,
};
useForm(payload).put(route('importTemplates.mappings.update', { template: props.template.uuid, mapping: m.id }), {
preserveScroll: true,
});
}
function deleteMapping(m) {
useForm({}).delete(route('importTemplates.mappings.delete', { template: props.template.uuid, mapping: m.id }), {
preserveScroll: true,
});
}
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 idx = entityMaps.findIndex(x => x.id === m.id);
if (idx < 0) return;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= entityMaps.length) return;
const a = entityMaps[idx];
const b = entityMaps[swapIdx];
// Build final ordered ids list using current order, swapping a/b positions
const byId = Object.fromEntries(all.map(x => [x.id, x]));
const ordered = all.map(x => x.id);
const ai = ordered.indexOf(a.id);
const bi = ordered.indexOf(b.id);
if (ai < 0 || bi < 0) return;
[ordered[ai], ordered[bi]] = [ordered[bi], ordered[ai]];
useForm({ order: ordered }).post(route('importTemplates.mappings.reorder', { template: props.template.uuid }), {
preserveScroll: true,
});
}
// Save basic
const save = () => {
const payload = { ...form.data() };
if (!canChangeClient.value) {
// drop client change when blocked
delete payload.client_uuid;
}
useForm(payload).put(route('importTemplates.update', { template: props.template.uuid }), { preserveScroll: true });
};
// Non-blocking confirm modal state for delete
const deleteConfirmOpen = ref(false);
const deleteForm = useForm({});
function openDeleteConfirm() { deleteConfirmOpen.value = true; }
function cancelDelete() { deleteConfirmOpen.value = false; }
function performDelete() {
deleteForm.delete(route('importTemplates.destroy', { template: props.template.uuid }), {
onFinish: () => { deleteConfirmOpen.value = false; },
});
}
</script>
<template>
<AppLayout :title="`Edit Template: ${props.template.name}`">
<template #header>
<div class="flex items-center justify-between">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uredi uvozno predlogo</h2>
<div class="flex items-center gap-2">
<Link :href="route('importTemplates.index')" class="px-3 py-1.5 border rounded text-sm">Nazaj</Link>
<button class="px-3 py-1.5 border rounded text-sm text-red-700 border-red-300 hover:bg-red-50" @click.prevent="openDeleteConfirm">Izbriši predlogo</button>
</div>
</div>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
<!-- Basic info -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Ime predloge</label>
<input v-model="form.name" type="text" class="mt-1 block w-full border rounded p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Vir</label>
<select v-model="form.source_type" class="mt-1 block w-full border rounded p-2">
<option value="csv">CSV</option>
<option value="xml">XML</option>
<option value="xls">XLS</option>
<option value="xlsx">XLSX</option>
<option value="json">JSON</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Privzeti tip zapisa</label>
<input v-model="form.default_record_type" type="text" class="mt-1 block w-full border rounded p-2" placeholder="npr.: account, person" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Naročnik (opcijsko)</label>
<Multiselect
v-model="form.client_uuid"
:options="props.clients || []"
:reduce="c => c.uuid"
track-by="uuid"
label="name"
placeholder="Global (brez naročnika)"
:searchable="true"
:allow-empty="true"
class="mt-1"
:disabled="!canChangeClient"
/>
<p v-if="!canChangeClient" class="text-xs text-amber-600 mt-1">Ni mogoče spremeniti naročnika, ker ta predloga že vsebuje preslikave.</p>
</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>
<button @click.prevent="save" class="ml-auto px-3 py-2 bg-indigo-600 text-white rounded">Shrani</button>
</div>
</div>
<!-- Sample headers viewer/editor -->
<div class="p-3 bg-gray-50 rounded border">
<div class="flex items-center justify-between mb-2">
<div class="text-sm font-medium text-gray-700">Vzorčni glavi stolpcev</div>
<button class="text-xs px-2 py-1 border rounded" @click.prevent="form.sample_headers = (form.sample_headers || []).concat([''])">Dodaj stolpec</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div v-for="(h, i) in (form.sample_headers = form.sample_headers ?? props.template.sample_headers ?? [])" :key="i" class="flex items-center gap-2">
<input v-model="form.sample_headers[i]" type="text" class="flex-1 border rounded p-2" placeholder="npr.: reference" />
<button class="px-2 py-1 border rounded" @click.prevent="form.sample_headers.splice(i,1)">✕</button>
</div>
</div>
</div>
<!-- Global Bulk Add Mappings -->
<div class="p-3 bg-indigo-50 rounded border border-indigo-200">
<div class="mb-2 text-sm font-medium text-indigo-900">Bulk dodajanje preslikav</div>
<div class="grid grid-cols-1 sm:grid-cols-6 gap-3 items-start">
<div class="sm:col-span-3">
<label class="block text-xs text-indigo-900">Source columns (CSV ali po vrsticah)</label>
<textarea v-model="bulkGlobal.sources" rows="3" class="mt-1 w-full border rounded p-2" placeholder="npr.: reference,first name,last name,amount"></textarea>
</div>
<div>
<label class="block text-xs text-indigo-900">Entity (opcijsko)</label>
<select v-model="bulkGlobal.entity" class="mt-1 w-full border rounded p-2">
<option value="">(brez pusti target prazno)</option>
<option v-for="opt in entityOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
</select>
</div>
<div>
<label class="block text-xs text-indigo-900">Field (opcijsko, za vse)</label>
<select v-model="bulkGlobal.default_field" class="mt-1 w-full border rounded p-2" :disabled="!bulkGlobal.entity">
<option value="">(auto from source)</option>
<option v-for="f in (fieldOptions[bulkGlobal.entity] || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
<div>
<label class="block text-xs text-indigo-900">Transform (za vse)</label>
<select v-model="bulkGlobal.transform" class="mt-1 w-full border rounded p-2">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
</div>
<div>
<label class="block text-xs text-indigo-900">Apply (za vse)</label>
<select v-model="bulkGlobal.apply_mode" class="mt-1 w-full border rounded p-2">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
</div>
</div>
<div class="mt-3">
<button
@click.prevent="(() => {
if (!bulkGlobal.sources || !bulkGlobal.sources.trim()) return;
useForm({
sources: bulkGlobal.sources,
entity: bulkGlobal.entity || null,
default_field: bulkGlobal.default_field || null,
transform: bulkGlobal.transform || null,
apply_mode: bulkGlobal.apply_mode || 'both',
}).post(route('importTemplates.mappings.bulk', { template: props.template.uuid }), {
preserveScroll: true,
onSuccess: () => { bulkGlobal.entity=''; bulkGlobal.sources=''; bulkGlobal.default_field=''; bulkGlobal.transform=''; bulkGlobal.apply_mode='both'; },
});
})()"
class="px-3 py-2 bg-indigo-600 text-white rounded"
>Dodaj več</button>
</div>
</div>
<!-- Unassigned mappings (no target_field) -->
<div v-if="unassigned.length" class="p-3 bg-amber-50 rounded border border-amber-200">
<div class="mb-2 text-sm font-medium text-amber-900">Nedodeljene preslikave ({{ unassigned.length }})</div>
<div class="space-y-2">
<div v-for="m in unassigned" :key="m.id" class="p-2 bg-white/60 border rounded">
<div class="grid grid-cols-1 sm:grid-cols-6 gap-2 items-center">
<div class="text-sm">
<div class="text-gray-500 text-xs">Source</div>
<div class="font-medium">{{ m.source_column }}</div>
</div>
<div>
<label class="block text-xs text-gray-600">Entity</label>
<select v-model="(unassignedState[m.id] ||= {}).entity" class="mt-1 w-full border rounded p-2">
<option value="">(izberi)</option>
<option v-for="opt in entityOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Field</label>
<select v-model="(unassignedState[m.id] ||= {}).field" class="mt-1 w-full border rounded p-2" :disabled="!(unassignedState[m.id]||{}).entity">
<option value="">(izberi)</option>
<option v-for="f in (fieldOptions[(unassignedState[m.id]||{}).entity] || [])" :key="f" :value="f">{{ f }}</option>
</select>
</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">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Apply</label>
<select v-model="m.apply_mode" class="mt-1 w-full border rounded p-2">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
</div>
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm" @click.prevent="saveUnassigned(m)">Shrani</button>
<button class="px-3 py-1.5 bg-red-600 text-white rounded text-sm" @click.prevent="deleteMapping(m)">Izbriši</button>
</div>
</div>
</div>
</div>
</div>
<!-- Entities accordion -->
<div class="divide-y">
<details v-for="entity in entities" :key="entity" :id="`acc-${entity}`" class="py-3">
<summary class="cursor-pointer select-none flex items-center justify-between">
<span class="font-medium">{{ entityOptions.find(e=>e.key===entity)?.label || entity }}</span>
<span class="text-xs text-gray-500">Klikni za razširitev</span>
</summary>
<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 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" />
<select v-model="m.transform" class="border rounded p-2 text-sm">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
<select v-model="m.apply_mode" class="border rounded p-2 text-sm">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
<div class="flex items-center gap-2">
<button class="px-2 py-1 text-xs border rounded" @click.prevent="reorder(entity, 'up', m)">▲</button>
<button class="px-2 py-1 text-xs border rounded" @click.prevent="reorder(entity, 'down', m)">▼</button>
</div>
</div>
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm" @click.prevent="updateMapping(m)">Shrani</button>
<button class="px-3 py-1.5 bg-red-600 text-white rounded text-sm" @click.prevent="deleteMapping(m)">Izbriši</button>
</div>
</div>
</div>
<div v-else class="text-sm text-gray-500">Ni definiranih preslikav za to entiteto.</div>
<!-- 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>
<label class="block text-xs text-gray-600">Source column (ne-dodeljene)</label>
<select v-model="(newRows[entity] ||= {}).source" class="mt-1 w-full border rounded p-2">
<option value="" disabled>(izberi)</option>
<option v-for="s in unassignedSourceColumns" :key="s" :value="s">{{ s }}</option>
</select>
<p v-if="!unassignedSourceColumns.length" class="text-xs text-gray-500 mt-1">Ni nedodeljenih virov. Uporabi Bulk ali najprej dodaj vire.</p>
</div>
<div>
<label class="block text-xs text-gray-600">Field</label>
<select v-model="(newRows[entity] ||= {}).field" class="mt-1 w-full border rounded p-2">
<option v-for="f in (fieldOptions[entity] || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Transform</label>
<select v-model="(newRows[entity] ||= {}).transform" class="mt-1 w-full border rounded p-2">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Apply</label>
<select v-model="(newRows[entity] ||= {}).apply_mode" class="mt-1 w-full border rounded p-2">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
</div>
<div class="sm:col-span-1">
<button @click.prevent="addRow(entity)" class="w-full sm:w-auto px-3 py-2 bg-emerald-600 text-white rounded">Dodaj preslikavo</button>
</div>
</div>
</div>
<!-- Bulk add mapping rows -->
<div class="p-3 bg-gray-50 rounded border">
<div class="mb-2 text-xs text-gray-600">Dodaj več stolpcev naenkrat (ločeno z vejicami ali novimi vrsticami). Če polje ne izbereš, bo target nastavljen na entity + ime stolpca.</div>
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 items-start">
<div class="sm:col-span-2">
<label class="block text-xs text-gray-600">Source columns (CSV ali po vrsticah)</label>
<textarea v-model="(bulkRows[entity] ||= {}).sources" rows="3" class="mt-1 w-full border rounded p-2" placeholder="npr.: reference,first name,last name"></textarea>
</div>
<div>
<label class="block text-xs text-gray-600">Field (opcijsko, za vse)</label>
<select v-model="(bulkRows[entity] ||= {}).default_field" class="mt-1 w-full border rounded p-2">
<option value="">(auto from source)</option>
<option v-for="f in (fieldOptions[entity] || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Transform (za vse)</label>
<select v-model="(bulkRows[entity] ||= {}).transform" class="mt-1 w-full border rounded p-2">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Apply (za vse)</label>
<select v-model="(bulkRows[entity] ||= {}).apply_mode" class="mt-1 w-full border rounded p-2">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
</div>
</div>
<div class="mt-2">
<button
@click.prevent="(() => {
const b = bulkRows[entity] ||= {};
if (!b.sources || !b.sources.trim()) return;
useForm({
sources: b.sources,
entity,
default_field: b.default_field || null,
transform: b.transform || null,
apply_mode: b.apply_mode || 'both',
}).post(route('importTemplates.mappings.bulk', { template: props.template.uuid }), {
preserveScroll: true,
onSuccess: () => { bulkRows[entity] = {}; },
});
})()"
class="px-3 py-2 bg-indigo-600 text-white rounded"
>Dodaj več</button>
</div>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="deleteConfirmOpen" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancelDelete"></div>
<div class="relative bg-white rounded shadow-lg w-96 max-w-[90%] p-5">
<div class="text-lg font-semibold mb-2">Izbrišem predlogo?</div>
<p class="text-sm text-gray-600 mb-4">Tega dejanja ni mogoče razveljaviti. Vse preslikave te predloge bodo izbrisane.</p>
<div class="flex items-center justify-end gap-2">
<button class="px-3 py-1.5 border rounded" @click.prevent="cancelDelete" :disabled="deleteForm.processing">Prekliči</button>
<button class="px-3 py-1.5 rounded text-white bg-red-600 disabled:opacity-60" @click.prevent="performDelete" :disabled="deleteForm.processing">
<span v-if="deleteForm.processing">Brisanje</span>
<span v-else>Izbriši</span>
</button>
</div>
</div>
</div>
</AppLayout>
</template>
<style>
.fade-enter-active, .fade-leave-active { transition: opacity .2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
<!-- moved modal into main template to avoid multiple <template> blocks -->