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 { UiButton, UiModal, UiSelect } from '@/components/ui';
|
|
|
|
|
|
|
|
|
|
|
|
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 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));
|
|
|
|
|
|
}
|
2026-09-29 14:45:19 +03:30
|
|
|
|
const { data } = await api.post<ImportContactsResult | Record<string, unknown>>(
|
|
|
|
|
|
props.importPath,
|
|
|
|
|
|
form,
|
|
|
|
|
|
{
|
|
|
|
|
|
headers: { 'Content-Type': undefined },
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-09-29 14:57:10 +03:30
|
|
|
|
return data as ImportContactsResult;
|
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;
|
|
|
|
|
|
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>
|