Mass changes
This commit is contained in:
@@ -1,647 +1,268 @@
|
||||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { ref, watch, computed, onMounted } from "vue";
|
||||
import { useForm, router } from "@inertiajs/vue3";
|
||||
import { ref, computed } from "vue";
|
||||
import Multiselect from "vue-multiselect";
|
||||
import axios from "axios";
|
||||
|
||||
// Props: provided by controller (clients + templates collections)
|
||||
const props = defineProps({
|
||||
templates: Array,
|
||||
clients: Array,
|
||||
});
|
||||
|
||||
const hasHeader = ref(true);
|
||||
const detected = ref({ columns: [], delimiter: ",", has_header: true });
|
||||
const importId = ref(null);
|
||||
const templateApplied = ref(false);
|
||||
const processing = ref(false);
|
||||
const processResult = ref(null);
|
||||
const mappingRows = ref([]);
|
||||
const mappingSaved = ref(false);
|
||||
const mappingSavedCount = ref(0);
|
||||
const selectedMappingsCount = computed(
|
||||
() => mappingRows.value.filter((r) => !r.skip && r.entity && r.field).length
|
||||
);
|
||||
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 {
|
||||
// When a template is chosen and provides meta.entities, limit suggestions to those entities
|
||||
const only = (selectedTemplate.value?.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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Basic create form (rest of workflow handled on the Continue page)
|
||||
const form = useForm({
|
||||
client_uuid: null,
|
||||
import_template_id: null,
|
||||
source_type: null,
|
||||
sheet_name: null,
|
||||
has_header: true,
|
||||
file: null,
|
||||
});
|
||||
|
||||
// Bridge Multiselect (expects option objects) to our form (stores client_uuid as string)
|
||||
// Multiselect bridge: client
|
||||
const selectedClientOption = computed({
|
||||
get() {
|
||||
const cuuid = form.client_uuid;
|
||||
if (!cuuid) return null;
|
||||
return (props.clients || []).find((c) => c.uuid === cuuid) || null;
|
||||
if (!form.client_uuid) return null;
|
||||
return (props.clients || []).find((c) => c.uuid === form.client_uuid) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.client_uuid = val ? val.uuid : null;
|
||||
},
|
||||
});
|
||||
|
||||
// Bridge Template Multiselect to store only template id (number) in form
|
||||
// Multiselect bridge: template
|
||||
const selectedTemplateOption = computed({
|
||||
get() {
|
||||
const tid = form.import_template_id;
|
||||
if (tid == null) return null;
|
||||
return (props.templates || []).find((t) => t.id === tid) || null;
|
||||
if (form.import_template_id == null) return null;
|
||||
return (props.templates || []).find((t) => t.id === form.import_template_id) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.import_template_id = val ? val.id : null;
|
||||
},
|
||||
});
|
||||
|
||||
// Helper: selected client's numeric id (fallback)
|
||||
const selectedClientId = computed(() => {
|
||||
const cuuid = form.client_uuid;
|
||||
if (!cuuid) return null;
|
||||
const c = (props.clients || []).find((x) => x.uuid === cuuid);
|
||||
return c ? c.id : null;
|
||||
});
|
||||
|
||||
// Show only global templates when no client is selected.
|
||||
// When a client is selected, show only that client's templates (match by client_uuid).
|
||||
// Filter templates: show globals when no client; when client selected show only that client's templates (no mixing to avoid confusion)
|
||||
const filteredTemplates = computed(() => {
|
||||
const cuuid = form.client_uuid;
|
||||
const list = props.templates || [];
|
||||
if (!cuuid) {
|
||||
return list.filter((t) => t.client_id == null);
|
||||
}
|
||||
// When client is selected, only show that client's templates (no globals)
|
||||
return list.filter(
|
||||
(t) => (t.client_uuid && t.client_uuid === cuuid) || t.client_id == null
|
||||
);
|
||||
return list.filter((t) => t.client_uuid === cuuid);
|
||||
});
|
||||
|
||||
const uploading = ref(false);
|
||||
const dragActive = ref(false);
|
||||
const uploadError = ref(null);
|
||||
|
||||
function onFileChange(e) {
|
||||
const files = e.target.files;
|
||||
if (files && files.length) {
|
||||
form.file = files[0];
|
||||
uploadError.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpload() {
|
||||
await form.post(route("imports.store"), {
|
||||
forceFormData: true,
|
||||
onSuccess: (res) => {
|
||||
const data = res?.props || {};
|
||||
},
|
||||
onFinish: async () => {
|
||||
// After upload, fetch columns for preview
|
||||
if (!form.recentlySuccessful) return;
|
||||
// Inertia doesn't expose JSON response directly with useForm; fallback to API call using fetch
|
||||
const fd = new FormData();
|
||||
fd.append("file", form.file);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchColumns() {
|
||||
if (!importId.value) return;
|
||||
const url = route("imports.columns", { import: importId.value });
|
||||
const { data } = await axios.get(url, {
|
||||
params: { has_header: hasHeader.value ? 1 : 0 },
|
||||
});
|
||||
detected.value = {
|
||||
columns: data.columns || [],
|
||||
delimiter: data.detected_delimiter || ",",
|
||||
has_header: !!data.has_header,
|
||||
};
|
||||
// initialize simple mapping rows with defaults if none exist
|
||||
if (!mappingRows.value.length) {
|
||||
mappingRows.value = (detected.value.columns || []).map((c, idx) => ({
|
||||
source_column: c,
|
||||
entity: "",
|
||||
field: "",
|
||||
skip: false,
|
||||
transform: "trim",
|
||||
apply_mode: "both",
|
||||
position: idx,
|
||||
}));
|
||||
function onFileDrop(e) {
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length) {
|
||||
form.file = files[0];
|
||||
uploadError.value = null;
|
||||
}
|
||||
await refreshSuggestions(detected.value.columns);
|
||||
// If there are mappings already (template applied or saved), load them to auto-assign
|
||||
await loadImportMappings();
|
||||
dragActive.value = false;
|
||||
}
|
||||
|
||||
async function uploadAndPreview() {
|
||||
async function startImport() {
|
||||
uploadError.value = null;
|
||||
if (!form.file) {
|
||||
// Basic guard: require a file before proceeding
|
||||
uploadError.value = "Najprej izberite datoteko."; // "Select a file first."
|
||||
return;
|
||||
}
|
||||
templateApplied.value = false;
|
||||
processResult.value = null;
|
||||
const fd = new window.FormData();
|
||||
fd.append("file", form.file);
|
||||
if (
|
||||
form.import_template_id !== null &&
|
||||
form.import_template_id !== undefined &&
|
||||
String(form.import_template_id).trim() !== ""
|
||||
) {
|
||||
fd.append("import_template_id", String(form.import_template_id));
|
||||
}
|
||||
if (form.client_uuid) {
|
||||
fd.append("client_uuid", String(form.client_uuid));
|
||||
}
|
||||
fd.append("has_header", hasHeader.value ? "1" : "0");
|
||||
uploading.value = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", form.file);
|
||||
if (form.import_template_id != null) {
|
||||
fd.append("import_template_id", String(form.import_template_id));
|
||||
}
|
||||
if (form.client_uuid) {
|
||||
fd.append("client_uuid", form.client_uuid);
|
||||
}
|
||||
fd.append("has_header", form.has_header ? "1" : "0");
|
||||
const { data } = await axios.post(route("imports.store"), fd, {
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
});
|
||||
// Redirect immediately to the continue page for this import
|
||||
if (data?.uuid) {
|
||||
router.visit(route("imports.continue", { import: data.uuid }));
|
||||
} else if (data?.id) {
|
||||
// Fallback: if uuid not returned for some reason, fetch columns here (legacy)
|
||||
importId.value = data.id;
|
||||
await fetchColumns();
|
||||
return;
|
||||
}
|
||||
if (data?.id) {
|
||||
// Fallback if only numeric id returned
|
||||
router.visit(route("imports.continue", { import: data.id }));
|
||||
return;
|
||||
}
|
||||
uploadError.value = "Nepričakovan odgovor strežnika."; // Unexpected server response.
|
||||
} catch (e) {
|
||||
if (e.response) {
|
||||
console.error("Upload error", e.response.status, e.response.data);
|
||||
if (e.response.data?.errors) {
|
||||
// Optionally you could surface errors in the UI; for now, log for visibility
|
||||
}
|
||||
if (e.response?.data?.message) {
|
||||
uploadError.value = e.response.data.message;
|
||||
} else {
|
||||
console.error("Upload error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If continuing an existing import, set importId and hydrate columns and mappings
|
||||
// No continuation logic on Create page anymore
|
||||
|
||||
async function applyTemplateToImport() {
|
||||
if (!importId.value || !form.import_template_id) return;
|
||||
try {
|
||||
await axios.post(
|
||||
route("importTemplates.apply", {
|
||||
template: form.import_template_id,
|
||||
import: importId.value,
|
||||
}),
|
||||
{},
|
||||
{
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
templateApplied.value = true;
|
||||
// Load mappings and auto-assign UI rows
|
||||
await loadImportMappings();
|
||||
} catch (e) {
|
||||
templateApplied.value = false;
|
||||
if (e.response) {
|
||||
console.error("Apply template error", e.response.status, e.response.data);
|
||||
} else {
|
||||
console.error("Apply template error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadImportMappings() {
|
||||
if (!importId.value) return;
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
route("imports.mappings.get", { import: importId.value }),
|
||||
{
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
const rows = Array.isArray(data?.mappings) ? data.mappings : [];
|
||||
if (!rows.length) return;
|
||||
// Build a lookup by source_column
|
||||
const bySource = new Map(rows.map((r) => [r.source_column, r]));
|
||||
// Update mappingRows (detected columns) to reflect applied mappings
|
||||
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 = keyByCanonicalRoot.value[record] || record;
|
||||
return {
|
||||
...r,
|
||||
entity,
|
||||
field: field || "",
|
||||
transform: m.transform || "",
|
||||
apply_mode: m.apply_mode || "both",
|
||||
skip: false,
|
||||
position: idx,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Load import mappings error",
|
||||
e.response?.status || "",
|
||||
e.response?.data || e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function processImport() {
|
||||
if (!importId.value) return;
|
||||
processing.value = true;
|
||||
processResult.value = null;
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
route("imports.process", { import: importId.value }),
|
||||
{},
|
||||
{
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
processResult.value = data;
|
||||
} catch (e) {
|
||||
if (e.response) {
|
||||
console.error("Process import error", e.response.status, e.response.data);
|
||||
processResult.value = { error: e.response.data || "Processing failed" };
|
||||
} else {
|
||||
console.error("Process import error", e);
|
||||
processResult.value = { error: "Processing failed" };
|
||||
uploadError.value = "Nalaganje ni uspelo."; // Upload failed.
|
||||
}
|
||||
console.error("Import upload failed", e.response?.status, e.response?.data || e);
|
||||
} finally {
|
||||
processing.value = false;
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// entity options and fields are dynamic from API
|
||||
|
||||
async function saveMappings() {
|
||||
if (!importId.value) return;
|
||||
mappingError.value = "";
|
||||
const mappings = mappingRows.value
|
||||
.filter((r) => !r.skip && r.entity && r.field)
|
||||
.map((r) => ({
|
||||
source_column: r.source_column,
|
||||
target_field: `${canonicalRootByKey.value[r.entity] || r.entity}.${r.field}`,
|
||||
transform: r.transform || null,
|
||||
apply_mode: r.apply_mode || "both",
|
||||
options: null,
|
||||
}));
|
||||
if (!mappings.length) {
|
||||
mappingSaved.value = false;
|
||||
mappingError.value =
|
||||
"Select entity and field for at least one column (or uncheck Skip) before saving.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
savingMappings.value = true;
|
||||
const url =
|
||||
typeof route === "function"
|
||||
? route("imports.mappings.save", { import: importId.value })
|
||||
: `/imports/${importId.value}/mappings`;
|
||||
const { data } = await axios.post(
|
||||
url,
|
||||
{ mappings },
|
||||
{
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
mappingSaved.value = true;
|
||||
mappingSavedCount.value = Number(data?.saved || mappings.length);
|
||||
mappingError.value = "";
|
||||
} catch (e) {
|
||||
mappingSaved.value = false;
|
||||
if (e.response) {
|
||||
console.error("Save mappings error", e.response.status, e.response.data);
|
||||
alert(
|
||||
"Failed to save mappings: " + (e.response.data?.message || e.response.status)
|
||||
);
|
||||
} else {
|
||||
console.error("Save mappings error", e);
|
||||
alert("Failed to save mappings. See console for details.");
|
||||
}
|
||||
} finally {
|
||||
savingMappings.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset saved flag whenever user edits mappings
|
||||
watch(
|
||||
mappingRows,
|
||||
() => {
|
||||
mappingSaved.value = false;
|
||||
mappingSavedCount.value = 0;
|
||||
mappingError.value = "";
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEntityDefs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="New Import">
|
||||
<AppLayout title="Nov uvoz">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">New Import</h2>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Nov uvoz</h2>
|
||||
</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">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white shadow sm:rounded-lg p-6 space-y-8">
|
||||
<!-- Intro / guidance -->
|
||||
<div class="text-sm text-gray-600 leading-relaxed">
|
||||
<p class="mb-2">
|
||||
1) Izberite stranko (opcijsko) in predlogo (če obstaja), 2) izberite
|
||||
datoteko (CSV, TXT, XLSX*) in 3) kliknite Začni uvoz. Nadaljnje preslikave
|
||||
in simulacija bodo na naslednji strani.
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
* XLSX podpora je odvisna od konfiguracije strežnika.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Client & Template selection -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Client</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Stranka</label>
|
||||
<Multiselect
|
||||
v-model="selectedClientOption"
|
||||
:options="clients"
|
||||
track-by="uuid"
|
||||
label="name"
|
||||
placeholder="Search clients..."
|
||||
placeholder="Poišči stranko..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Template</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Predloga</label>
|
||||
<Multiselect
|
||||
v-model="selectedTemplateOption"
|
||||
:options="filteredTemplates"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Search templates..."
|
||||
placeholder="Poišči predlogo..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ option.name }}</span>
|
||||
<span class="ml-2 text-xs text-gray-500"
|
||||
>({{ option.source_type }})</span
|
||||
>
|
||||
</div>
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
||||
>{{ option.client_id ? "Client" : "Global" }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template #singleLabel="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ option.name }}</span>
|
||||
<span class="ml-1 text-xs text-gray-500"
|
||||
>({{ option.source_type }})</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
||||
class="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
||||
>{{ option.client_id ? "Client" : "Global" }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</Multiselect>
|
||||
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">
|
||||
Only global templates are shown until a client is selected.
|
||||
Prikazane so samo globalne predloge dokler ne izberete stranke.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">File</label>
|
||||
<input type="file" @change="onFileChange" class="mt-1 block w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700"
|
||||
>Has header row</label
|
||||
<!-- File + Header -->
|
||||
<div class="grid grid-cols-1 gap-6 items-start">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Datoteka</label>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-md p-6 text-center cursor-pointer transition-colors"
|
||||
:class="{
|
||||
'border-indigo-400 bg-indigo-50': dragActive,
|
||||
'border-gray-300 hover:border-gray-400': !dragActive,
|
||||
}"
|
||||
@dragover.prevent="dragActive = true"
|
||||
@dragleave.prevent="dragActive = false"
|
||||
@drop.prevent="onFileDrop"
|
||||
>
|
||||
<input type="checkbox" v-model="hasHeader" class="mt-2" />
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
id="import-file-input"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<label for="import-file-input" class="block cursor-pointer select-none">
|
||||
<div v-if="!form.file" class="text-sm text-gray-600">
|
||||
Povlecite datoteko sem ali
|
||||
<span class="text-indigo-600 underline">kliknite za izbiro</span>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-800 flex flex-col gap-1">
|
||||
<span class="font-medium">{{ form.file.name }}</span>
|
||||
<span class="text-xs text-gray-500"
|
||||
>{{ (form.file.size / 1024).toFixed(1) }} kB</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] inline-block bg-gray-100 px-1.5 py-0.5 rounded"
|
||||
>Zamenjaj</span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<input type="checkbox" v-model="form.has_header" class="rounded" />
|
||||
<span>Prva vrstica je glava</span>
|
||||
</label>
|
||||
<div class="text-xs text-gray-500 leading-relaxed">
|
||||
Če ni označeno, bodo stolpci poimenovani po zaporedju (A, B, C ...).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<!-- Errors -->
|
||||
<div v-if="uploadError" class="text-sm text-red-600">
|
||||
{{ uploadError }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
<button
|
||||
@click.prevent="uploadAndPreview"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded"
|
||||
>
|
||||
Upload & Preview Columns
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="applyTemplateToImport"
|
||||
:disabled="!importId || !form.import_template_id || templateApplied"
|
||||
class="px-4 py-2 bg-emerald-600 disabled:bg-gray-300 text-white rounded"
|
||||
>
|
||||
{{ templateApplied ? "Template Applied" : "Apply Template" }}
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="saveMappings"
|
||||
:disabled="!importId || processing || savingMappings"
|
||||
class="px-4 py-2 bg-orange-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||
title="Save ad-hoc mappings for this import"
|
||||
type="button"
|
||||
@click="startImport"
|
||||
:disabled="uploading"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 rounded bg-indigo-600 disabled:bg-indigo-300 text-white text-sm font-medium shadow-sm"
|
||||
>
|
||||
<span
|
||||
v-if="savingMappings"
|
||||
class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
||||
v-if="uploading"
|
||||
class="h-4 w-4 border-2 border-white/60 border-t-transparent rounded-full animate-spin"
|
||||
></span>
|
||||
<span>Save Mappings</span>
|
||||
<span
|
||||
v-if="selectedMappingsCount"
|
||||
class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded"
|
||||
>{{ selectedMappingsCount }}</span
|
||||
>
|
||||
<span>{{ uploading ? "Nalagam..." : "Začni uvoz" }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="processImport"
|
||||
:disabled="!importId || processing || (!templateApplied && !mappingSaved)"
|
||||
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded"
|
||||
type="button"
|
||||
@click="
|
||||
() => {
|
||||
form.file = null;
|
||||
uploadError = null;
|
||||
}
|
||||
"
|
||||
:disabled="uploading || !form.file"
|
||||
class="px-4 py-2 text-sm rounded border bg-white disabled:opacity-50"
|
||||
>
|
||||
{{ processing ? "Processing…" : "Process Import" }}
|
||||
Počisti
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600" v-if="!importId">
|
||||
Upload a file first to enable saving mappings.
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 text-xs text-gray-600"
|
||||
v-else-if="importId && !selectedMappingsCount"
|
||||
>
|
||||
Select an Entity and Field for at least one detected column (or uncheck Skip)
|
||||
and then click Save Mappings.
|
||||
</div>
|
||||
|
||||
<div v-if="detected.columns.length" class="pt-4">
|
||||
<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>
|
||||
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
|
||||
<th class="p-2 border">Source column</th>
|
||||
<th class="p-2 border">Entity</th>
|
||||
<th class="p-2 border">Field</th>
|
||||
<th class="p-2 border">Transform</th>
|
||||
<th class="p-2 border">Apply mode</th>
|
||||
<th class="p-2 border">Skip</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in mappingRows" :key="idx" class="border-t">
|
||||
<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>
|
||||
<option
|
||||
v-for="opt in entityOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<select v-model="row.field" class="border rounded p-1 w-full">
|
||||
<option value="">—</option>
|
||||
<option
|
||||
v-for="f in fieldOptionsByEntity[row.entity] || []"
|
||||
:key="f.value"
|
||||
:value="f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<select v-model="row.transform" class="border rounded p-1 w-full">
|
||||
<option value="">None</option>
|
||||
<option value="trim">Trim</option>
|
||||
<option value="upper">Uppercase</option>
|
||||
<option value="lower">Lowercase</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<select v-model="row.apply_mode" class="border rounded p-1 w-full">
|
||||
<option value="both">Both</option>
|
||||
<option value="insert">Insert only</option>
|
||||
<option value="update">Update only</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="p-2 border text-center">
|
||||
<input type="checkbox" v-model="row.skip" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2">
|
||||
Mappings saved ({{ mappingSavedCount }}).
|
||||
</div>
|
||||
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">
|
||||
{{ mappingError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="processResult" class="pt-4">
|
||||
<h3 class="font-semibold mb-2">Import Result</h3>
|
||||
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{
|
||||
processResult
|
||||
}}</pre>
|
||||
<div class="text-xs text-gray-400 pt-4 border-t">
|
||||
Po nalaganju boste preusmerjeni na nadaljevanje uvoza, kjer lahko izvedete
|
||||
preslikave, simulacijo in končno obdelavo.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user