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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user