1097 lines
37 KiB
Vue
1097 lines
37 KiB
Vue
<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,
|
|
client: Object,
|
|
});
|
|
|
|
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(false);
|
|
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("");
|
|
// Delimiter selection (auto by default, can be overridden by template or user)
|
|
const delimiterState = ref({ mode: "auto", custom: "" });
|
|
const effectiveDelimiter = computed(() => {
|
|
switch (delimiterState.value.mode) {
|
|
case "auto":
|
|
return null; // let backend detect
|
|
case "comma":
|
|
return ",";
|
|
case "semicolon":
|
|
return ";";
|
|
case "tab":
|
|
return "\t";
|
|
case "pipe":
|
|
return "|";
|
|
case "space":
|
|
return " ";
|
|
case "custom":
|
|
return delimiterState.value.custom || null;
|
|
default:
|
|
return null;
|
|
}
|
|
});
|
|
// Initialize delimiter from import meta if previously chosen
|
|
const initForced = props.import?.meta?.forced_delimiter || null;
|
|
if (initForced) {
|
|
const map = { ",": "comma", ";": "semicolon", "\t": "tab", "|": "pipe", " ": "space" };
|
|
const mode = map[initForced] || "custom";
|
|
delimiterState.value.mode = mode;
|
|
if (mode === "custom") delimiterState.value.custom = initForced;
|
|
}
|
|
// 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
|
|
);
|
|
|
|
// Dynamic entity definitions and options fetched from API
|
|
const entityDefs = ref([]); // [{ key, label, canonical_root, fields: [] }]
|
|
const usingEntityFallback = ref(false);
|
|
const entityOptions = computed(() =>
|
|
entityDefs.value.map((e) => ({ value: e.key, label: e.label || e.key }))
|
|
);
|
|
const fieldOptionsByEntity = computed(() =>
|
|
Object.fromEntries(
|
|
entityDefs.value.map((e) => [e.key, Array.isArray(e.fields) ? e.fields : []])
|
|
)
|
|
);
|
|
const canonicalRootByKey = computed(() =>
|
|
Object.fromEntries(entityDefs.value.map((e) => [e.key, e.canonical_root || e.key]))
|
|
);
|
|
const keyByCanonicalRoot = computed(() => {
|
|
const m = {};
|
|
for (const e of entityDefs.value) {
|
|
if (e.canonical_root) {
|
|
m[e.canonical_root] = e.key;
|
|
}
|
|
}
|
|
return m;
|
|
});
|
|
|
|
// Provide fields for either a UI key (e.g. "contracts") or a canonical root (e.g. "contract")
|
|
function fieldsForEntity(entityOrCanonical) {
|
|
if (!entityOrCanonical) return [];
|
|
// Try direct key first
|
|
const direct = fieldOptionsByEntity.value[entityOrCanonical];
|
|
if (Array.isArray(direct)) return direct;
|
|
// If a canonical root was stored before entityDefs loaded, map it now
|
|
const byKey = keyByCanonicalRoot.value[entityOrCanonical];
|
|
if (byKey) {
|
|
const via = fieldOptionsByEntity.value[byKey];
|
|
if (Array.isArray(via)) return via;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function defaultEntityDefs() {
|
|
return [
|
|
{
|
|
key: "person",
|
|
label: "Person",
|
|
canonical_root: "person",
|
|
fields: [
|
|
"first_name",
|
|
"last_name",
|
|
"full_name",
|
|
"gender",
|
|
"birthday",
|
|
"tax_number",
|
|
"social_security_number",
|
|
"description",
|
|
],
|
|
},
|
|
{
|
|
key: "person_addresses",
|
|
label: "Person Addresses",
|
|
canonical_root: "address",
|
|
fields: ["address", "country", "type_id", "description"],
|
|
},
|
|
{
|
|
key: "person_phones",
|
|
label: "Person Phones",
|
|
canonical_root: "phone",
|
|
fields: ["nu", "country_code", "type_id", "description"],
|
|
},
|
|
{
|
|
key: "emails",
|
|
label: "Emails",
|
|
canonical_root: "email",
|
|
fields: ["value", "is_primary", "label"],
|
|
},
|
|
{
|
|
key: "contracts",
|
|
label: "Contracts",
|
|
canonical_root: "contract",
|
|
fields: [
|
|
"reference",
|
|
"start_date",
|
|
"end_date",
|
|
"description",
|
|
"type_id",
|
|
"client_case_id",
|
|
],
|
|
},
|
|
{
|
|
key: "accounts",
|
|
label: "Accounts",
|
|
canonical_root: "account",
|
|
fields: [
|
|
"reference",
|
|
"balance_amount",
|
|
"contract_id",
|
|
"contract_reference",
|
|
"type_id",
|
|
"active",
|
|
"description",
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
async function loadEntityDefs() {
|
|
try {
|
|
const { data } = await axios.get("/api/import-entities");
|
|
const items = Array.isArray(data?.entities) ? data.entities : [];
|
|
// Normalize fields to arrays and ensure labels
|
|
const normalized = items.map((e) => ({
|
|
key: e.key,
|
|
label: e.label || e.key,
|
|
canonical_root: e.canonical_root || e.key,
|
|
fields: Array.isArray(e.fields) ? e.fields : [],
|
|
}));
|
|
if (!normalized.length) {
|
|
usingEntityFallback.value = true;
|
|
entityDefs.value = defaultEntityDefs();
|
|
} else {
|
|
usingEntityFallback.value = false;
|
|
entityDefs.value = normalized;
|
|
}
|
|
// Normalize any existing mapping row entity values to UI keys if they are canonical roots
|
|
if (Array.isArray(mappingRows.value) && mappingRows.value.length) {
|
|
const mapCanonToKey = keyByCanonicalRoot.value;
|
|
mappingRows.value = mappingRows.value.map((r) => {
|
|
const current = r.entity;
|
|
if (current && !entityDefs.value.find((e) => e.key === current)) {
|
|
const maybeKey = mapCanonToKey[current];
|
|
if (maybeKey) {
|
|
return { ...r, entity: maybeKey };
|
|
}
|
|
}
|
|
return r;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load import entity definitions", e);
|
|
usingEntityFallback.value = true;
|
|
entityDefs.value = defaultEntityDefs();
|
|
// Also normalize with fallback
|
|
if (Array.isArray(mappingRows.value) && mappingRows.value.length) {
|
|
const mapCanonToKey = keyByCanonicalRoot.value;
|
|
mappingRows.value = mappingRows.value.map((r) => {
|
|
const current = r.entity;
|
|
if (current && !entityDefs.value.find((e) => e.key === current)) {
|
|
const maybeKey = mapCanonToKey[current];
|
|
if (maybeKey) {
|
|
return { ...r, entity: maybeKey };
|
|
}
|
|
}
|
|
return r;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) => ({
|
|
source_column: c,
|
|
entity: "",
|
|
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, "");
|
|
}
|
|
|
|
// Frontend auto-guessing disabled: entity/field will remain blank unless loaded from persisted mappings or set by the user
|
|
function guessMappingForHeader(h) {
|
|
return 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 are provided dynamically by API via entityOptions and fieldOptionsByEntity
|
|
|
|
// Local state for selects
|
|
const form = ref({
|
|
client_uuid: props.client.uuid,
|
|
import_template_id: props.import?.import_template_id || null,
|
|
});
|
|
|
|
// Initialize client_uuid from import.client_uuid (preferred) using provided clients list
|
|
if (props.import?.client_uuid) {
|
|
const found = (props.clients || []).find((c) => c.uuid === props.import.client_uuid);
|
|
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 params = { has_header: hasHeader.value ? 1 : 0 };
|
|
if (effectiveDelimiter.value) {
|
|
params.delimiter = effectiveDelimiter.value;
|
|
}
|
|
const { data } = await axios.get(url, { params });
|
|
// 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,
|
|
};
|
|
|
|
console.table(colsRaw, normCols);
|
|
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) => ({
|
|
source_column: c,
|
|
entity: "",
|
|
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 {
|
|
if (templateApplied.value) {
|
|
const ok = window.confirm(
|
|
'Re-apply this template? This will overwrite current mappings for this import.'
|
|
);
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
}
|
|
await axios.post(
|
|
route("importTemplates.apply", {
|
|
template: form.value.import_template_id,
|
|
import: importId.value,
|
|
}),
|
|
{},
|
|
{
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
}
|
|
);
|
|
templateApplied.value = true;
|
|
// If template has a default delimiter, adopt it and refetch columns
|
|
const tpl = selectedTemplateOption.value;
|
|
const tplDelim = tpl?.delimiter || tpl?.meta?.delimiter || null;
|
|
if (tplDelim) {
|
|
// map to known mode if possible, else set custom
|
|
const map = {
|
|
",": "comma",
|
|
";": "semicolon",
|
|
"\t": "tab",
|
|
"|": "pipe",
|
|
" ": "space",
|
|
};
|
|
const mode = map[tplDelim] || "custom";
|
|
delimiterState.value.mode = mode;
|
|
if (mode === "custom") delimiterState.value.custom = tplDelim;
|
|
await fetchColumns();
|
|
}
|
|
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,
|
|
}
|
|
);
|
|
console.log(data);
|
|
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 using canonical roots from API
|
|
function entityKeyToRecord(key) {
|
|
return canonicalRootByKey.value[key] || key;
|
|
}
|
|
function recordToEntityKey(record) {
|
|
return keyByCanonicalRoot.value[record] || record;
|
|
}
|
|
|
|
// Initial load
|
|
onMounted(async () => {
|
|
await loadEntityDefs();
|
|
// Build mapping grid from existing meta columns if present
|
|
if (detected.value.columns?.length) {
|
|
mappingRows.value = (detected.value.columns || [])
|
|
// keep only defined, non-empty values (after trimming)
|
|
.filter((c) => c !== undefined && c !== null && String(c).trim() !== "")
|
|
.map((c, idx) => {
|
|
return {
|
|
source_column: c,
|
|
entity: "",
|
|
field: "",
|
|
skip: false,
|
|
transform: "trim",
|
|
apply_mode: "both",
|
|
position: idx,
|
|
};
|
|
});
|
|
|
|
await loadImportMappings();
|
|
console.log(mappingRows.value);
|
|
} else {
|
|
await fetchColumns();
|
|
}
|
|
// Auto-apply template mapping once if a template is selected and not already applied
|
|
console.log(templateApplied.value);
|
|
try {
|
|
if (!templateApplied.value && form.value.import_template_id && importId.value) {
|
|
await applyTemplateToImport();
|
|
}
|
|
} catch (e) {
|
|
console.warn('Auto apply template failed', e);
|
|
}
|
|
// 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) => {
|
|
return {
|
|
source_column: c,
|
|
entity: "",
|
|
field: "",
|
|
skip: false,
|
|
transform: "trim",
|
|
apply_mode: "both",
|
|
position: idx,
|
|
};
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// If user changes delimiter selection, refresh detected columns
|
|
watch(
|
|
() => delimiterState.value,
|
|
async () => {
|
|
if (importId.value) {
|
|
await fetchColumns();
|
|
}
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
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
|
|
v-if="templateApplied"
|
|
class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 align-middle"
|
|
>applied</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="false"
|
|
>
|
|
<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>
|
|
|
|
<!-- Parsing options -->
|
|
<div
|
|
class="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end"
|
|
v-if="!isCompleted"
|
|
>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700">Header row</label>
|
|
<select
|
|
v-model="hasHeader"
|
|
class="mt-1 block w-full border rounded p-2"
|
|
@change="fetchColumns"
|
|
>
|
|
<option :value="true">Has header</option>
|
|
<option :value="false">No header (positional)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700">Delimiter</label>
|
|
<select
|
|
v-model="delimiterState.mode"
|
|
class="mt-1 block w-full border rounded p-2"
|
|
>
|
|
<option value="auto">Auto-detect</option>
|
|
<option value="comma">Comma ,</option>
|
|
<option value="semicolon">Semicolon ;</option>
|
|
<option value="tab">Tab \t</option>
|
|
<option value="pipe">Pipe |</option>
|
|
<option value="space">Space ␠</option>
|
|
<option value="custom">Custom…</option>
|
|
</select>
|
|
<p class="text-xs text-gray-500 mt-1">
|
|
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
|
|
</p>
|
|
</div>
|
|
<div v-if="delimiterState.mode === 'custom'">
|
|
<label class="block text-sm font-medium text-gray-700"
|
|
>Custom delimiter</label
|
|
>
|
|
<input
|
|
v-model="delimiterState.custom"
|
|
maxlength="4"
|
|
placeholder=","
|
|
class="mt-1 block w-full border rounded p-2"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3" v-if="!isCompleted">
|
|
<button
|
|
@click.prevent="applyTemplateToImport"
|
|
:disabled="!importId || !form.import_template_id"
|
|
class="px-4 py-2 bg-emerald-600 disabled:bg-gray-300 text-white rounded"
|
|
>
|
|
{{ templateApplied ? 'Re-apply Template' : '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 }}, delimiter:
|
|
{{ detected.delimiter || "auto" }}</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 fieldsForEntity(row.entity)"
|
|
:key="f"
|
|
:value="f"
|
|
>
|
|
{{ f }}
|
|
</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>
|