feat(web): migrate CRM screens to Ant Design Vue with central theme

Adopt ant-design-vue and a shared antdTheme for leads, pipeline, invoices, and shared form controls under strict single-page governance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 19:39:59 +03:30
co-authored by Cursor
parent ab538b8304
commit 4abab55a6b
40 changed files with 7529 additions and 11795 deletions
+119 -227
View File
@@ -4,8 +4,21 @@ import axios from 'axios';
import { api } from '@/lib/api';
import type { CreatePersonRequest, PersonDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import {
Avatar as AAvatar,
Button as AButton,
Checkbox as ACheckbox,
Flex as AFlex,
Input as AInput,
List as AList,
ListItem as AListItem,
Modal as AModal,
Space as ASpace,
Typography,
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@ant-design/icons-vue';
import {
filterPhoneContacts,
isContactPickerSupported,
@@ -15,6 +28,8 @@ import {
type PhoneContactDraft,
} from '@/lib/phone-contacts';
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
const props = withDefaults(
defineProps<{
disabled?: boolean;
@@ -38,7 +53,6 @@ const importing = ref(false);
const drafts = ref<PhoneContactDraft[]>([]);
const selected = ref<Set<string>>(new Set());
const listQuery = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const modalOpen = computed(() => phase.value !== 'closed');
const modalTitle = computed(() => {
@@ -65,7 +79,6 @@ async function showReview(rows: PhoneContactDraft[], opts?: { selectAll?: boolea
drafts.value = sortPhoneContacts(rows);
listQuery.value = '';
selected.value = opts?.selectAll ? new Set(drafts.value.map((r) => r.key)) : new Set();
// بعد از دیالوگ فایل، یک تیک صبر کن تا مودال مطمئن باز شود
phase.value = 'closed';
await nextTick();
phase.value = 'review';
@@ -83,7 +96,6 @@ function selectAll(on: boolean) {
selected.value = new Set();
return;
}
// اگر جستجو فعال است فقط نتایج را انتخاب کن، وگرنه همه
if (listQuery.value.trim()) {
const next = new Set(selected.value);
for (const row of visibleDrafts.value) next.add(row.key);
@@ -121,7 +133,6 @@ async function runDevicePicker() {
emit('fail', 'مخاطبی انتخاب نشد.');
return;
}
// مثل vcf: جستجو/انتخاب دستی، نه ورود خودکار همه
await showReview(rows, { selectAll: false });
} catch (e) {
const name = e instanceof DOMException ? e.name : '';
@@ -143,11 +154,12 @@ function startPicker() {
openHelp();
}
async function onVcfFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
const beforeVcfUpload: UploadProps['beforeUpload'] = (file) => {
void handleVcfFile(file as File);
return false;
};
async function handleVcfFile(file: File) {
try {
const rows = await parseVCardFile(file);
if (!rows.length) {
@@ -223,68 +235,70 @@ async function importSelected() {
</script>
<template>
<span class="wrap" @click.stop>
<span class="trigger" @click="startPicker">
<span @click.stop>
<span @click="startPicker">
<slot />
</span>
<input
ref="fileInput"
class="hidden"
type="file"
accept=".vcf,text/vcard,text/x-vcard"
@change="onVcfFile"
/>
<UiModal :open="modalOpen" :title="modalTitle" @close="cancel">
<a-modal
:open="modalOpen"
:title="modalTitle"
:confirm-loading="importing"
@cancel="cancel"
>
<template v-if="phase === 'choose'">
<p class="lead">
<a-typography-paragraph type="secondary">
مخاطبین را از دفترچه تلفن یا فایل <strong>.vcf</strong> بگیرید.
بعد در لیست الفبایی جستجو کنید و یک، چند، یا همه را برای ورود انتخاب کنید.
</p>
<div class="choose-actions">
<UiButton variant="primary" block @click="runDevicePicker">
<UiIcon name="phone" :size="16" />
</a-typography-paragraph>
<a-flex vertical :gap="8">
<a-button type="primary" block @click="runDevicePicker">
<template #icon><PhoneOutlined /></template>
از دفترچه تلفن
</UiButton>
<UiButton variant="ghost" block @click="fileInput?.click()">
از فایل .vcf
</UiButton>
</div>
</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
>
<a-button block>از فایل .vcf</a-button>
</a-upload>
</a-flex>
</template>
<template v-else-if="phase === 'help'">
<p class="lead">
<a-typography-paragraph type="secondary">
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمی‌شود.
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
بعد از بارگذاری می‌توانید یک، چند، یا همه مخاطبین را انتخاب کنید.
</p>
<ul class="tips">
<li>اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد.</li>
<li>آیفون: Contacts → Share Contact / Export → فایل vCard.</li>
</ul>
</a-typography-paragraph>
<a-typography-paragraph type="secondary">
اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد. آیفون: Contacts → Share Contact /
Export → فایل vCard.
</a-typography-paragraph>
</template>
<template v-else-if="phase === 'review'">
<p class="lead">
<a-typography-paragraph type="secondary">
{{ toFaDigits(drafts.length) }} مخاطب از فایل/گوشی خوانده شد (مرتب‌شده الفبایی).
موارد دلخواه را تیک بزنید؛ فقط انتخاب‌شده‌ها وارد می‌شوند.
</p>
<div class="search">
<UiIcon name="search" :size="15" />
<input
v-model="listQuery"
type="search"
placeholder="جستجو نام یا موبایل…"
autocomplete="off"
/>
</div>
<div class="toolbar">
<button type="button" class="link" @click="selectAll(true)">
{{ listQuery.trim() ? 'انتخاب نتایج' : 'انتخاب همه' }}
</button>
<button type="button" class="link" @click="selectAll(false)">هیچ‌کدام</button>
<span class="count">
</a-typography-paragraph>
<a-input
v-model:value="listQuery"
allow-clear
placeholder="جستجو نام یا موبایل…"
style="width: 100%; margin-bottom: 12px"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-space :size="8">
<a-button type="link" @click="selectAll(true)">
{{ listQuery.trim() ? 'انتخاب نتایج' : 'انتخاب همه' }}
</a-button>
<a-button type="link" @click="selectAll(false)">هیچ‌کدام</a-button>
</a-space>
<a-typography-text type="secondary">
{{ toFaDigits(selectedCount) }} انتخاب
<template v-if="listQuery.trim()">
· {{ toFaDigits(visibleDrafts.length) }} نتیجه
@@ -292,194 +306,72 @@ async function importSelected() {
<template v-else>
از {{ toFaDigits(drafts.length) }}
</template>
</span>
</div>
<ul v-if="visibleDrafts.length" class="list">
<li v-for="row in visibleDrafts" :key="row.key">
<label>
<input
type="checkbox"
:checked="selected.has(row.key)"
</a-typography-text>
</a-flex>
<a-list
v-if="visibleDrafts.length"
:data-source="visibleDrafts"
:style="{ maxHeight: 'min(48vh, 360px)', overflow: 'auto' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-checkbox
:checked="selected.has(item.key)"
:disabled="importing"
@change="toggle(row.key)"
/>
<span class="avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
<span class="meta">
<strong>{{ row.fullName }}</strong>
<small>{{ draftLine(row) }}</small>
</span>
</label>
</li>
</ul>
<p v-else class="empty">مخاطبی با این جستجو پیدا نشد.</p>
@click.prevent="toggle(item.key)"
>
<a-space :size="8">
<a-avatar>{{ item.fullName.slice(0, 1) }}</a-avatar>
<span>
<div><a-typography-text strong>{{ item.fullName }}</a-typography-text></div>
<a-typography-text type="secondary">{{ draftLine(item) }}</a-typography-text>
</span>
</a-space>
</a-checkbox>
</a-list-item>
</template>
</a-list>
<a-typography-text v-else type="secondary">
مخاطبی با این جستجو پیدا نشد.
</a-typography-text>
</template>
<template #footer>
<template v-if="phase === 'choose'">
<UiButton variant="ghost" @click="cancel">انصراف</UiButton>
<a-button @click="cancel">انصراف</a-button>
</template>
<template v-else-if="phase === 'help'">
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
<UiButton variant="primary" :disabled="importing" @click="fileInput?.click()">
انتخاب فایل .vcf
</UiButton>
<a-button :disabled="importing" @click="cancel">انصراف</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
>
<a-button type="primary" :disabled="importing">انتخاب فایل .vcf</a-button>
</a-upload>
</template>
<template v-else-if="phase === 'review'">
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
<UiButton variant="ghost" :disabled="importing" @click="fileInput?.click()">
فایل دیگر
</UiButton>
<UiButton
variant="primary"
<a-button :disabled="importing" @click="cancel">انصراف</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
:disabled="importing"
>
<a-button :disabled="importing">فایل دیگر</a-button>
</a-upload>
<a-button
type="primary"
:disabled="importing || !selectedCount"
:loading="importing"
@click="importSelected"
>
<UiIcon name="user-plus" :size="16" />
<template #icon><UserAddOutlined /></template>
ورود {{ toFaDigits(selectedCount) }}
{{ target === 'leads' ? 'لید' : 'نفر' }}
</UiButton>
</a-button>
</template>
</template>
</UiModal>
</a-modal>
</span>
</template>
<style scoped>
.wrap {
display: inline-flex;
}
.trigger {
display: inline-flex;
}
.hidden {
display: none;
}
.lead {
margin: 0 0 0.85rem;
color: #64748b;
font-size: 0.88rem;
line-height: 1.7;
}
.choose-actions {
display: grid;
gap: 0.55rem;
}
.tips {
margin: 0;
padding: 0 1.1rem 0 0;
color: #475569;
font-size: 0.84rem;
line-height: 1.8;
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
padding: 0.45rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 0.84rem;
color: #0f172a;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.7rem;
flex-wrap: wrap;
}
.link {
border: 0;
background: transparent;
color: #0f766e;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
padding: 0;
}
.count {
margin-inline-start: auto;
color: #94a3b8;
font-size: 0.78rem;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
max-height: min(48vh, 360px);
overflow: auto;
}
.list label {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.65rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
background: #fff;
cursor: pointer;
}
.avatar {
width: 34px;
height: 34px;
border-radius: var(--radius);
background: #ecfdf8;
color: #0f766e;
display: grid;
place-items: center;
font-weight: 700;
flex-shrink: 0;
}
.meta {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.meta strong {
font-size: 0.86rem;
}
.meta small {
color: #94a3b8;
font-size: 0.74rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
margin: 0.75rem 0 0;
color: #94a3b8;
font-size: 0.84rem;
text-align: center;
}
</style>