Files
CRM_Montzar/apps/web/src/components/ExcelImportFlow.vue
T
2026-09-29 14:57:10 +03:30

229 lines
5.8 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { ExcelPreviewResult, ImportContactsResult } from '@mento/shared';
import { UiButton, UiModal, UiSelect } from '@/components/ui';
const props = defineProps<{
previewPath: string;
importPath: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
start: [];
done: [result: ImportContactsResult];
fail: [message: string];
settled: [];
}>();
const input = ref<HTMLInputElement | null>(null);
const pending = ref<File | null>(null);
const preview = ref<ExcelPreviewResult | null>(null);
const mapping = reactive<Record<string, string>>({});
const submitting = ref(false);
const requiredKeys = computed(
() => preview.value?.fields.filter((f) => f.required).map((f) => f.key) ?? [],
);
const mappedFields = computed(() => new Set(Object.values(mapping).filter(Boolean)));
const canImport = computed(() => requiredKeys.value.every((key) => mappedFields.value.has(key)));
function pick() {
if (props.disabled || submitting.value) return;
input.value?.click();
}
function reset() {
pending.value = null;
preview.value = null;
submitting.value = false;
for (const key of Object.keys(mapping)) delete mapping[key];
}
function closeMapper() {
reset();
emit('settled');
}
function setColumnField(header: string, field: string) {
if (field) {
for (const [col, current] of Object.entries(mapping)) {
if (col !== header && current === field) mapping[col] = '';
}
}
mapping[header] = field;
}
async function postImport(file: File, map?: Record<string, string>) {
const form = new FormData();
form.append('file', file);
if (map && Object.values(map).some(Boolean)) {
form.append('mapping', JSON.stringify(map));
}
const { data } = await api.post<ImportContactsResult | Record<string, unknown>>(
props.importPath,
form,
{
headers: { 'Content-Type': undefined },
},
);
return data as ImportContactsResult;
}
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function onFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
emit('start');
submitting.value = true;
try {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<ExcelPreviewResult>(props.previewPath, form, {
headers: { 'Content-Type': undefined },
});
if (!data.needsMapping) {
emit('done', await postImport(file));
reset();
emit('settled');
return;
}
pending.value = file;
preview.value = data;
for (const col of data.columns) {
mapping[col.header] = data.suggested[col.header] ?? '';
}
} catch (e) {
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
reset();
emit('settled');
} finally {
submitting.value = false;
}
}
async function confirmMapping() {
if (!pending.value || !canImport.value) return;
submitting.value = true;
try {
emit('done', await postImport(pending.value, { ...mapping }));
reset();
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'ایمپورت ناموفق بود'));
submitting.value = false;
}
}
</script>
<template>
<span class="excel-import" @click.stop="pick">
<slot />
<input
ref="input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onFile"
@click.stop
/>
</span>
<UiModal :open="!!preview" title="تطبیق ستون‌های اکسل" @close="closeMapper">
<p class="lead">
ستون‌های این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
</p>
<div class="rows">
<div v-for="col in preview?.columns ?? []" :key="col.header" class="row">
<div class="source">
<strong>{{ col.header }}</strong>
<small v-if="col.samples.length">نمونه: {{ col.samples.join('، ') }}</small>
</div>
<UiSelect
:model-value="mapping[col.header] ?? ''"
@update:model-value="setColumnField(col.header, $event)"
>
<option value="">نادیده بگیر</option>
<option v-for="field in preview?.fields ?? []" :key="field.key" :value="field.key">
{{ field.label }}{{ field.required ? ' *' : '' }}
</option>
</UiSelect>
</div>
</div>
<p v-if="!canImport" class="hint">فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.</p>
<template #footer>
<UiButton variant="ghost" :disabled="submitting" @click="closeMapper">انصراف</UiButton>
<UiButton :disabled="submitting || !canImport" @click="confirmMapping">
{{ submitting ? 'در حال ورود…' : 'ورود فایل' }}
</UiButton>
</template>
</UiModal>
</template>
<style scoped>
.excel-import {
display: inline-flex;
}
.lead {
margin: 0 0 1rem;
color: var(--muted);
line-height: 1.7;
}
.rows {
display: grid;
gap: 0.75rem;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 0.75rem;
align-items: center;
}
.source {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.source strong {
font-size: 0.95rem;
}
.source small {
color: var(--muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
margin: 0.85rem 0 0;
color: var(--danger);
font-size: 0.85rem;
}
@media (max-width: 700px) {
.row {
grid-template-columns: 1fr;
}
}
</style>