Mager updated
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import BasicButton from '@/Components/buttons/BasicButton.vue';
|
||||
import Drawer from '@/Components/Drawer.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SectionTitle from '@/Components/SectionTitle.vue';
|
||||
@@ -87,7 +87,7 @@ const store = () => {
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<Drawer
|
||||
<DialogModal
|
||||
:show="show"
|
||||
@close="close"
|
||||
>
|
||||
@@ -161,5 +161,5 @@ const store = () => {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</Drawer>
|
||||
</DialogModal>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import Drawer from '@/Components/Drawer.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SectionTitle from '@/Components/SectionTitle.vue';
|
||||
@@ -48,7 +48,7 @@ const storeContract = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
<DialogModal
|
||||
:show="show"
|
||||
@close="close"
|
||||
>
|
||||
@@ -97,6 +97,6 @@ const storeContract = () => {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</Drawer>
|
||||
</DialogModal>
|
||||
|
||||
</template>
|
||||
@@ -8,6 +8,11 @@ import ContractDrawer from "./Partials/ContractDrawer.vue";
|
||||
import ContractTable from "./Partials/ContractTable.vue";
|
||||
import ActivityDrawer from "./Partials/ActivityDrawer.vue";
|
||||
import ActivityTable from "./Partials/ActivityTable.vue";
|
||||
import DocumentsTable from "@/Components/DocumentsTable.vue";
|
||||
import DocumentUploadDialog from "@/Components/DocumentUploadDialog.vue";
|
||||
import DocumentViewerDialog from "@/Components/DocumentViewerDialog.vue";
|
||||
import { classifyDocument } from "@/Services/documents";
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { AngleDownIcon, AngleUpIcon } from "@/Utilities/Icons";
|
||||
import Pagination from "@/Components/Pagination.vue";
|
||||
|
||||
@@ -18,9 +23,32 @@ const props = defineProps({
|
||||
activities: Object,
|
||||
contract_types: Array,
|
||||
actions: Array,
|
||||
types: Object
|
||||
types: Object,
|
||||
documents: Array
|
||||
});
|
||||
|
||||
const showUpload = ref(false);
|
||||
const openUpload = () => { showUpload.value = true; };
|
||||
const closeUpload = () => { showUpload.value = false; };
|
||||
const onUploaded = () => {
|
||||
// Refresh page data to include the new document
|
||||
router.reload({ only: ['documents'] });
|
||||
};
|
||||
|
||||
const viewer = ref({ open: false, src: '', title: '' });
|
||||
const openViewer = (doc) => {
|
||||
const kind = classifyDocument(doc)
|
||||
if (kind === 'preview') {
|
||||
const url = route('clientCase.document.view', { client_case: props.client_case.uuid, document: doc.uuid })
|
||||
viewer.value = { open: true, src: url, title: doc.original_name || doc.name };
|
||||
} else {
|
||||
const url = route('clientCase.document.download', { client_case: props.client_case.uuid, document: doc.uuid })
|
||||
// immediate download: navigate to URL
|
||||
window.location.href = url
|
||||
}
|
||||
};
|
||||
const closeViewer = () => { viewer.value.open = false; viewer.value.src = ''; };
|
||||
|
||||
const clientDetails = ref(true);
|
||||
|
||||
//Drawer add new contract
|
||||
@@ -131,6 +159,29 @@ const hideClietnDetails = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Documents section -->
|
||||
<div class="pt-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4">
|
||||
<div class="mx-auto max-w-4x1">
|
||||
<div class="flex justify-between p-4">
|
||||
<SectionTitle>
|
||||
<template #title>Dokumenti</template>
|
||||
</SectionTitle>
|
||||
<FwbButton @click="openUpload">Dodaj</FwbButton>
|
||||
</div>
|
||||
<DocumentsTable :documents="documents" @view="openViewer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentUploadDialog
|
||||
:show="showUpload"
|
||||
@close="closeUpload"
|
||||
@uploaded="onUploaded"
|
||||
:post-url="route('clientCase.document.store', client_case)"
|
||||
/>
|
||||
<DocumentViewerDialog :show="viewer.open" :src="viewer.src" :title="viewer.title" @close="closeViewer" />
|
||||
<div class="pt-12 pb-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4">
|
||||
|
||||
@@ -8,7 +8,7 @@ import InputLabel from '@/Components/InputLabel.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { Link, useForm } from '@inertiajs/vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import Drawer from '@/Components/Drawer.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import Pagination from '@/Components/Pagination.vue';
|
||||
import SearchInput from '@/Components/SearchInput.vue';
|
||||
|
||||
@@ -117,7 +117,7 @@ const storeClient = () => {
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
<Drawer
|
||||
<DialogModal
|
||||
:show="drawerCreateClient"
|
||||
@close="drawerCreateClient = false"
|
||||
>
|
||||
@@ -250,5 +250,5 @@ const storeClient = () => {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</Drawer>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import Drawer from '@/Components/Drawer.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
@@ -61,7 +61,7 @@ const storeCase = () => {
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<Drawer
|
||||
<DialogModal
|
||||
:show="show"
|
||||
@close="close">
|
||||
|
||||
@@ -197,5 +197,5 @@ const storeCase = () => {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</Drawer>
|
||||
</DialogModal>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import Drawer from '@/Components/Drawer.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SectionTitle from '@/Components/SectionTitle.vue';
|
||||
@@ -42,7 +42,7 @@ onMounted(() => {
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<Drawer
|
||||
<DialogModal
|
||||
:show="show"
|
||||
@close="close"
|
||||
>
|
||||
@@ -114,5 +114,5 @@ onMounted(() => {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</Drawer>
|
||||
</DialogModal>
|
||||
</template>
|
||||
@@ -0,0 +1,537 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useForm, router } from '@inertiajs/vue3';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
import axios from 'axios';
|
||||
|
||||
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);
|
||||
|
||||
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)
|
||||
const selectedClientOption = computed({
|
||||
get() {
|
||||
const cuuid = form.client_uuid;
|
||||
if (!cuuid) return null;
|
||||
return (props.clients || []).find(c => c.uuid === cuuid) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.client_uuid = val ? val.uuid : null;
|
||||
}
|
||||
});
|
||||
|
||||
// Bridge Template Multiselect to store only template id (number) in form
|
||||
const selectedTemplateOption = computed({
|
||||
get() {
|
||||
const tid = form.import_template_id;
|
||||
if (tid == null) return null;
|
||||
return (props.templates || []).find(t => t.id === tid) || 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).
|
||||
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);
|
||||
});
|
||||
|
||||
function onFileChange(e) {
|
||||
const files = e.target.files;
|
||||
if (files && files.length) {
|
||||
form.file = files[0];
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
// If there are mappings already (template applied or saved), load them to auto-assign
|
||||
await loadImportMappings();
|
||||
}
|
||||
|
||||
async function uploadAndPreview() {
|
||||
if (!form.file) {
|
||||
// Basic guard: require a file before proceeding
|
||||
return;
|
||||
}
|
||||
templateApplied.value = false;
|
||||
processResult.value = null;
|
||||
const fd = new window.FormData();
|
||||
fd.append('file', form.file);
|
||||
if (Number.isFinite(form.import_template_id)) {
|
||||
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');
|
||||
try {
|
||||
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();
|
||||
}
|
||||
} 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
|
||||
}
|
||||
} 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 = recordToEntityKey(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' };
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const entityOptions = [
|
||||
{ value: 'person', label: 'Person' },
|
||||
{ value: 'person_addresses', label: 'Person Address' },
|
||||
{ value: 'person_phones', label: 'Person Phone' },
|
||||
{ value: 'emails', label: 'Email' },
|
||||
{ value: 'accounts', label: 'Account' },
|
||||
{ value: 'contracts', label: 'Contract' },
|
||||
];
|
||||
|
||||
const fieldOptionsByEntity = {
|
||||
person: [
|
||||
{ value: 'first_name', label: 'First name' },
|
||||
{ value: 'last_name', label: 'Last name' },
|
||||
{ value: 'full_name', label: 'Full name' },
|
||||
{ value: 'tax_number', label: 'Tax number' },
|
||||
{ value: 'social_security_number', label: 'SSN' },
|
||||
{ value: 'birthday', label: 'Birthday' },
|
||||
{ value: 'gender', label: 'Gender' },
|
||||
{ value: 'description', label: 'Description' },
|
||||
],
|
||||
person_addresses: [
|
||||
{ value: 'address', label: 'Address' },
|
||||
{ value: 'country', label: 'Country' },
|
||||
{ value: 'type_id', label: 'Address Type Id' },
|
||||
{ value: 'description', label: 'Description' },
|
||||
],
|
||||
person_phones: [
|
||||
{ value: 'nu', label: 'Phone number' },
|
||||
{ value: 'country_code', label: 'Country code' },
|
||||
{ value: 'type_id', label: 'Phone Type Id' },
|
||||
{ value: 'description', label: 'Description' },
|
||||
],
|
||||
emails: [
|
||||
{ value: 'value', label: 'Email address' },
|
||||
{ value: 'label', label: 'Label' },
|
||||
{ value: 'is_primary', label: 'Is primary' },
|
||||
],
|
||||
accounts: [
|
||||
{ value: 'reference', label: 'Reference' },
|
||||
{ value: 'description', label: 'Description' },
|
||||
{ value: 'contract_id', label: 'Contract Id' },
|
||||
{ value: 'contract_reference', label: 'Contract Reference' },
|
||||
{ value: 'type_id', label: 'Account Type Id' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'balance_amount', label: 'Balance Amount' },
|
||||
],
|
||||
contracts: [
|
||||
{ value: 'reference', label: 'Reference' },
|
||||
{ value: 'start_date', label: 'Start Date' },
|
||||
{ value: 'end_date', label: 'End Date' },
|
||||
{ value: 'description', label: 'Description' },
|
||||
{ value: 'client_case_id', label: 'Client Case Id' },
|
||||
{ value: 'type_id', label: 'Contract Type Id' },
|
||||
],
|
||||
};
|
||||
|
||||
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: `${entityKeyToRecord(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 });
|
||||
|
||||
function entityKeyToRecord(key) {
|
||||
// Map UI entities to record_type nouns used by processor
|
||||
if (key === 'person_addresses') return 'address';
|
||||
if (key === 'person_phones') return 'phone';
|
||||
if (key === 'emails') return 'email';
|
||||
if (key === 'accounts') return 'account';
|
||||
if (key === 'contracts') return 'contract';
|
||||
return 'person';
|
||||
}
|
||||
|
||||
function recordToEntityKey(record) {
|
||||
if (record === 'address') return 'person_addresses';
|
||||
if (record === 'phone') return 'person_phones';
|
||||
if (record === 'email') return 'emails';
|
||||
if (record === 'account') return 'accounts';
|
||||
if (record === 'contract') return 'contracts';
|
||||
return 'person';
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="New Import">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">New Import</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>
|
||||
<label class="block text-sm font-medium text-gray-700">Client</label>
|
||||
<Multiselect
|
||||
v-model="selectedClientOption"
|
||||
:options="clients"
|
||||
track-by="uuid"
|
||||
label="name"
|
||||
placeholder="Search clients..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Template</label>
|
||||
<Multiselect
|
||||
v-model="selectedTemplateOption"
|
||||
:options="filteredTemplates"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Search templates..."
|
||||
: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="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">{{ 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.</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>
|
||||
<input type="checkbox" v-model="hasHeader" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<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"
|
||||
>
|
||||
<span v-if="savingMappings" class="inline-block h-4 w-4 border-2 border-white/70 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>
|
||||
</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"
|
||||
>
|
||||
{{ processing ? 'Processing…' : 'Process Import' }}
|
||||
</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">
|
||||
<h3 class="font-semibold mb-2">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})</h3>
|
||||
<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">{{ row.source_column }}</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,805 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
import axios from 'axios';
|
||||
|
||||
const props = defineProps({
|
||||
import: Object,
|
||||
templates: Array,
|
||||
clients: Array,
|
||||
});
|
||||
|
||||
const importId = ref(props.import?.id || null);
|
||||
const hasHeader = ref(Boolean(props.import?.meta?.has_header ?? true));
|
||||
const detected = ref({ columns: props.import?.meta?.columns || [], delimiter: props.import?.meta?.detected_delimiter || ',', has_header: hasHeader.value });
|
||||
const templateApplied = ref(Boolean(props.import?.import_template_id));
|
||||
const processing = ref(false);
|
||||
const processResult = ref(null);
|
||||
const mappingRows = ref([]);
|
||||
const mappingSaved = ref(false);
|
||||
const mappingSavedCount = ref(0);
|
||||
const mappingError = ref('');
|
||||
const savingMappings = ref(false);
|
||||
// Persisted mappings from backend (raw view regardless of detected columns)
|
||||
const persistedMappings = ref([]);
|
||||
const detectedNote = ref('');
|
||||
// Logs
|
||||
const events = ref([]);
|
||||
const eventsLimit = ref(200);
|
||||
const loadingEvents = ref(false);
|
||||
|
||||
// Completed status helper
|
||||
const isCompleted = computed(() => (props.import?.status || '') === 'completed');
|
||||
|
||||
// Whether backend has any saved mappings for this import
|
||||
const hasPersistedMappings = computed(() => (persistedMappings.value?.length || 0) > 0);
|
||||
const canProcess = computed(() => !!importId.value && !processing.value && hasPersistedMappings.value && !isCompleted.value);
|
||||
|
||||
// Display rows used by the table: prefer mappingRows if present; otherwise fall back to detected columns
|
||||
const displayRows = computed(() => {
|
||||
if (Array.isArray(mappingRows.value) && mappingRows.value.length > 0) {
|
||||
return mappingRows.value;
|
||||
}
|
||||
const cols = detected.value?.columns || [];
|
||||
return cols.map((c, idx) => {
|
||||
const guess = guessMappingForHeader(c);
|
||||
return {
|
||||
source_column: c,
|
||||
entity: guess?.entity || '',
|
||||
field: guess?.field || '',
|
||||
skip: false,
|
||||
transform: 'trim',
|
||||
apply_mode: 'both',
|
||||
position: idx,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Header normalization and guess mapping for auto-assigning sensible defaults
|
||||
function stripDiacritics(s) {
|
||||
if (!s) return '';
|
||||
return String(s)
|
||||
.replace(/[čć]/gi, 'c')
|
||||
.replace(/[š]/gi, 's')
|
||||
.replace(/[ž]/gi, 'z')
|
||||
.replace(/[đ]/gi, 'd')
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
}
|
||||
function normalizeHeader(h) {
|
||||
if (!h) return '';
|
||||
const base = stripDiacritics(String(h)).toLowerCase();
|
||||
return base.replace(/[^a-z0-9]+/g, ' ').trim().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function guessMappingForHeader(h) {
|
||||
const key = normalizeHeader(h);
|
||||
// Confident matches only; avoid overly generic keys like 'name'
|
||||
const dict = {
|
||||
// Person
|
||||
firstname: { entity: 'person', field: 'first_name' },
|
||||
givenname: { entity: 'person', field: 'first_name' },
|
||||
forename: { entity: 'person', field: 'first_name' },
|
||||
lastname: { entity: 'person', field: 'last_name' },
|
||||
surname: { entity: 'person', field: 'last_name' },
|
||||
familyname: { entity: 'person', field: 'last_name' },
|
||||
fullname: { entity: 'person', field: 'full_name' },
|
||||
taxnumber: { entity: 'person', field: 'tax_number' },
|
||||
taxid: { entity: 'person', field: 'tax_number' },
|
||||
tin: { entity: 'person', field: 'tax_number' },
|
||||
socialsecuritynumber: { entity: 'person', field: 'social_security_number' },
|
||||
ssn: { entity: 'person', field: 'social_security_number' },
|
||||
birthday: { entity: 'person', field: 'birthday' },
|
||||
birthdate: { entity: 'person', field: 'birthday' },
|
||||
dob: { entity: 'person', field: 'birthday' },
|
||||
gender: { entity: 'person', field: 'gender' },
|
||||
description: { entity: 'person', field: 'description' },
|
||||
// Email
|
||||
email: { entity: 'emails', field: 'value' },
|
||||
emailaddress: { entity: 'emails', field: 'value' },
|
||||
// Phone
|
||||
phone: { entity: 'person_phones', field: 'nu' },
|
||||
phonenumber: { entity: 'person_phones', field: 'nu' },
|
||||
mobile: { entity: 'person_phones', field: 'nu' },
|
||||
gsm: { entity: 'person_phones', field: 'nu' },
|
||||
telephone: { entity: 'person_phones', field: 'nu' },
|
||||
countrycode: { entity: 'person_phones', field: 'country_code' },
|
||||
// Address
|
||||
address: { entity: 'person_addresses', field: 'address' },
|
||||
street: { entity: 'person_addresses', field: 'address' },
|
||||
country: { entity: 'person_addresses', field: 'country' },
|
||||
// Accounts
|
||||
accountreference: { entity: 'accounts', field: 'reference' },
|
||||
accountref: { entity: 'accounts', field: 'reference' },
|
||||
balancedue: { entity: 'accounts', field: 'balance_amount' },
|
||||
balance: { entity: 'accounts', field: 'balance_amount' },
|
||||
amount: { entity: 'accounts', field: 'balance_amount' },
|
||||
// Contracts
|
||||
reference: { entity: 'contracts', field: 'reference' },
|
||||
ref: { entity: 'contracts', field: 'reference' },
|
||||
contractreference: { entity: 'contracts', field: 'reference' },
|
||||
contractref: { entity: 'contracts', field: 'reference' },
|
||||
contract: { entity: 'contracts', field: 'reference' },
|
||||
contractno: { entity: 'contracts', field: 'reference' },
|
||||
contractnum: { entity: 'contracts', field: 'reference' },
|
||||
contractnumber: { entity: 'contracts', field: 'reference' },
|
||||
agreement: { entity: 'contracts', field: 'reference' },
|
||||
agreementno: { entity: 'contracts', field: 'reference' },
|
||||
agreementnum: { entity: 'contracts', field: 'reference' },
|
||||
agreementnumber: { entity: 'contracts', field: 'reference' },
|
||||
startdate: { entity: 'contracts', field: 'start_date' },
|
||||
enddate: { entity: 'contracts', field: 'end_date' },
|
||||
// Slovenian/common localized headers (normalized)
|
||||
ime: { entity: 'person', field: 'first_name' },
|
||||
priimek: { entity: 'person', field: 'last_name' },
|
||||
imeinpriimek: { entity: 'person', field: 'full_name' },
|
||||
polnoime: { entity: 'person', field: 'full_name' },
|
||||
davcna: { entity: 'person', field: 'tax_number' },
|
||||
davcnastevilka: { entity: 'person', field: 'tax_number' },
|
||||
emso: { entity: 'person', field: 'social_security_number' },
|
||||
rojstnidatum: { entity: 'person', field: 'birthday' },
|
||||
spol: { entity: 'person', field: 'gender' },
|
||||
opis: { entity: 'person', field: 'description' },
|
||||
eposta: { entity: 'emails', field: 'value' },
|
||||
elektronskaposta: { entity: 'emails', field: 'value' },
|
||||
telefon: { entity: 'person_phones', field: 'nu' },
|
||||
mobilni: { entity: 'person_phones', field: 'nu' },
|
||||
gsm: { entity: 'person_phones', field: 'nu' },
|
||||
klicna: { entity: 'person_phones', field: 'country_code' },
|
||||
drzava: { entity: 'person_addresses', field: 'country' },
|
||||
naslov: { entity: 'person_addresses', field: 'address' },
|
||||
ulica: { entity: 'person_addresses', field: 'address' },
|
||||
sklic: { entity: 'accounts', field: 'reference' },
|
||||
referenca: { entity: 'accounts', field: 'reference' },
|
||||
saldo: { entity: 'accounts', field: 'balance_amount' },
|
||||
znesek: { entity: 'accounts', field: 'balance_amount' },
|
||||
pogodbasklic: { entity: 'contracts', field: 'reference' },
|
||||
pogodbastevilka: { entity: 'contracts', field: 'reference' },
|
||||
pogodba: { entity: 'contracts', field: 'reference' },
|
||||
pogodbast: { entity: 'contracts', field: 'reference' },
|
||||
zacetek: { entity: 'contracts', field: 'start_date' },
|
||||
konec: { entity: 'contracts', field: 'end_date' },
|
||||
};
|
||||
return dict[key] || null;
|
||||
}
|
||||
|
||||
// Normalize source column to match persisted mappings in a case/space/diacritic-insensitive way
|
||||
function normalizeSource(s) {
|
||||
return normalizeHeader(s);
|
||||
}
|
||||
|
||||
// Entity and field options used by the mapping grid
|
||||
const entityOptions = [
|
||||
{ value: 'person', label: 'Person' },
|
||||
{ value: 'person_addresses', label: 'Person Address' },
|
||||
{ value: 'person_phones', label: 'Person Phone' },
|
||||
{ value: 'emails', label: 'Email' },
|
||||
{ value: 'accounts', label: 'Account' },
|
||||
{ value: 'contracts', label: 'Contract' },
|
||||
];
|
||||
|
||||
const fieldOptionsByEntity = {
|
||||
person: [
|
||||
{ value: 'first_name', label: 'First name' },
|
||||
{ value: 'last_name', label: 'Last name' },
|
||||
{ value: 'full_name', label: 'Full name' },
|
||||
{ value: 'tax_number', label: 'Tax number' },
|
||||
{ value: 'social_security_number', label: 'SSN' },
|
||||
{ value: 'birthday', label: 'Birthday' },
|
||||
{ value: 'gender', label: 'Gender' },
|
||||
{ value: 'description', label: 'Description' },
|
||||
],
|
||||
person_addresses: [
|
||||
{ value: 'address', label: 'Address' },
|
||||
{ value: 'country', label: 'Country' },
|
||||
{ value: 'type_id', label: 'Address Type Id' },
|
||||
{ value: 'description', label: 'Description' },
|
||||
],
|
||||
person_phones: [
|
||||
{ value: 'nu', label: 'Phone number' },
|
||||
{ value: 'country_code', label: 'Country code' },
|
||||
{ value: 'type_id', label: 'Phone Type Id' },
|
||||
{ value: 'description', label: 'Description' },
|
||||
],
|
||||
emails: [
|
||||
{ value: 'value', label: 'Email address' },
|
||||
{ value: 'label', label: 'Label' },
|
||||
{ value: 'is_primary', label: 'Is primary' },
|
||||
],
|
||||
accounts: [
|
||||
{ value: 'reference', label: 'Reference' },
|
||||
{ value: 'description', label: 'Description' },
|
||||
{ value: 'contract_id', label: 'Contract Id' },
|
||||
{ value: 'contract_reference', label: 'Contract Reference' },
|
||||
{ value: 'type_id', label: 'Account Type Id' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'balance_amount', label: 'Balance Amount' },
|
||||
],
|
||||
contracts: [
|
||||
{ value: 'reference', label: 'Reference' },
|
||||
{ value: 'start_date', label: 'Start Date' },
|
||||
{ value: 'end_date', label: 'End Date' },
|
||||
{ value: 'description', label: 'Description' },
|
||||
{ value: 'client_case_id', label: 'Client Case Id' },
|
||||
{ value: 'type_id', label: 'Contract Type Id' },
|
||||
],
|
||||
};
|
||||
|
||||
// Local state for selects
|
||||
const form = ref({
|
||||
client_uuid: null,
|
||||
import_template_id: props.import?.import_template_id || null,
|
||||
});
|
||||
|
||||
// Initialize client_uuid from numeric client_id using provided clients list
|
||||
if (props.import?.client_id) {
|
||||
const found = (props.clients || []).find(c => c.id === props.import.client_id);
|
||||
form.value.client_uuid = found ? found.uuid : null;
|
||||
}
|
||||
|
||||
const selectedClientOption = computed({
|
||||
get() {
|
||||
const cuuid = form.value.client_uuid;
|
||||
if (!cuuid) return null;
|
||||
return (props.clients || []).find(c => c.uuid === cuuid) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.value.client_uuid = val ? val.uuid : null;
|
||||
}
|
||||
});
|
||||
|
||||
const selectedTemplateOption = computed({
|
||||
get() {
|
||||
const tid = form.value.import_template_id;
|
||||
if (tid == null) return null;
|
||||
return (props.templates || []).find(t => t.id === tid) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.value.import_template_id = val ? val.id : null;
|
||||
}
|
||||
});
|
||||
|
||||
// Show only global templates when no client is selected.
|
||||
// When a client is selected, show only that client's templates (strict match by client_uuid, no globals).
|
||||
const filteredTemplates = computed(() => {
|
||||
const cuuid = form.value.client_uuid;
|
||||
const list = props.templates || [];
|
||||
if (!cuuid) {
|
||||
return list.filter(t => t.client_id == null);
|
||||
}
|
||||
return list.filter(t => t.client_uuid && t.client_uuid === cuuid);
|
||||
});
|
||||
|
||||
const selectedMappingsCount = computed(() => mappingRows.value.filter(r => !r.skip && r.entity && r.field).length);
|
||||
|
||||
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 } });
|
||||
// Normalize columns to strings for consistent rendering
|
||||
const colsRaw = Array.isArray(data.columns) ? data.columns : [];
|
||||
const normCols = colsRaw.map((c) => {
|
||||
if (typeof c === 'string' || typeof c === 'number') return String(c);
|
||||
if (c && typeof c === 'object') {
|
||||
return String(c.name ?? c.header ?? c.label ?? Object.values(c)[0] ?? '');
|
||||
}
|
||||
return '';
|
||||
}).filter(Boolean);
|
||||
detected.value = {
|
||||
columns: normCols,
|
||||
delimiter: data.detected_delimiter || ',',
|
||||
has_header: !!data.has_header,
|
||||
};
|
||||
detectedNote.value = data.note || '';
|
||||
// initialize mapping rows if empty
|
||||
if (!mappingRows.value.length && detected.value.columns.length) {
|
||||
mappingRows.value = detected.value.columns.map((c, idx) => {
|
||||
const guess = guessMappingForHeader(c);
|
||||
return {
|
||||
source_column: c,
|
||||
entity: guess?.entity || '',
|
||||
field: guess?.field || '',
|
||||
skip: false,
|
||||
transform: 'trim',
|
||||
apply_mode: 'both',
|
||||
position: idx,
|
||||
};
|
||||
});
|
||||
}
|
||||
await loadImportMappings();
|
||||
// Fallback: if no detected columns were found, but persisted mappings exist, use them to render the grid
|
||||
if ((!detected.value.columns || detected.value.columns.length === 0) && mappingRows.value.length === 0 && persistedMappings.value.length > 0) {
|
||||
mappingRows.value = persistedMappings.value.map((m, idx) => {
|
||||
const tf = String(m.target_field || '');
|
||||
const [record, field] = tf ? tf.split('.', 2) : ['', ''];
|
||||
return {
|
||||
source_column: m.source_column,
|
||||
entity: recordToEntityKey(record),
|
||||
field: field || '',
|
||||
skip: false,
|
||||
transform: m.transform || 'trim',
|
||||
apply_mode: m.apply_mode || 'both',
|
||||
position: idx,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTemplateToImport() {
|
||||
if (!importId.value || !form.value.import_template_id) return;
|
||||
try {
|
||||
await axios.post(route('importTemplates.apply', { template: form.value.import_template_id, import: importId.value }), {}, {
|
||||
headers: { Accept: 'application/json' },
|
||||
withCredentials: true,
|
||||
});
|
||||
templateApplied.value = true;
|
||||
await loadImportMappings();
|
||||
} catch (e) {
|
||||
templateApplied.value = false;
|
||||
console.error('Apply template error', e.response?.status || '', e.response?.data || 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 : [];
|
||||
// Store raw persisted mappings for display regardless of detected columns
|
||||
persistedMappings.value = rows.slice();
|
||||
if (!rows.length) return;
|
||||
const bySource = new Map(rows.map(r => [normalizeSource(r.source_column), r]));
|
||||
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
|
||||
const m = bySource.get(normalizeSource(r.source_column));
|
||||
if (!m) return r;
|
||||
const tf = String(m.target_field || '');
|
||||
let entity = m.entity || '';
|
||||
let field = r.field || '';
|
||||
if (tf) {
|
||||
const [record, fld] = tf.split('.', 2);
|
||||
const inferred = recordToEntityKey(record);
|
||||
if (!entity) entity = inferred;
|
||||
if (fld) field = fld;
|
||||
}
|
||||
return {
|
||||
...r,
|
||||
entity,
|
||||
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 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,
|
||||
entity: r.entity || null,
|
||||
target_field: `${entityKeyToRecord(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 = route('imports.mappings.save', { import: importId.value });
|
||||
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 = '';
|
||||
// Refresh persisted mappings so Process gating reflects the actual DB state
|
||||
await loadImportMappings();
|
||||
} catch (e) {
|
||||
mappingSaved.value = false;
|
||||
console.error('Save mappings error', e.response?.status || '', e.response?.data || e);
|
||||
} finally {
|
||||
savingMappings.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error('Process import error', e.response?.status || '', e.response?.data || e);
|
||||
processResult.value = { error: 'Processing failed' };
|
||||
} finally {
|
||||
processing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function entityKeyToRecord(key) {
|
||||
if (key === 'person_addresses') return 'address';
|
||||
if (key === 'person_phones') return 'phone';
|
||||
if (key === 'emails') return 'email';
|
||||
if (key === 'accounts') return 'account';
|
||||
if (key === 'contracts') return 'contract';
|
||||
return 'person';
|
||||
}
|
||||
function recordToEntityKey(record) {
|
||||
switch (record) {
|
||||
case 'person':
|
||||
return 'person';
|
||||
case 'address':
|
||||
case 'person_address':
|
||||
case 'person_addresses':
|
||||
return 'person_addresses';
|
||||
case 'phone':
|
||||
case 'person_phone':
|
||||
case 'person_phones':
|
||||
return 'person_phones';
|
||||
case 'email':
|
||||
case 'emails':
|
||||
return 'emails';
|
||||
case 'account':
|
||||
case 'accounts':
|
||||
return 'accounts';
|
||||
case 'contract':
|
||||
case 'contracts':
|
||||
return 'contracts';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
onMounted(async () => {
|
||||
// Build mapping grid from existing meta columns if present
|
||||
if (detected.value.columns?.length) {
|
||||
mappingRows.value = (detected.value.columns || []).map((c, idx) => {
|
||||
const guess = guessMappingForHeader(c);
|
||||
return {
|
||||
source_column: c,
|
||||
entity: guess?.entity || '',
|
||||
field: guess?.field || '',
|
||||
skip: false,
|
||||
transform: 'trim',
|
||||
apply_mode: 'both',
|
||||
position: idx,
|
||||
};
|
||||
});
|
||||
await loadImportMappings();
|
||||
} else {
|
||||
await fetchColumns();
|
||||
}
|
||||
// Load recent events (logs)
|
||||
await fetchEvents();
|
||||
});
|
||||
|
||||
// Reset saved flag whenever user edits mappings
|
||||
watch(mappingRows, () => {
|
||||
mappingSaved.value = false;
|
||||
mappingSavedCount.value = 0;
|
||||
mappingError.value = '';
|
||||
}, { deep: true });
|
||||
|
||||
// If detected columns are loaded after mount, initialize mapping rows once
|
||||
watch(() => detected.value.columns, (cols) => {
|
||||
if (Array.isArray(cols) && cols.length > 0 && mappingRows.value.length === 0) {
|
||||
mappingRows.value = cols.map((c, idx) => {
|
||||
const guess = guessMappingForHeader(c);
|
||||
return {
|
||||
source_column: c,
|
||||
entity: guess?.entity || '',
|
||||
field: guess?.field || '',
|
||||
skip: false,
|
||||
transform: 'trim',
|
||||
apply_mode: 'both',
|
||||
position: idx,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchEvents() {
|
||||
if (!importId.value) return;
|
||||
loadingEvents.value = true;
|
||||
try {
|
||||
const { data } = await axios.get(route('imports.events', { import: importId.value }), {
|
||||
params: { limit: eventsLimit.value },
|
||||
headers: { Accept: 'application/json' },
|
||||
withCredentials: true,
|
||||
});
|
||||
events.value = Array.isArray(data?.events) ? data.events : [];
|
||||
} catch (e) {
|
||||
console.error('Load import events error', e.response?.status || '', e.response?.data || e);
|
||||
} finally {
|
||||
loadingEvents.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :title="`Import ${props.import?.uuid || ''}`">
|
||||
<template #header>
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-1">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Continue Import</h2>
|
||||
<div class="text-sm text-gray-600">
|
||||
<span class="mr-4">Client: <strong>{{ selectedClientOption?.name || selectedClientOption?.uuid || '—' }}</strong></span>
|
||||
<span>
|
||||
Template:
|
||||
<strong>{{ selectedTemplateOption?.name || '—' }}</strong>
|
||||
<span v-if="templateApplied" class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 align-middle">locked</span>
|
||||
</span>
|
||||
</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">
|
||||
<div v-if="isCompleted" class="p-3 border rounded bg-gray-50 text-sm">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-1">
|
||||
<div><span class="text-gray-600">Status:</span> <span class="font-medium text-emerald-700">Completed</span></div>
|
||||
<div><span class="text-gray-600">Finished:</span> <span class="font-medium">{{ props.import?.finished_at ? new Date(props.import.finished_at).toLocaleString() : '—' }}</span></div>
|
||||
<div><span class="text-gray-600">Total:</span> <span class="font-medium">{{ props.import?.total_rows ?? '—' }}</span></div>
|
||||
<div><span class="text-gray-600">Imported:</span> <span class="font-medium">{{ props.import?.imported_rows ?? '—' }}</span></div>
|
||||
<div><span class="text-gray-600">Invalid:</span> <span class="font-medium">{{ props.import?.invalid_rows ?? '—' }}</span></div>
|
||||
<div><span class="text-gray-600">Valid:</span> <span class="font-medium">{{ props.import?.valid_rows ?? '—' }}</span></div>
|
||||
</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">Client</label>
|
||||
<Multiselect
|
||||
v-model="selectedClientOption"
|
||||
:options="clients"
|
||||
track-by="uuid"
|
||||
label="name"
|
||||
placeholder="Search clients..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
disabled
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Client is set during upload.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Template</label>
|
||||
<Multiselect
|
||||
v-model="selectedTemplateOption"
|
||||
:options="filteredTemplates"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Search templates..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
:disabled="templateApplied"
|
||||
>
|
||||
<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="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">{{ option.client_id ? 'Client' : 'Global' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Multiselect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3" v-if="!isCompleted">
|
||||
<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 || isCompleted"
|
||||
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"
|
||||
>
|
||||
<span v-if="savingMappings" class="inline-block h-4 w-4 border-2 border-white/70 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>
|
||||
</button>
|
||||
<button
|
||||
@click.prevent="processImport"
|
||||
:disabled="!canProcess"
|
||||
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded"
|
||||
>
|
||||
{{ processing ? 'Processing…' : 'Process Import' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600" v-if="!importId">
|
||||
Import not found.
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600" v-else-if="importId && !hasPersistedMappings && !isCompleted">
|
||||
Apply a template or select Entity and Field for one or more columns, then click Save Mappings to enable processing.
|
||||
</div>
|
||||
|
||||
<div v-if="persistedMappings.length" class="pt-4">
|
||||
<h3 class="font-semibold mb-2">Current Saved Mappings</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full border bg-white text-sm">
|
||||
<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">Target field</th>
|
||||
<th class="p-2 border">Transform</th>
|
||||
<th class="p-2 border">Mode</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in persistedMappings" :key="m.id" class="border-t">
|
||||
<td class="p-2 border">{{ m.source_column }}</td>
|
||||
<td class="p-2 border">{{ m.target_field }}</td>
|
||||
<td class="p-2 border">{{ m.transform || '—' }}</td>
|
||||
<td class="p-2 border">{{ m.apply_mode || 'both' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isCompleted && displayRows.length" class="pt-4">
|
||||
<h3 class="font-semibold mb-2">
|
||||
<template v-if="!isCompleted">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})
|
||||
<span class="ml-2 text-xs text-gray-500">detected: {{ detected.columns.length }}, rows: {{ displayRows.length }}</span>
|
||||
</template>
|
||||
<template v-else>Detected Columns</template>
|
||||
</h3>
|
||||
<p v-if="detectedNote" class="text-xs text-gray-500 mb-2">{{ detectedNote }}</p>
|
||||
<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 displayRows" :key="idx" class="border-t">
|
||||
<td class="p-2 border text-sm">{{ row.source_column }}</td>
|
||||
<td class="p-2 border">
|
||||
<select v-model="row.entity" class="border rounded p-1 w-full" :disabled="isCompleted">
|
||||
<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" :disabled="isCompleted">
|
||||
<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" :disabled="isCompleted">
|
||||
<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" :disabled="isCompleted">
|
||||
<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" :disabled="isCompleted" />
|
||||
</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-else-if="!isCompleted" class="pt-4">
|
||||
<h3 class="font-semibold mb-2">Detected Columns</h3>
|
||||
<p class="text-sm text-gray-600">No columns detected. {{ detectedNote || 'Preview is available for CSV/TXT files. You can still apply a template or use the saved mappings below.' }}</p>
|
||||
</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>
|
||||
|
||||
<div class="pt-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold">Logs</h3>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<label class="text-gray-600">Show</label>
|
||||
<select v-model.number="eventsLimit" class="border rounded p-1" @change="fetchEvents">
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
<option :value="200">200</option>
|
||||
<option :value="500">500</option>
|
||||
</select>
|
||||
<button @click.prevent="fetchEvents" class="px-2 py-1 border rounded text-sm" :disabled="loadingEvents">
|
||||
{{ loadingEvents ? 'Refreshing…' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full border bg-white text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
|
||||
<th class="p-2 border">Time</th>
|
||||
<th class="p-2 border">Level</th>
|
||||
<th class="p-2 border">Event</th>
|
||||
<th class="p-2 border">Message</th>
|
||||
<th class="p-2 border">Row</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ev in events" :key="ev.id" class="border-t">
|
||||
<td class="p-2 border whitespace-nowrap">{{ new Date(ev.created_at).toLocaleString() }}</td>
|
||||
<td class="p-2 border">
|
||||
<span :class="[
|
||||
'px-2 py-0.5 rounded text-xs',
|
||||
ev.level === 'error' ? 'bg-red-100 text-red-800' :
|
||||
ev.level === 'warning' ? 'bg-amber-100 text-amber-800' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
]">{{ ev.level }}</span>
|
||||
</td>
|
||||
<td class="p-2 border">{{ ev.event }}</td>
|
||||
<td class="p-2 border">
|
||||
<div>{{ ev.message }}</div>
|
||||
<div v-if="ev.context" class="text-xs text-gray-500">{{ ev.context }}</div>
|
||||
</td>
|
||||
<td class="p-2 border">{{ ev.import_row_id ?? '—' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!events.length">
|
||||
<td class="p-3 text-center text-gray-500" colspan="5">No events yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
|
||||
const props = defineProps({
|
||||
imports: Object,
|
||||
});
|
||||
|
||||
function statusBadge(status) {
|
||||
const map = {
|
||||
uploaded: 'bg-gray-200 text-gray-700',
|
||||
parsed: 'bg-blue-100 text-blue-800',
|
||||
validating: 'bg-amber-100 text-amber-800',
|
||||
completed: 'bg-emerald-100 text-emerald-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
};
|
||||
return map[status] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Uvozi">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uvozi</h2>
|
||||
<Link :href="route('imports.create')" class="px-3 py-2 rounded bg-blue-600 text-white text-sm">Novi uvoz</Link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-6">
|
||||
<div class="max-w-6xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white shadow sm:rounded-lg p-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs uppercase text-gray-500 border-b">
|
||||
<th class="p-2">Datum</th>
|
||||
<th class="p-2">Datoteka</th>
|
||||
<th class="p-2">Status</th>
|
||||
<th class="p-2">Naročnik</th>
|
||||
<th class="p-2">Predloga</th>
|
||||
<th class="p-2">Akcije</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="imp in imports.data" :key="imp.uuid" class="border-b">
|
||||
<td class="p-2 whitespace-nowrap">{{ new Date(imp.created_at).toLocaleString() }}</td>
|
||||
<td class="p-2">{{ imp.original_name }}</td>
|
||||
<td class="p-2"><span :class="['px-2 py-0.5 rounded text-xs', statusBadge(imp.status)]">{{ imp.status }}</span></td>
|
||||
<td class="p-2">{{ imp.client?.uuid ?? '—' }}</td>
|
||||
<td class="p-2">{{ imp.template?.name ?? '—' }}</td>
|
||||
<td class="p-2 space-x-2">
|
||||
<Link :href="route('imports.continue', { import: imp.uuid })" class="px-2 py-1 rounded bg-gray-200 text-gray-800 text-xs">Poglej</Link>
|
||||
<Link v-if="imp.status !== 'completed'" :href="route('imports.continue', { import: imp.uuid })" class="px-2 py-1 rounded bg-amber-600 text-white text-xs">Nadaljuj</Link>
|
||||
<span v-else class="text-xs text-gray-400">Zaključen</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
|
||||
<div>
|
||||
Prikaz {{ imports.meta.from }}–{{ imports.meta.to }} od {{ imports.meta.total }}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Link v-if="imports.links.prev" :href="imports.links.prev" class="px-2 py-1 border rounded">Nazaj</Link>
|
||||
<Link v-if="imports.links.next" :href="imports.links.next" class="px-2 py-1 border rounded">Naprej</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
|
||||
const props = defineProps({
|
||||
clients: Array,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
description: '',
|
||||
source_type: 'csv',
|
||||
default_record_type: '',
|
||||
is_active: true,
|
||||
client_uuid: null,
|
||||
entities: [],
|
||||
});
|
||||
|
||||
function submit() {
|
||||
form.post(route('importTemplates.store'), {
|
||||
onSuccess: () => {
|
||||
// You can redirect or show a success message here
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Create Import Template">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Create Import Template</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-6">
|
||||
<div class="max-w-3xl 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>
|
||||
<label class="block text-sm font-medium text-gray-700">Client (optional)</label>
|
||||
<Multiselect
|
||||
v-model="form.client_uuid"
|
||||
:options="props.clients || []"
|
||||
:reduce="c => c.uuid"
|
||||
track-by="uuid"
|
||||
label="name"
|
||||
placeholder="Global (no client)"
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Leave empty to make this template global (visible to all clients).</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Entities (tables)</label>
|
||||
<Multiselect
|
||||
v-model="form.entities"
|
||||
:options="[
|
||||
{ value: 'person', label: 'Person' },
|
||||
{ value: 'person_addresses', label: 'Person Addresses' },
|
||||
{ value: 'person_phones', label: 'Person Phones' },
|
||||
{ value: 'emails', label: 'Emails' },
|
||||
{ value: 'accounts', label: 'Accounts' },
|
||||
{ value: 'contracts', label: 'Contracts' },
|
||||
]"
|
||||
:multiple="true"
|
||||
track-by="value"
|
||||
label="label"
|
||||
:reduce="o => o.value"
|
||||
placeholder="Select one or more entities"
|
||||
:searchable="false"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Choose which tables this template targets. You can still define per-column mappings later.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Name</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">Description</label>
|
||||
<textarea v-model="form.description" class="mt-1 block w-full border rounded p-2" rows="3" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Source Type</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">Default Record Type (optional)</label>
|
||||
<input v-model="form.default_record_type" type="text" class="mt-1 block w-full border rounded p-2" placeholder="e.g., account, person" />
|
||||
</div>
|
||||
</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">Active</label>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<button @click.prevent="submit" class="px-4 py-2 bg-emerald-600 text-white rounded" :disabled="form.processing">
|
||||
{{ form.processing ? 'Saving…' : 'Create Template' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="form.errors && Object.keys(form.errors).length" class="text-sm text-red-600">
|
||||
<div v-for="(msg, key) in form.errors" :key="key">{{ msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,495 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useForm, Link } from '@inertiajs/vue3';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
|
||||
const props = defineProps({
|
||||
template: Object,
|
||||
clients: Array,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
name: props.template.name,
|
||||
description: props.template.description,
|
||||
source_type: props.template.source_type,
|
||||
default_record_type: props.template.default_record_type || '',
|
||||
is_active: props.template.is_active,
|
||||
client_uuid: props.template.client_uuid || null,
|
||||
});
|
||||
|
||||
const entities = computed(() => (props.template.meta?.entities || []));
|
||||
const hasMappings = computed(() => (props.template.mappings?.length || 0) > 0);
|
||||
const canChangeClient = computed(() => !hasMappings.value); // guard reassignment when mappings exist (optional rule)
|
||||
|
||||
// Local state for adding a new mapping row per entity accordion
|
||||
const newRows = ref({});
|
||||
const bulkRows = ref({}); // per-entity textarea and options
|
||||
const bulkGlobal = ref({ entity: '', sources: '', default_field: '', transform: '', apply_mode: 'both' });
|
||||
const unassigned = computed(() => (props.template.mappings || []).filter(m => !m.target_field));
|
||||
const unassignedSourceColumns = computed(() => {
|
||||
const set = new Set();
|
||||
for (const m of unassigned.value) {
|
||||
if (m.source_column) set.add(m.source_column);
|
||||
}
|
||||
return Array.from(set).sort((a,b)=>a.localeCompare(b));
|
||||
});
|
||||
const unassignedState = ref({});
|
||||
|
||||
function saveUnassigned(m) {
|
||||
const st = unassignedState.value[m.id] || {};
|
||||
if (st.entity && st.field) {
|
||||
m.target_field = `${st.entity}.${st.field}`;
|
||||
} else {
|
||||
m.target_field = null;
|
||||
}
|
||||
updateMapping(m);
|
||||
}
|
||||
|
||||
const entityOptions = [
|
||||
{ key: 'person', label: 'Person' },
|
||||
{ key: 'person_addresses', label: 'Person Addresses' },
|
||||
{ key: 'person_phones', label: 'Person Phones' },
|
||||
{ key: 'emails', label: 'Emails' },
|
||||
{ key: 'accounts', label: 'Accounts' },
|
||||
{ key: 'contracts', label: 'Contracts' },
|
||||
];
|
||||
|
||||
const fieldOptions = {
|
||||
person: [
|
||||
'first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'
|
||||
],
|
||||
person_addresses: [ 'address', 'country', 'type_id', 'description' ],
|
||||
person_phones: [ 'nu', 'country_code', 'type_id', 'description' ],
|
||||
emails: [ 'email', 'is_primary' ],
|
||||
accounts: [ 'reference', 'balance_amount', 'contract_id', 'contract_reference' ],
|
||||
contracts: [ 'reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id' ],
|
||||
};
|
||||
|
||||
function toggle(entity) {
|
||||
const el = document.getElementById(`acc-${entity}`);
|
||||
if (el) el.open = !el.open;
|
||||
}
|
||||
|
||||
function addRow(entity) {
|
||||
const row = newRows.value[entity];
|
||||
if (!row || !row.source || !row.field) return;
|
||||
const target_field = `${entity}.${row.field}`;
|
||||
const payload = {
|
||||
source_column: row.source,
|
||||
target_field,
|
||||
transform: row.transform || null,
|
||||
apply_mode: row.apply_mode || 'both',
|
||||
position: (props.template.mappings?.length || 0) + 1,
|
||||
};
|
||||
useForm(payload).post(route('importTemplates.mappings.add', { template: props.template.uuid }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { newRows.value[entity] = {}; },
|
||||
});
|
||||
}
|
||||
|
||||
function updateMapping(m) {
|
||||
const payload = {
|
||||
source_column: m.source_column,
|
||||
target_field: m.target_field,
|
||||
transform: m.transform,
|
||||
apply_mode: m.apply_mode,
|
||||
position: m.position,
|
||||
};
|
||||
useForm(payload).put(route('importTemplates.mappings.update', { template: props.template.uuid, mapping: m.id }), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
function deleteMapping(m) {
|
||||
useForm({}).delete(route('importTemplates.mappings.delete', { template: props.template.uuid, mapping: m.id }), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
function reorder(entity, direction, m) {
|
||||
// Build new order across all mappings, swapping positions for this entity scope
|
||||
const all = [...props.template.mappings];
|
||||
const entityMaps = all.filter(x => x.target_field?.startsWith(entity + '.'));
|
||||
const idx = entityMaps.findIndex(x => x.id === m.id);
|
||||
if (idx < 0) return;
|
||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= entityMaps.length) return;
|
||||
const a = entityMaps[idx];
|
||||
const b = entityMaps[swapIdx];
|
||||
// Build final ordered ids list using current order, swapping a/b positions
|
||||
const byId = Object.fromEntries(all.map(x => [x.id, x]));
|
||||
const ordered = all.map(x => x.id);
|
||||
const ai = ordered.indexOf(a.id);
|
||||
const bi = ordered.indexOf(b.id);
|
||||
if (ai < 0 || bi < 0) return;
|
||||
[ordered[ai], ordered[bi]] = [ordered[bi], ordered[ai]];
|
||||
useForm({ order: ordered }).post(route('importTemplates.mappings.reorder', { template: props.template.uuid }), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Save basic
|
||||
const save = () => {
|
||||
const payload = { ...form.data() };
|
||||
if (!canChangeClient.value) {
|
||||
// drop client change when blocked
|
||||
delete payload.client_uuid;
|
||||
}
|
||||
useForm(payload).put(route('importTemplates.update', { template: props.template.uuid }), { preserveScroll: true });
|
||||
};
|
||||
// Non-blocking confirm modal state for delete
|
||||
const deleteConfirmOpen = ref(false);
|
||||
const deleteForm = useForm({});
|
||||
function openDeleteConfirm() { deleteConfirmOpen.value = true; }
|
||||
function cancelDelete() { deleteConfirmOpen.value = false; }
|
||||
function performDelete() {
|
||||
deleteForm.delete(route('importTemplates.destroy', { template: props.template.uuid }), {
|
||||
onFinish: () => { deleteConfirmOpen.value = false; },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :title="`Edit Template: ${props.template.name}`">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uredi uvozno predlogo</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<Link :href="route('importTemplates.index')" class="px-3 py-1.5 border rounded text-sm">Nazaj</Link>
|
||||
<button class="px-3 py-1.5 border rounded text-sm text-red-700 border-red-300 hover:bg-red-50" @click.prevent="openDeleteConfirm">Izbriši predlogo</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-6">
|
||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
|
||||
<!-- Basic info -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Ime predloge</label>
|
||||
<input v-model="form.name" type="text" class="mt-1 block w-full border rounded p-2" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Vir</label>
|
||||
<select v-model="form.source_type" class="mt-1 block w-full border rounded p-2">
|
||||
<option value="csv">CSV</option>
|
||||
<option value="xml">XML</option>
|
||||
<option value="xls">XLS</option>
|
||||
<option value="xlsx">XLSX</option>
|
||||
<option value="json">JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Privzeti tip zapisa</label>
|
||||
<input v-model="form.default_record_type" type="text" class="mt-1 block w-full border rounded p-2" placeholder="npr.: account, person" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Naročnik (opcijsko)</label>
|
||||
<Multiselect
|
||||
v-model="form.client_uuid"
|
||||
:options="props.clients || []"
|
||||
:reduce="c => c.uuid"
|
||||
track-by="uuid"
|
||||
label="name"
|
||||
placeholder="Global (brez naročnika)"
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
:disabled="!canChangeClient"
|
||||
/>
|
||||
<p v-if="!canChangeClient" class="text-xs text-amber-600 mt-1">Ni mogoče spremeniti naročnika, ker ta predloga že vsebuje preslikave.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="is_active" v-model="form.is_active" type="checkbox" class="rounded" />
|
||||
<label for="is_active" class="text-sm font-medium text-gray-700">Aktivna</label>
|
||||
<button @click.prevent="save" class="ml-auto px-3 py-2 bg-indigo-600 text-white rounded">Shrani</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sample headers viewer/editor -->
|
||||
<div class="p-3 bg-gray-50 rounded border">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-sm font-medium text-gray-700">Vzorčni glavi stolpcev</div>
|
||||
<button class="text-xs px-2 py-1 border rounded" @click.prevent="form.sample_headers = (form.sample_headers || []).concat([''])">Dodaj stolpec</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div v-for="(h, i) in (form.sample_headers = form.sample_headers ?? props.template.sample_headers ?? [])" :key="i" class="flex items-center gap-2">
|
||||
<input v-model="form.sample_headers[i]" type="text" class="flex-1 border rounded p-2" placeholder="npr.: reference" />
|
||||
<button class="px-2 py-1 border rounded" @click.prevent="form.sample_headers.splice(i,1)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Bulk Add Mappings -->
|
||||
<div class="p-3 bg-indigo-50 rounded border border-indigo-200">
|
||||
<div class="mb-2 text-sm font-medium text-indigo-900">Bulk dodajanje preslikav</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-6 gap-3 items-start">
|
||||
<div class="sm:col-span-3">
|
||||
<label class="block text-xs text-indigo-900">Source columns (CSV ali po vrsticah)</label>
|
||||
<textarea v-model="bulkGlobal.sources" rows="3" class="mt-1 w-full border rounded p-2" placeholder="npr.: reference,first name,last name,amount"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-indigo-900">Entity (opcijsko)</label>
|
||||
<select v-model="bulkGlobal.entity" class="mt-1 w-full border rounded p-2">
|
||||
<option value="">(brez – pusti target prazno)</option>
|
||||
<option v-for="opt in entityOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-indigo-900">Field (opcijsko, za vse)</label>
|
||||
<select v-model="bulkGlobal.default_field" class="mt-1 w-full border rounded p-2" :disabled="!bulkGlobal.entity">
|
||||
<option value="">(auto from source)</option>
|
||||
<option v-for="f in (fieldOptions[bulkGlobal.entity] || [])" :key="f" :value="f">{{ f }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-indigo-900">Transform (za vse)</label>
|
||||
<select v-model="bulkGlobal.transform" class="mt-1 w-full border rounded p-2">
|
||||
<option value="">None</option>
|
||||
<option value="trim">trim</option>
|
||||
<option value="upper">upper</option>
|
||||
<option value="lower">lower</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-indigo-900">Apply (za vse)</label>
|
||||
<select v-model="bulkGlobal.apply_mode" class="mt-1 w-full border rounded p-2">
|
||||
<option value="both">both</option>
|
||||
<option value="insert">insert</option>
|
||||
<option value="update">update</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button
|
||||
@click.prevent="(() => {
|
||||
if (!bulkGlobal.sources || !bulkGlobal.sources.trim()) return;
|
||||
useForm({
|
||||
sources: bulkGlobal.sources,
|
||||
entity: bulkGlobal.entity || null,
|
||||
default_field: bulkGlobal.default_field || null,
|
||||
transform: bulkGlobal.transform || null,
|
||||
apply_mode: bulkGlobal.apply_mode || 'both',
|
||||
}).post(route('importTemplates.mappings.bulk', { template: props.template.uuid }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { bulkGlobal.entity=''; bulkGlobal.sources=''; bulkGlobal.default_field=''; bulkGlobal.transform=''; bulkGlobal.apply_mode='both'; },
|
||||
});
|
||||
})()"
|
||||
class="px-3 py-2 bg-indigo-600 text-white rounded"
|
||||
>Dodaj več</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unassigned mappings (no target_field) -->
|
||||
<div v-if="unassigned.length" class="p-3 bg-amber-50 rounded border border-amber-200">
|
||||
<div class="mb-2 text-sm font-medium text-amber-900">Nedodeljene preslikave ({{ unassigned.length }})</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="m in unassigned" :key="m.id" class="p-2 bg-white/60 border rounded">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-6 gap-2 items-center">
|
||||
<div class="text-sm">
|
||||
<div class="text-gray-500 text-xs">Source</div>
|
||||
<div class="font-medium">{{ m.source_column }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600">Entity</label>
|
||||
<select v-model="(unassignedState[m.id] ||= {}).entity" class="mt-1 w-full border rounded p-2">
|
||||
<option value="">(izberi)</option>
|
||||
<option v-for="opt in entityOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600">Field</label>
|
||||
<select v-model="(unassignedState[m.id] ||= {}).field" class="mt-1 w-full border rounded p-2" :disabled="!(unassignedState[m.id]||{}).entity">
|
||||
<option value="">(izberi)</option>
|
||||
<option v-for="f in (fieldOptions[(unassignedState[m.id]||{}).entity] || [])" :key="f" :value="f">{{ f }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600">Transform</label>
|
||||
<select v-model="m.transform" class="mt-1 w-full border rounded p-2">
|
||||
<option value="">None</option>
|
||||
<option value="trim">trim</option>
|
||||
<option value="upper">upper</option>
|
||||
<option value="lower">lower</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600">Apply</label>
|
||||
<select v-model="m.apply_mode" class="mt-1 w-full border rounded p-2">
|
||||
<option value="both">both</option>
|
||||
<option value="insert">insert</option>
|
||||
<option value="update">update</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm" @click.prevent="saveUnassigned(m)">Shrani</button>
|
||||
<button class="px-3 py-1.5 bg-red-600 text-white rounded text-sm" @click.prevent="deleteMapping(m)">Izbriši</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entities accordion -->
|
||||
<div class="divide-y">
|
||||
<details v-for="entity in entities" :key="entity" :id="`acc-${entity}`" class="py-3">
|
||||
<summary class="cursor-pointer select-none flex items-center justify-between">
|
||||
<span class="font-medium">{{ entityOptions.find(e=>e.key===entity)?.label || entity }}</span>
|
||||
<span class="text-xs text-gray-500">Klikni za razširitev</span>
|
||||
</summary>
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- Existing mappings for this entity -->
|
||||
<div v-if="props.template.mappings && props.template.mappings.length" class="space-y-2">
|
||||
<div v-for="m in props.template.mappings.filter(m=>m.target_field?.startsWith(entity + '.'))" :key="m.id" class="flex items-center justify-between p-2 border rounded gap-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 flex-1 items-center">
|
||||
<input v-model="m.source_column" class="border rounded p-2 text-sm" />
|
||||
<input v-model="m.target_field" class="border rounded p-2 text-sm" />
|
||||
<select v-model="m.transform" class="border rounded p-2 text-sm">
|
||||
<option value="">None</option>
|
||||
<option value="trim">trim</option>
|
||||
<option value="upper">upper</option>
|
||||
<option value="lower">lower</option>
|
||||
</select>
|
||||
<select v-model="m.apply_mode" class="border rounded p-2 text-sm">
|
||||
<option value="both">both</option>
|
||||
<option value="insert">insert</option>
|
||||
<option value="update">update</option>
|
||||
</select>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="px-2 py-1 text-xs border rounded" @click.prevent="reorder(entity, 'up', m)">▲</button>
|
||||
<button class="px-2 py-1 text-xs border rounded" @click.prevent="reorder(entity, 'down', m)">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm" @click.prevent="updateMapping(m)">Shrani</button>
|
||||
<button class="px-3 py-1.5 bg-red-600 text-white rounded text-sm" @click.prevent="deleteMapping(m)">Izbriši</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-500">Ni definiranih preslikav za to entiteto.</div>
|
||||
|
||||
<!-- Add new mapping row -->
|
||||
<div class="p-3 bg-gray-50 rounded border">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 items-end">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600">Source column (ne-dodeljene)</label>
|
||||
<select v-model="(newRows[entity] ||= {}).source" class="mt-1 w-full border rounded p-2">
|
||||
<option value="" disabled>(izberi)</option>
|
||||
<option v-for="s in unassignedSourceColumns" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
<p v-if="!unassignedSourceColumns.length" class="text-xs text-gray-500 mt-1">Ni nedodeljenih virov. Uporabi Bulk ali najprej dodaj vire.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600">Field</label>
|
||||
<select v-model="(newRows[entity] ||= {}).field" class="mt-1 w-full border rounded p-2">
|
||||
<option v-for="f in (fieldOptions[entity] || [])" :key="f" :value="f">{{ f }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600">Transform</label>
|
||||
<select v-model="(newRows[entity] ||= {}).transform" class="mt-1 w-full border rounded p-2">
|
||||
<option value="">None</option>
|
||||
<option value="trim">trim</option>
|
||||
<option value="upper">upper</option>
|
||||
<option value="lower">lower</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600">Apply</label>
|
||||
<select v-model="(newRows[entity] ||= {}).apply_mode" class="mt-1 w-full border rounded p-2">
|
||||
<option value="both">both</option>
|
||||
<option value="insert">insert</option>
|
||||
<option value="update">update</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<button @click.prevent="addRow(entity)" class="w-full sm:w-auto px-3 py-2 bg-emerald-600 text-white rounded">Dodaj preslikavo</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk add mapping rows -->
|
||||
<div class="p-3 bg-gray-50 rounded border">
|
||||
<div class="mb-2 text-xs text-gray-600">Dodaj več stolpcev naenkrat (ločeno z vejicami ali novimi vrsticami). Če polje ne izbereš, bo target nastavljen na entity + ime stolpca.</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 items-start">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs text-gray-600">Source columns (CSV ali po vrsticah)</label>
|
||||
<textarea v-model="(bulkRows[entity] ||= {}).sources" rows="3" class="mt-1 w-full border rounded p-2" placeholder="npr.: reference,first name,last name"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600">Field (opcijsko, za vse)</label>
|
||||
<select v-model="(bulkRows[entity] ||= {}).default_field" class="mt-1 w-full border rounded p-2">
|
||||
<option value="">(auto from source)</option>
|
||||
<option v-for="f in (fieldOptions[entity] || [])" :key="f" :value="f">{{ f }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600">Transform (za vse)</label>
|
||||
<select v-model="(bulkRows[entity] ||= {}).transform" class="mt-1 w-full border rounded p-2">
|
||||
<option value="">None</option>
|
||||
<option value="trim">trim</option>
|
||||
<option value="upper">upper</option>
|
||||
<option value="lower">lower</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600">Apply (za vse)</label>
|
||||
<select v-model="(bulkRows[entity] ||= {}).apply_mode" class="mt-1 w-full border rounded p-2">
|
||||
<option value="both">both</option>
|
||||
<option value="insert">insert</option>
|
||||
<option value="update">update</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button
|
||||
@click.prevent="(() => {
|
||||
const b = bulkRows[entity] ||= {};
|
||||
if (!b.sources || !b.sources.trim()) return;
|
||||
useForm({
|
||||
sources: b.sources,
|
||||
entity,
|
||||
default_field: b.default_field || null,
|
||||
transform: b.transform || null,
|
||||
apply_mode: b.apply_mode || 'both',
|
||||
}).post(route('importTemplates.mappings.bulk', { template: props.template.uuid }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { bulkRows[entity] = {}; },
|
||||
});
|
||||
})()"
|
||||
class="px-3 py-2 bg-indigo-600 text-white rounded"
|
||||
>Dodaj več</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="deleteConfirmOpen" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click="cancelDelete"></div>
|
||||
<div class="relative bg-white rounded shadow-lg w-96 max-w-[90%] p-5">
|
||||
<div class="text-lg font-semibold mb-2">Izbrišem predlogo?</div>
|
||||
<p class="text-sm text-gray-600 mb-4">Tega dejanja ni mogoče razveljaviti. Vse preslikave te predloge bodo izbrisane.</p>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="px-3 py-1.5 border rounded" @click.prevent="cancelDelete" :disabled="deleteForm.processing">Prekliči</button>
|
||||
<button class="px-3 py-1.5 rounded text-white bg-red-600 disabled:opacity-60" @click.prevent="performDelete" :disabled="deleteForm.processing">
|
||||
<span v-if="deleteForm.processing">Brisanje…</span>
|
||||
<span v-else>Izbriši</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity .2s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
<!-- moved modal into main template to avoid multiple <template> blocks -->
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { Link, useForm } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
|
||||
// Non-blocking confirm modal state
|
||||
const confirmOpen = ref(false);
|
||||
const confirmUuid = ref(null);
|
||||
const deleteForm = useForm({});
|
||||
|
||||
function requestDelete(uuid) {
|
||||
confirmUuid.value = uuid;
|
||||
confirmOpen.value = true;
|
||||
}
|
||||
|
||||
function performDelete() {
|
||||
if (!confirmUuid.value) return;
|
||||
deleteForm.delete(route('importTemplates.destroy', { template: confirmUuid.value }), {
|
||||
preserveScroll: true,
|
||||
onFinish: () => {
|
||||
confirmOpen.value = false;
|
||||
confirmUuid.value = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
confirmOpen.value = false;
|
||||
confirmUuid.value = null;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
templates: Array,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Uvozne predloge">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uvozne predloge</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">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-sm text-gray-600">Skupaj: {{ props.templates?.length || 0 }}</div>
|
||||
<Link :href="route('importTemplates.create')" class="px-3 py-2 bg-emerald-600 text-white rounded">Nova predloga</Link>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
<div v-for="t in props.templates" :key="t.uuid" class="py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium">{{ t.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ t.description }}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">{{ t.client?.name || 'Global' }} • {{ t.source_type.toUpperCase() }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="['text-xs px-2 py-0.5 rounded', t.is_active ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500']">{{ t.is_active ? 'Active' : 'Inactive' }}</span>
|
||||
<Link :href="route('importTemplates.edit', { template: t.uuid })" class="px-3 py-1.5 border rounded text-sm">Uredi</Link>
|
||||
<button
|
||||
class="px-3 py-1.5 border rounded text-sm text-red-700 border-red-300 hover:bg-red-50"
|
||||
@click.prevent="requestDelete(t.uuid)"
|
||||
>Izbriši</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Delete Modal -->
|
||||
<div v-if="confirmOpen" 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>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { FwbTable, FwbTableBody, FwbTableHead, FwbTableHeadCell, FwbTableCell, FwbTableRow, FwbDropdown } from 'flowbite-vue';
|
||||
import { DottedMenu, EditIcon, TrashBinIcon } from '@/Utilities/Icons';
|
||||
import Drawer from '@/Components/Drawer.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
@@ -134,7 +134,7 @@ const store = () => {
|
||||
</fwb-table-row>
|
||||
</fwb-table-body>
|
||||
</fwb-table>
|
||||
<Drawer
|
||||
<DialogModal
|
||||
:show="drawerEdit"
|
||||
@close="closeEditDrawer"
|
||||
>
|
||||
@@ -211,9 +211,9 @@ const store = () => {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</Drawer>
|
||||
</DialogModal>
|
||||
|
||||
<Drawer
|
||||
<DialogModal
|
||||
:show="drawerCreate"
|
||||
@close="closeCreateDrawer"
|
||||
>
|
||||
@@ -285,5 +285,5 @@ const store = () => {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</Drawer>
|
||||
</DialogModal>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { FwbTable, FwbTableBody, FwbTableHead, FwbTableHeadCell, FwbTableCell, FwbTableRow } from 'flowbite-vue';
|
||||
import { EditIcon } from '@/Utilities/Icons';
|
||||
import Drawer from '@/Components/Drawer.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
@@ -116,7 +116,7 @@ const store = () => {
|
||||
</fwb-table-body>
|
||||
</fwb-table>
|
||||
|
||||
<Drawer
|
||||
<DialogModal
|
||||
:show="drawerEdit"
|
||||
@close="closeEditDrawer"
|
||||
>
|
||||
@@ -174,9 +174,9 @@ const store = () => {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</Drawer>
|
||||
</DialogModal>
|
||||
|
||||
<Drawer
|
||||
<DialogModal
|
||||
:show="drawerCreate"
|
||||
@close="closeCreateDrawer"
|
||||
>
|
||||
@@ -234,5 +234,5 @@ const store = () => {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</Drawer>
|
||||
</DialogModal>
|
||||
</template>
|
||||
Reference in New Issue
Block a user