Adding registration date and multiple addresses for contacts and leads.
This commit is contained in:
@@ -3,7 +3,7 @@ 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';
|
||||
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
|
||||
|
||||
const props = defineProps<{
|
||||
previewPath: string;
|
||||
@@ -23,6 +23,7 @@ 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 requiredKeys = computed(
|
||||
() => preview.value?.fields.filter((f) => f.required).map((f) => f.key) ?? [],
|
||||
@@ -32,6 +33,11 @@ const mappedFields = computed(() => new Set(Object.values(mapping).filter(Boolea
|
||||
|
||||
const canImport = computed(() => requiredKeys.value.every((key) => mappedFields.value.has(key)));
|
||||
|
||||
const busyOpen = computed(() => submitting.value && phase.value !== 'idle');
|
||||
const busyMessage = computed(() =>
|
||||
phase.value === 'import' ? 'در حال ورود اطلاعات از اکسل…' : 'در حال خواندن و بررسی فایل اکسل…',
|
||||
);
|
||||
|
||||
function pick() {
|
||||
if (props.disabled || submitting.value) return;
|
||||
input.value?.click();
|
||||
@@ -41,10 +47,12 @@ function reset() {
|
||||
pending.value = null;
|
||||
preview.value = null;
|
||||
submitting.value = false;
|
||||
phase.value = 'idle';
|
||||
for (const key of Object.keys(mapping)) delete mapping[key];
|
||||
}
|
||||
|
||||
function closeMapper() {
|
||||
if (submitting.value) return;
|
||||
reset();
|
||||
emit('settled');
|
||||
}
|
||||
@@ -59,6 +67,7 @@ function setColumnField(header: string, field: string) {
|
||||
}
|
||||
|
||||
async function postImport(file: File, map?: Record<string, string>) {
|
||||
phase.value = 'import';
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (map && Object.values(map).some(Boolean)) {
|
||||
@@ -89,6 +98,7 @@ async function onFile(ev: Event) {
|
||||
if (!file) return;
|
||||
emit('start');
|
||||
submitting.value = true;
|
||||
phase.value = 'preview';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
@@ -106,42 +116,51 @@ async function onFile(ev: Event) {
|
||||
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');
|
||||
} catch (e) {
|
||||
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
|
||||
reset();
|
||||
emit('settled');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMapping() {
|
||||
if (!pending.value || !canImport.value) return;
|
||||
if (!pending.value || !canImport.value || submitting.value) return;
|
||||
emit('start');
|
||||
submitting.value = true;
|
||||
phase.value = 'import';
|
||||
try {
|
||||
emit('done', await postImport(pending.value, { ...mapping }));
|
||||
reset();
|
||||
emit('settled');
|
||||
} catch (e) {
|
||||
emit('fail', apiMessage(e, 'ایمپورت ناموفق بود'));
|
||||
phase.value = 'idle';
|
||||
submitting.value = false;
|
||||
emit('settled');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="excel-import" @click.stop="pick">
|
||||
<slot />
|
||||
<span class="excel-import" :class="{ isBusy: submitting }" @click.stop="pick">
|
||||
<slot :loading="submitting" :disabled="disabled || submitting" />
|
||||
<input
|
||||
ref="input"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
:disabled="disabled || submitting"
|
||||
@change="onFile"
|
||||
@click.stop
|
||||
/>
|
||||
</span>
|
||||
|
||||
<UiBusyOverlay :open="busyOpen" :message="busyMessage" nest />
|
||||
|
||||
<UiModal :open="!!preview" title="تطبیق ستونهای اکسل" @close="closeMapper">
|
||||
<p class="lead">
|
||||
ستونهای این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
|
||||
@@ -154,6 +173,7 @@ async function confirmMapping() {
|
||||
</div>
|
||||
<UiSelect
|
||||
:model-value="mapping[col.header] ?? ''"
|
||||
:disabled="submitting"
|
||||
@update:model-value="setColumnField(col.header, $event)"
|
||||
>
|
||||
<option value="">نادیده بگیر</option>
|
||||
@@ -166,8 +186,8 @@ async function confirmMapping() {
|
||||
<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 :disabled="!canImport" :loading="submitting" @click="confirmMapping">
|
||||
ورود فایل
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
@@ -178,6 +198,11 @@ async function confirmMapping() {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.excel-import.isBusy {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
message?: string;
|
||||
/** وقتی true باشد روی مودال هم دیده میشود */
|
||||
nest?: boolean;
|
||||
}>(),
|
||||
{
|
||||
message: 'لطفاً صبر کنید…',
|
||||
nest: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="busy-overlay"
|
||||
:class="{ nest }"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="card">
|
||||
<span class="spinner" aria-hidden="true" />
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.busy-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.busy-overlay.nest {
|
||||
z-index: 110;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.85rem;
|
||||
min-width: min(18rem, 100%);
|
||||
padding: 1.35rem 1.5rem;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
|
||||
color: #0f172a;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid #ccfbf1;
|
||||
border-top-color: #0d9488;
|
||||
border-radius: 50%;
|
||||
animation: busy-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes busy-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@ export { default as UiSelect } from './UiSelect.vue';
|
||||
export { default as UiField } from './UiField.vue';
|
||||
export { default as UiCard } from './UiCard.vue';
|
||||
export { default as UiModal } from './UiModal.vue';
|
||||
export { default as UiBusyOverlay } from './UiBusyOverlay.vue';
|
||||
export { default as UiPageHeader } from './UiPageHeader.vue';
|
||||
export { default as UiBadge } from './UiBadge.vue';
|
||||
export { default as UiPagination } from './UiPagination.vue';
|
||||
|
||||
Reference in New Issue
Block a user