Teren-app/resources/js/Pages/Imports/Templates/Edit.vue

1257 lines
48 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, 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,
reactivate: props.template.reactivate ?? false,
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",
group: "",
});
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;
}
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);
}
// 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 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 }),
{
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,
options: m.options || null,
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
>
<div class="flex items-center gap-2 ml-6">
<input
id="reactivate"
v-model="form.reactivate"
type="checkbox"
class="rounded"
/>
<label for="reactivate" class="text-sm font-medium text-gray-700"
>Reaktivacija</label
>
</div>
<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>
<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
@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',
group: bulkGlobal.group || '',
}).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';
bulkGlobal.group = '';
},
}
);
})()
"
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">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">
<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-6 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>
<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"
@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-6 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>
<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)"
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>
<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
@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',
group: b.group || '',
}).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',
options: b.group ? { group: b.group } : null,
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 -->