Files
CRM_Montzar/apps/web/src/components/ExcelImportFlow.vue
T

334 lines
9.2 KiB
Vue
Raw Normal View History

2026-09-19 15:30:31 +03:30
<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 { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
2026-09-19 15:30:31 +03:30
const props = defineProps<{
previewPath: string;
importPath: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
start: [];
2026-09-29 14:57:10 +03:30
done: [result: ImportContactsResult];
fail: [message: string];
2026-09-19 15:30:31 +03:30
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 phase = ref<'idle' | 'preview' | 'import'>('idle');
const progressLabel = ref('');
2026-09-19 15:30:31 +03:30
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)));
const busyOpen = computed(() => submitting.value && phase.value !== 'idle');
const busyMessage = computed(() => {
if (phase.value === 'import') {
return progressLabel.value || 'در حال ورود اطلاعات از اکسل…';
}
return 'در حال خواندن و بررسی فایل اکسل…';
});
2026-09-19 15:30:31 +03:30
function pick() {
if (props.disabled || submitting.value) return;
input.value?.click();
}
function reset() {
pending.value = null;
preview.value = null;
submitting.value = false;
phase.value = 'idle';
progressLabel.value = '';
2026-09-19 15:30:31 +03:30
for (const key of Object.keys(mapping)) delete mapping[key];
}
function closeMapper() {
if (submitting.value) return;
2026-09-19 15:30:31 +03:30
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;
}
function mergeImportResults(
acc: ImportContactsResult,
part: ImportContactsResult,
): ImportContactsResult {
const errors = [...acc.errors, ...(part.errors ?? [])];
return {
created: acc.created + (part.created ?? 0),
skipped: acc.skipped + (part.skipped ?? 0),
errors: errors.slice(0, 200),
boardId: part.boardId ?? acc.boardId,
boards: part.boards?.length ? part.boards : acc.boards,
sessionId: part.sessionId,
total: part.total ?? acc.total,
processed: part.processed ?? acc.processed,
hasMore: part.hasMore,
nextOffset: part.nextOffset,
};
}
async function postImportChunk(opts: {
file?: File;
map?: Record<string, string>;
sessionId?: string;
offset?: number;
}) {
2026-09-19 15:30:31 +03:30
const form = new FormData();
if (opts.file) form.append('file', opts.file);
if (opts.map && Object.values(opts.map).some(Boolean)) {
form.append('mapping', JSON.stringify(opts.map));
2026-09-19 15:30:31 +03:30
}
if (opts.sessionId) form.append('sessionId', opts.sessionId);
if (opts.offset != null) form.append('offset', String(opts.offset));
const { data } = await api.post<ImportContactsResult>(props.importPath, form, {
headers: { 'Content-Type': undefined },
timeout: 3 * 60 * 1000,
});
return data;
}
async function runChunkedImport(file: File, map?: Record<string, string>) {
phase.value = 'import';
progressLabel.value = 'در حال ورود اطلاعات از اکسل…';
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let sessionId: string | undefined;
let offset = 0;
let guard = 0;
while (guard < 500) {
guard += 1;
const part = await postImportChunk({
file: sessionId ? undefined : file,
map: sessionId ? undefined : map,
sessionId,
offset: sessionId ? offset : undefined,
});
acc = mergeImportResults(acc, part);
const total = part.total ?? 0;
const processed = part.processed ?? 0;
if (total > 0) {
progressLabel.value = `ورود چندمرحله‌ای: ${processed.toLocaleString('fa-IR')} از ${total.toLocaleString('fa-IR')} ردیف`;
}
if (!part.hasMore || !part.sessionId) break;
sessionId = part.sessionId;
offset = part.nextOffset ?? processed;
}
return acc;
2026-09-19 15:30:31 +03:30
}
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;
phase.value = 'preview';
2026-09-19 15:30:31 +03:30
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) {
try {
emit('done', await runChunkedImport(file));
reset();
emit('settled');
} catch (impErr) {
emit('fail', apiMessage(impErr, 'ایمپورت ناموفق بود'));
reset();
emit('settled');
}
2026-09-19 15:30:31 +03:30
return;
}
pending.value = file;
preview.value = data;
for (const col of data.columns) {
mapping[col.header] = data.suggested[col.header] ?? '';
}
phase.value = 'idle';
submitting.value = false;
// Allow parent toolbar while user maps columns.
emit('settled');
2026-09-19 15:30:31 +03:30
} catch (e) {
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
reset();
emit('settled');
}
}
async function confirmMapping() {
if (!pending.value || !canImport.value || submitting.value) return;
emit('start');
2026-09-19 15:30:31 +03:30
submitting.value = true;
phase.value = 'import';
2026-09-19 15:30:31 +03:30
try {
emit('done', await runChunkedImport(pending.value, { ...mapping }));
2026-09-19 15:30:31 +03:30
reset();
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'ایمپورت ناموفق بود'));
phase.value = 'idle';
2026-09-19 15:30:31 +03:30
submitting.value = false;
progressLabel.value = '';
emit('settled');
2026-09-19 15:30:31 +03:30
}
}
</script>
<template>
<span class="excel-import" :class="{ isBusy: submitting }" @click.stop="pick">
<slot :loading="submitting" :disabled="disabled || submitting" />
2026-09-19 15:30:31 +03:30
<input
ref="input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
:disabled="disabled || submitting"
2026-09-19 15:30:31 +03:30
@change="onFile"
@click.stop
/>
</span>
<UiBusyOverlay :open="busyOpen" :message="busyMessage" nest />
2026-09-19 15:30:31 +03:30
<UiModal :open="!!preview" title="تطبیق ستون‌های اکسل" @close="closeMapper">
<p class="lead">
ستون‌های این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
</p>
<p v-if="preview?.largeFile" class="large-hint">
فایل بزرگ است ({{ (preview.rowCount ?? 0).toLocaleString('fa-IR') }} ردیف) — ورود به‌صورت چندمرحله‌ای انجام می‌شود.
</p>
2026-09-19 15:30:31 +03:30
<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] ?? ''"
:disabled="submitting"
2026-09-19 15:30:31 +03:30
@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="!canImport" :loading="submitting" @click="confirmMapping">
ورود فایل
2026-09-19 15:30:31 +03:30
</UiButton>
</template>
</UiModal>
</template>
<style scoped>
.excel-import {
display: inline-flex;
}
.excel-import.isBusy {
pointer-events: none;
opacity: 0.7;
}
2026-09-19 15:30:31 +03:30
.lead {
margin: 0 0 1rem;
color: var(--muted);
line-height: 1.7;
}
.large-hint {
margin: -0.35rem 0 1rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
background: color-mix(in srgb, var(--accent, #2563eb) 12%, transparent);
color: var(--fg, inherit);
font-size: 0.88rem;
line-height: 1.6;
}
2026-09-19 15:30:31 +03:30
.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>