229 lines
5.8 KiB
Vue
229 lines
5.8 KiB
Vue
<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>
|