Adding registration date and multiple addresses for contacts and leads.

This commit is contained in:
2026-10-03 12:29:11 +03:30
parent 989c03b381
commit 1309da2281
22 changed files with 699 additions and 32 deletions
+33 -8
View File
@@ -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>
+1
View File
@@ -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';