537 lines
20 KiB
Vue
537 lines
20 KiB
Vue
<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 (form.import_template_id !== null && form.import_template_id !== undefined && String(form.import_template_id).trim() !== '') {
|
|
fd.append('import_template_id', String(form.import_template_id));
|
|
}
|
|
if (form.client_uuid) {
|
|
fd.append('client_uuid', String(form.client_uuid));
|
|
}
|
|
fd.append('has_header', hasHeader.value ? '1' : '0');
|
|
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> |