1113 lines
42 KiB
Vue
1113 lines
42 KiB
Vue
<script setup>
|
||
import AppLayout from "@/Layouts/AppLayout.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({
|
||
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,
|
||
sample_headers: props.template.sample_headers || [],
|
||
// Add meta with default delimiter support
|
||
meta: {
|
||
...(props.template.meta || {}),
|
||
delimiter: (props.template.meta && props.template.meta.delimiter) || "",
|
||
},
|
||
});
|
||
|
||
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)
|
||
|
||
// 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({});
|
||
|
||
// 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) {
|
||
m.target_field = `${st.entity}.${st.field}`;
|
||
} else {
|
||
m.target_field = null;
|
||
}
|
||
updateMapping(m);
|
||
}
|
||
|
||
// 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 only = props.template.meta?.entities || [];
|
||
const { data } = await axios.post("/api/import-entities/suggest", {
|
||
columns: cols,
|
||
only_entities: only,
|
||
});
|
||
suggestions.value = { ...suggestions.value, ...(data?.suggestions || {}) };
|
||
} catch (e) {
|
||
console.error("Failed to load suggestions", e);
|
||
}
|
||
}
|
||
|
||
// 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}`);
|
||
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 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;
|
||
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;
|
||
}
|
||
// Normalize empty delimiter: remove from meta to allow auto-detect
|
||
if (
|
||
payload.meta &&
|
||
typeof payload.meta.delimiter === "string" &&
|
||
payload.meta.delimiter.trim() === ""
|
||
) {
|
||
delete payload.meta.delimiter;
|
||
}
|
||
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;
|
||
},
|
||
});
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
);
|
||
|
||
// Ensure default contract match key when turning on payments mode on existing template
|
||
watch(
|
||
() => form.meta.payments_import,
|
||
(enabled) => {
|
||
if (enabled && !form.meta.contract_key_mode) {
|
||
form.meta.contract_key_mode = "reference";
|
||
}
|
||
}
|
||
);
|
||
</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>
|
||
<label class="block text-sm font-medium text-gray-700"
|
||
>Privzeti ločilni znak (CSV)</label
|
||
>
|
||
<select
|
||
v-model="form.meta.delimiter"
|
||
class="mt-1 block w-full border rounded p-2"
|
||
>
|
||
<option value="">(Auto-detect)</option>
|
||
<option value=",">Comma ,</option>
|
||
<option value=";">Semicolon ;</option>
|
||
<option value="\t">Tab \t</option>
|
||
<option value="|">Pipe |</option>
|
||
<option value=" ">Space ␠</option>
|
||
</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"
|
||
>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>
|
||
<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 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>
|
||
|
||
<!-- Payments import toggle and settings -->
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<div class="flex items-center gap-2">
|
||
<input
|
||
id="payments_import"
|
||
v-model="form.meta.payments_import"
|
||
type="checkbox"
|
||
class="rounded"
|
||
/>
|
||
<label for="payments_import" class="text-sm font-medium text-gray-700"
|
||
>Payments import</label
|
||
>
|
||
</div>
|
||
<p class="text-xs text-gray-500 mt-1">
|
||
When enabled, entities are locked to Contracts → Accounts → Payments.
|
||
</p>
|
||
</div>
|
||
<div v-if="form.meta.payments_import">
|
||
<label class="block text-sm font-medium text-gray-700"
|
||
>Contract match key</label
|
||
>
|
||
<select
|
||
v-model="form.meta.contract_key_mode"
|
||
class="mt-1 block w-full border rounded p-2"
|
||
>
|
||
<option value="reference">
|
||
Reference (use only contract.reference to locate records)
|
||
</option>
|
||
</select>
|
||
<p class="text-xs text-gray-500 mt-1">
|
||
Map your CSV column to contract.reference to resolve contracts for this
|
||
client.
|
||
</p>
|
||
</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 (ločeno z vejicami, podpičji ali po vrsticah)</label
|
||
>
|
||
<textarea
|
||
v-model="bulkGlobal.sources"
|
||
rows="3"
|
||
class="mt-1 w-full border rounded p-2"
|
||
placeholder="npr.: Pogodba sklic,Številka plačila,Datum,Znesek"
|
||
></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 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>
|
||
<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>
|
||
<option value="keyref">keyref (use as lookup key)</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 info when payments mode is on -->
|
||
<div
|
||
v-if="form.meta.payments_import"
|
||
class="p-3 bg-emerald-50 rounded border border-emerald-200"
|
||
>
|
||
<div class="text-sm text-emerald-900 mb-1">Entities are locked:</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<span
|
||
class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 px-3 py-1 text-xs font-medium"
|
||
>Contracts</span
|
||
>
|
||
<span
|
||
class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 px-3 py-1 text-xs font-medium"
|
||
>Accounts</span
|
||
>
|
||
<span
|
||
class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 px-3 py-1 text-xs font-medium"
|
||
>Payments</span
|
||
>
|
||
</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) => {
|
||
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"
|
||
/>
|
||
<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>
|
||
<option value="keyref">keyref (use as lookup key)</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>
|
||
<option value="keyref">keyref (use as lookup key)</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>
|
||
<option value="keyref">keyref (use as lookup key)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="mt-2">
|
||
<button
|
||
@click.prevent="
|
||
(() => {
|
||
const b = (bulkRows[entity] ||= {});
|
||
if (!b.sources || !b.sources.trim()) return;
|
||
const eKey = entity;
|
||
useForm({
|
||
sources: b.sources,
|
||
entity: eKey,
|
||
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>
|
||
<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>
|
||
</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 0.2s;
|
||
}
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
</style>
|
||
|
||
<!-- moved modal into main template to avoid multiple <template> blocks -->
|