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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+403 -164
View File
@@ -1,18 +1,153 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import {
CalendarOutlined,
HolderOutlined,
PhoneOutlined,
ReloadOutlined,
UnorderedListOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton, UiEmptyState, UiPageHeader } from '@/components/ui';
import type { LeadBoardColumnDto, LeadDto } from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const router = useRouter();
const GAP = 16;
const COL_WIDTH = 280;
const loading = ref(false);
const moving = ref(false);
const error = ref('');
const columns = ref<LeadBoardColumnDto[]>([]);
const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
let suppressCardClick = false;
/**
* ارتفاع کامل زیر topbar شل.
* height:0 + flex:1 روی فرزند، زنجیرهٔ کشش را اجباری می‌کند.
*/
const pageStyle = computed(() => ({
boxSizing: 'border-box' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: `${GAP}px`,
height: 'calc(100dvh - 120px)',
maxHeight: 'calc(100dvh - 120px)',
minHeight: '420px',
minWidth: 0,
}));
const boardShellStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column' as const,
padding: `${GAP}px`,
background: token.value.colorBgContainer,
border: `1px solid ${token.value.colorBorderSecondary}`,
borderRadius: `${token.value.borderRadiusLG}px`,
position: 'relative' as const,
overflow: 'hidden',
}));
const boardTrackStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'row' as const,
alignItems: 'stretch',
gap: `${GAP}px`,
width: '100%',
overflowX: 'auto' as const,
overflowY: 'hidden' as const,
paddingBottom: '4px',
}));
function columnStyle(stageId: string) {
const active = dropStageId.value === stageId;
return {
boxSizing: 'border-box' as const,
flex: `0 0 ${COL_WIDTH}px`,
width: `${COL_WIDTH}px`,
alignSelf: 'stretch',
display: 'flex',
flexDirection: 'column' as const,
minHeight: 0,
background: token.value.colorFillAlter,
border: `1px solid ${
active ? token.value.colorPrimary : token.value.colorBorderSecondary
}`,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: active ? `0 0 0 2px ${token.value.colorPrimaryBg}` : undefined,
overflow: 'hidden',
};
}
const columnHeadStyle = computed(() => ({
boxSizing: 'border-box' as const,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: `${GAP}px`,
padding: `${GAP}px`,
borderBottom: `1px solid ${token.value.colorBorderSecondary}`,
background: token.value.colorBgContainer,
}));
const columnBodyStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
overflowY: 'auto' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
padding: '12px',
margin: 0,
}));
const cardBodyStyle = computed(() => ({
padding: '12px 14px',
margin: 0,
}));
function leadInitials(lead: LeadDto) {
const raw = (lead.contactName || lead.title || '?').trim();
const parts = raw.split(/\s+/).filter(Boolean);
if (!parts.length) return '؟';
if (parts.length === 1) return parts[0]!.slice(0, 1);
return `${parts[0]!.slice(0, 1)}${parts[parts.length - 1]!.slice(0, 1)}`;
}
function leadSubtitle(lead: LeadDto) {
return [lead.contactName, lead.city].filter(Boolean).join(' · ') || '';
}
function formatDate(value: string | null) {
if (!value) return '';
@@ -39,7 +174,13 @@ async function load() {
}
}
function openLead(lead: LeadDto) {
if (suppressCardClick || draggingId.value || moving.value) return;
void router.push({ name: 'lead-detail', params: { id: lead.id } });
}
function onDragStart(lead: LeadDto, event: DragEvent) {
suppressCardClick = true;
draggingId.value = lead.id;
event.dataTransfer?.setData('text/plain', lead.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
@@ -48,6 +189,9 @@ function onDragStart(lead: LeadDto, event: DragEvent) {
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
window.setTimeout(() => {
suppressCardClick = false;
}, 0);
}
function onDragOver(stageId: string, event: DragEvent) {
@@ -106,183 +250,278 @@ onMounted(load);
</script>
<template>
<section class="page">
<UiPageHeader title="مسیر لیدها" lead="کشیدن و رها کردن برای تغییر وضعیت">
<template #actions>
<RouterLink :to="{ name: 'leads' }" class="link-btn">
<UiButton variant="ghost" size="sm">فهرست لیدها</UiButton>
<div class="lead-board-page" :style="pageStyle">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="GAP">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">
مسیر لیدها
</a-typography-title>
<a-typography-text type="secondary">
کشیدن و رها کردن برای تغییر وضعیت
</a-typography-text>
</div>
<a-space :size="GAP">
<RouterLink :to="{ name: 'leads' }" custom v-slot="{ navigate }">
<a-button @click="navigate">
<template #icon><UnorderedListOutlined /></template>
فهرست لیدها
</a-button>
</RouterLink>
<UiButton variant="ghost" size="sm" :disabled="loading || moving" @click="load">
<a-button
:disabled="loading || moving"
:loading="loading"
@click="load"
>
<template #icon><ReloadOutlined /></template>
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
</a-button>
</a-space>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<a-alert v-if="error" type="error" show-icon :message="error" />
<div v-else-if="columns.length" class="board">
<section
v-for="col in columns"
:key="col.stage.id"
class="column"
:class="{ drop: dropStageId === col.stage.id }"
@dragover="onDragOver(col.stage.id, $event)"
@drop="onDrop(col.stage.id, $event)"
<div :style="boardShellStyle">
<div
v-if="loading || moving"
:style="{
position: 'absolute',
inset: 0,
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorBgMask,
}"
>
<header class="col-head">
<span
class="dot"
:style="{ background: col.stage.color || 'var(--accent)' }"
/>
<h2>{{ col.stage.name }}</h2>
<em>{{ toFaDigits(col.total ?? col.leads.length) }}</em>
</header>
<a-spin />
</div>
<div class="cards">
<article
v-for="lead in col.leads"
:key="lead.id"
class="card"
:class="{ dragging: draggingId === lead.id }"
draggable="true"
@dragstart="onDragStart(lead, $event)"
@dragend="onDragEnd"
>
<RouterLink class="title" :to="{ name: 'lead-detail', params: { id: lead.id } }">
{{ lead.title }}
</RouterLink>
<p v-if="lead.mobile" class="line" dir="ltr">{{ lead.mobile }}</p>
<p v-if="lead.city" class="line">{{ lead.city }}</p>
<p v-if="lead.assigneeName" class="line muted">{{ lead.assigneeName }}</p>
<p v-if="lead.nextFollowUpAt" class="line muted">
پیگیری: {{ formatDate(lead.nextFollowUpAt) }}
</p>
</article>
<p v-if="!col.leads.length" class="empty-col">خالی</p>
<a-empty
v-if="!loading && !columns.length"
description="مرحله‌ای برای نمایش در مسیر لیدها نیست. در تنظیمات مراحل لید، «نمایش روی مسیر» را فعال کنید."
:style="{ margin: 'auto' }"
/>
<div
v-else-if="columns.length"
class="lead-board-track"
:style="boardTrackStyle"
>
<div
v-for="col in columns"
:key="col.stage.id"
:style="columnStyle(col.stage.id)"
@dragover="onDragOver(col.stage.id, $event)"
@drop="onDrop(col.stage.id, $event)"
>
<div :style="columnHeadStyle">
<a-space :size="8">
<span
:style="{
width: '8px',
height: '8px',
borderRadius: '50%',
background: col.stage.color || token.colorPrimary,
display: 'inline-block',
flexShrink: 0,
}"
/>
<a-typography-text strong>{{ col.stage.name }}</a-typography-text>
</a-space>
<a-typography-text type="secondary">
{{ toFaDigits(col.leads.length) }}
</a-typography-text>
</div>
<div :style="columnBodyStyle">
<a-card
v-for="lead in col.leads"
:key="lead.id"
hoverable
:bordered="true"
:body-style="cardBodyStyle"
:style="{
cursor: 'pointer',
opacity: draggingId === lead.id ? 0.45 : 1,
flexShrink: 0,
margin: 0,
background: token.colorBgContainer,
boxShadow: token.boxShadow,
}"
@click="openLead(lead)"
>
<a-flex vertical :gap="10" :style="{ width: '100%', minWidth: 0 }">
<a-flex :gap="10" align="center" :style="{ minWidth: 0 }">
<span
role="button"
tabindex="0"
aria-label="جابه‌جایی لید"
title="برای جابه‌جایی بکشید"
draggable="true"
:style="{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
flexShrink: 0,
borderRadius: `${token.borderRadiusSM}px`,
color: token.colorTextTertiary,
cursor: 'grab',
background: token.colorFillQuaternary,
}"
@click.stop
@dragstart.stop="onDragStart(lead, $event)"
@dragend.stop="onDragEnd"
>
<HolderOutlined />
</span>
<a-avatar
:style="{
background: token.colorPrimaryBg,
color: token.colorPrimary,
flexShrink: 0,
fontWeight: 700,
}"
>
{{ leadInitials(lead) }}
</a-avatar>
<div :style="{ flex: 1, minWidth: 0 }">
<a-typography-text
strong
ellipsis
:style="{ display: 'block', margin: 0, lineHeight: 1.35 }"
:title="lead.title"
>
{{ lead.title }}
</a-typography-text>
<a-typography-text
v-if="leadSubtitle(lead)"
type="secondary"
ellipsis
:style="{
display: 'block',
margin: 0,
marginTop: '2px',
fontSize: `${token.fontSizeSM}px`,
lineHeight: 1.35,
}"
:title="leadSubtitle(lead)"
>
{{ leadSubtitle(lead) }}
</a-typography-text>
</div>
</a-flex>
<a-flex
justify="space-between"
align="center"
:gap="8"
:style="{ minWidth: 0 }"
>
<a-flex
v-if="lead.mobile"
align="center"
:gap="6"
dir="ltr"
:style="{ minWidth: 0 }"
>
<PhoneOutlined
:style="{
color: token.colorPrimary,
fontSize: `${token.fontSizeSM}px`,
}"
/>
<a-typography-text
:style="{
margin: 0,
fontSize: `${token.fontSizeSM}px`,
fontVariantNumeric: 'tabular-nums',
color: token.colorText,
}"
>
{{ lead.mobile }}
</a-typography-text>
</a-flex>
<a-tag
v-if="lead.assigneeName"
:bordered="false"
:style="{
margin: 0,
maxWidth: '46%',
overflow: 'hidden',
textOverflow: 'ellipsis',
background: token.colorFillSecondary,
}"
:title="lead.assigneeName"
>
{{ lead.assigneeName }}
</a-tag>
</a-flex>
<a-flex
v-if="lead.nextFollowUpAt"
align="center"
:gap="6"
:style="{
paddingTop: '8px',
borderTop: `1px solid ${token.colorSplit}`,
}"
>
<CalendarOutlined
:style="{
color: token.colorTextSecondary,
fontSize: `${token.fontSizeSM}px`,
}"
/>
<a-typography-text
type="secondary"
:style="{
margin: 0,
fontSize: `${token.fontSizeSM}px`,
}"
>
پیگیری {{ formatDate(lead.nextFollowUpAt) }}
</a-typography-text>
</a-flex>
</a-flex>
</a-card>
<a-typography-text
v-if="!col.leads.length"
type="secondary"
:style="{
textAlign: 'center',
display: 'block',
margin: 'auto',
padding: `${GAP}px`,
}"
>
خالی
</a-typography-text>
</div>
</div>
</section>
</div>
</div>
<UiEmptyState
v-else-if="!loading"
title="مرحله‌ای برای نمایش در مسیر لیدها نیست."
description="در تنظیمات مراحل لید، «نمایش روی مسیر» را فعال کنید."
/>
</section>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
/* فقط ارتفاع/اسکرول ساختاری — بدون رنگ/ری‌اسکین Ant */
.lead-board-page {
width: 100%;
}
.link-btn {
text-decoration: none;
.lead-board-track {
scrollbar-width: thin;
}
.board {
display: flex;
gap: 0.85rem;
overflow-x: auto;
padding-bottom: 0.5rem;
align-items: flex-start;
min-height: 24rem;
.lead-board-track::-webkit-scrollbar {
height: 8px;
}
.column {
flex: 0 0 16.5rem;
background: color-mix(in srgb, var(--surface-2, #f8fafc) 85%, #fff);
border: 1px solid var(--line);
border-radius: var(--radius);
display: grid;
grid-template-rows: auto 1fr;
max-height: calc(100vh - 12rem);
}
.column.drop {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.col-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.7rem 0.75rem;
border-bottom: 1px solid var(--line);
}
.col-head h2 {
margin: 0;
font-size: 0.95rem;
flex: 1;
}
.col-head em {
font-style: normal;
font-size: 0.8rem;
color: var(--muted);
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
flex-shrink: 0;
}
.cards {
padding: 0.55rem;
display: grid;
gap: 0.5rem;
overflow-y: auto;
}
.card {
background: #fff;
border: 1px solid var(--line);
border-radius: calc(var(--radius) - 2px);
padding: 0.65rem 0.7rem;
display: grid;
gap: 0.2rem;
cursor: grab;
}
.card.dragging {
opacity: 0.45;
}
.title {
font-weight: 600;
color: var(--ink);
text-decoration: none;
}
.title:hover {
color: var(--accent);
}
.line {
margin: 0;
font-size: 0.82rem;
}
.muted,
.empty-col {
color: var(--muted);
}
.empty-col {
margin: 0.5rem;
text-align: center;
font-size: 0.85rem;
}
.error {
color: #b91c1c;
margin: 0;
.lead-board-track::-webkit-scrollbar-thumb {
border-radius: 999px;
background: color-mix(in srgb, currentColor 28%, transparent);
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+370 -520
View File
@@ -2,6 +2,37 @@
import { computed, nextTick, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
Modal,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space,
Space as ASpace,
Spin as ASpin,
Textarea as ATextarea,
Typography,
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import {
ArrowLeftOutlined,
CheckOutlined,
DeleteOutlined,
PhoneOutlined,
PlusOutlined,
SearchOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
@@ -19,17 +50,12 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
import UiIcon from '@/components/UiIcon.vue';
import {
UiButton,
UiCard,
UiField,
UiInput,
UiModal,
UiPageHeader,
UiSelect,
UiTextarea,
} from '@/components/ui';
const ASpaceCompact = Space.Compact;
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const props = withDefaults(
defineProps<{
@@ -77,16 +103,21 @@ const loyaltyTierName = ref('');
const contactPickerSupported = isContactPickerSupported();
const pickingContact = ref(false);
const contactFileInput = ref<HTMLInputElement | null>(null);
const sourceOpen = ref(false);
const pickOpen = ref(false);
const pickDrafts = ref<PhoneContactDraft[]>([]);
const pickQuery = ref('');
const vcfInputKey = ref(0);
const visiblePickDrafts = computed(() =>
filterPhoneContacts(pickDrafts.value, pickQuery.value),
);
const companyOptions = computed(() => [
{ value: '', label: 'بدون شرکت' },
...companies.value.map((c) => ({ value: c.id, label: c.name })),
]);
const backTo = computed(() =>
isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } },
);
@@ -147,9 +178,38 @@ function fillFromPhoneContact() {
sourceOpen.value = true;
return;
}
contactFileInput.value?.click();
vcfInputKey.value += 1;
void nextTick(() => triggerVcfPick());
}
const vcfUploadRef = ref<{ $el?: HTMLElement } | null>(null);
function triggerVcfPick() {
const root = vcfUploadRef.value?.$el as HTMLElement | undefined;
const input = root?.querySelector('input[type="file"]') as HTMLInputElement | null;
input?.click();
}
const onVcfBeforeUpload: UploadProps['beforeUpload'] = (file) => {
void (async () => {
pickingContact.value = true;
error.value = '';
try {
const rows = await parseVCardFile(file as File);
if (!rows.length) {
error.value = 'مخاطبی در فایل پیدا نشد.';
return;
}
await openPickList(rows);
} catch {
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
} finally {
pickingContact.value = false;
}
})();
return false;
};
async function pickFromDevice() {
if (pickingContact.value || saving.value) return;
pickingContact.value = true;
@@ -167,7 +227,7 @@ async function pickFromDevice() {
const message = e instanceof Error ? e.message : '';
if (name === 'AbortError') return;
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
contactFileInput.value?.click();
triggerVcfPick();
return;
}
error.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
@@ -178,28 +238,7 @@ async function pickFromDevice() {
function pickFromVcf() {
sourceOpen.value = false;
contactFileInput.value?.click();
}
async function onContactVcfFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
pickingContact.value = true;
error.value = '';
try {
const rows = await parseVCardFile(file);
if (!rows.length) {
error.value = 'مخاطبی در فایل پیدا نشد.';
return;
}
await openPickList(rows);
} catch {
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
} finally {
pickingContact.value = false;
}
triggerVcfPick();
}
function fill(p: PersonDto) {
@@ -334,14 +373,24 @@ async function submit() {
}
}
async function remove() {
if (!id.value || !confirm('این شخص حذف شود؟')) return;
try {
await api.delete(`/people/${id.value}`);
await router.push({ name: 'contacts' });
} catch {
error.value = 'حذف ناموفق بود';
}
function remove() {
if (!id.value) return;
Modal.confirm({
title: 'حذف شخص',
content: 'این شخص حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
try {
await api.delete(`/people/${id.value}`);
await router.push({ name: 'contacts' });
} catch {
error.value = 'حذف ناموفق بود';
return Promise.reject();
}
},
});
}
function cancel() {
@@ -356,498 +405,299 @@ onMounted(load);
</script>
<template>
<section class="crm-page" :class="{ embedded, narrow: !embedded }">
<!-- Desktop Header -->
<UiPageHeader
<div>
<a-flex
v-if="!embedded"
:title="pageTitle"
:back-to="backTo"
justify="space-between"
align="center"
wrap="wrap"
:gap="12"
:style="{ marginBottom: '16px' }"
>
<template #lead>
<span class="crumb">
<div>
<a-typography-text type="secondary">
<RouterLink :to="{ name: 'contacts' }">مخاطبین</RouterLink>
<span>›</span>
<strong>{{ pageTitle }}</strong>
</span>
</template>
<template #actions>
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
<UiButton v-if="!isNew" variant="danger" :disabled="saving" @click="remove">
· {{ pageTitle }}
</a-typography-text>
<a-typography-title :level="3" :style="{ margin: '4px 0 0' }">{{ pageTitle }}</a-typography-title>
</div>
<a-space :size="8" wrap>
<a-button @click="cancel">
<template #icon><ArrowLeftOutlined /></template>
انصراف
</a-button>
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">
<template #icon><DeleteOutlined /></template>
حذف
</UiButton>
</template>
</UiPageHeader>
</a-button>
<a-button type="primary" :loading="saving" @click="submit">
<template #icon><CheckOutlined /></template>
ذخیره
</a-button>
</a-space>
</a-flex>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="crm-form" @submit.prevent="submit">
<UiCard>
<div class="crm-form-grid-2">
<UiField label="نام" for-id="firstName">
<UiInput id="firstName" v-model="firstName" />
</UiField>
<a-spin :spinning="loading">
<a-form v-if="!loading" layout="vertical" @finish="submit">
<a-card :bordered="false">
<a-divider orientation="right">اطلاعات اصلی</a-divider>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام">
<a-input v-model:value="firstName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="نام خانوادگی">
<a-input v-model:value="lastName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="موبایل">
<a-space-compact block style="width: 100%">
<a-input v-model:value="mobile" type="tel" style="width: 100%" />
<a-button
:disabled="pickingContact || saving"
@click="fillFromPhoneContact"
>
<template #icon><PhoneOutlined /></template>
{{ pickingContact ? '…' : 'از مخاطبین' }}
</a-button>
</a-space-compact>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تلفن">
<a-input v-model:value="phone" type="tel" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ایمیل">
<a-input v-model:value="email" type="email" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="سمت">
<a-input v-model:value="position" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="آدرس وب‌سایت">
<a-input v-model:value="website" dir="ltr" placeholder="https://example.com" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="پیج اینستاگرام">
<a-input v-model:value="instagram" dir="ltr" placeholder="@username یا لینک" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ تولد">
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="معرف">
<a-input v-model:value="referrer" placeholder="نام معرف" />
</a-form-item>
</a-col>
</a-row>
<UiField label="نام خانوادگی" for-id="lastName">
<UiInput id="lastName" v-model="lastName" />
</UiField>
<a-divider orientation="right">آدرس‌ها</a-divider>
<a-flex vertical :gap="8" :style="{ marginBottom: '8px' }">
<a-flex justify="flex-end">
<a-button type="link" @click="addAddress">
<template #icon><PlusOutlined /></template>
افزودن آدرس
</a-button>
</a-flex>
<a-row v-for="(_, idx) in addresses" :key="idx" :gutter="[8, 0]">
<a-col :flex="'auto'">
<a-input
v-model:value="addresses[idx]"
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
/>
</a-col>
<a-col>
<a-button danger @click="removeAddress(idx)">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-col>
</a-row>
</a-flex>
<UiField label="موبایل" for-id="mobile">
<template #label-extra>
<button
type="button"
class="from-contact"
:disabled="pickingContact || saving"
@click="fillFromPhoneContact"
>
<UiIcon name="phone" :size="14" />
{{ pickingContact ? '…' : 'از مخاطبین' }}
</button>
</template>
<UiInput id="mobile" v-model="mobile" type="tel" />
</UiField>
<input
ref="contactFileInput"
class="hidden-file"
type="file"
accept=".vcf,text/vcard,text/x-vcard"
@change="onContactVcfFile"
/>
<UiModal nest :open="sourceOpen" title="از مخاطبین" @close="closeSource">
<p class="pick-lead">
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
</p>
<div class="pick-choose">
<UiButton variant="primary" block :disabled="pickingContact" @click="pickFromDevice">
<UiIcon name="phone" :size="16" />
از دفترچه تلفن
</UiButton>
<UiButton variant="ghost" block :disabled="pickingContact" @click="pickFromVcf">
از فایل .vcf
</UiButton>
</div>
<template #footer>
<UiButton variant="ghost" @click="closeSource">انصراف</UiButton>
</template>
</UiModal>
<UiModal nest :open="pickOpen" title="انتخاب مخاطب" @close="closePick">
<p class="pick-lead">
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتب‌شده الفبایی).
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
</p>
<div class="pick-search">
<UiIcon name="search" :size="15" />
<input
v-model="pickQuery"
type="search"
placeholder="جستجو نام یا موبایل…"
autocomplete="off"
/>
</div>
<ul v-if="visiblePickDrafts.length" class="pick-list">
<li v-for="row in visiblePickDrafts" :key="row.key">
<button type="button" class="pick-row" @click="choosePick(row)">
<span class="pick-avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
<span class="pick-meta">
<strong>{{ row.fullName }}</strong>
<small>{{ draftLine(row) }}</small>
</span>
</button>
</li>
</ul>
<p v-else class="pick-empty">مخاطبی با این جستجو پیدا نشد.</p>
<template #footer>
<UiButton variant="ghost" @click="closePick">انصراف</UiButton>
<UiButton variant="ghost" @click="contactFileInput?.click()">فایل دیگر</UiButton>
</template>
</UiModal>
<UiField label="تلفن" for-id="phone">
<UiInput id="phone" v-model="phone" type="tel" />
</UiField>
<UiField label="ایمیل" for-id="email">
<UiInput id="email" v-model="email" type="email" />
</UiField>
<UiField label="سمت" for-id="position">
<UiInput id="position" v-model="position" />
</UiField>
<UiField label="آدرس وب‌سایت" for-id="website">
<UiInput id="website" v-model="website" dir="ltr" placeholder="https://example.com" />
</UiField>
<UiField label="پیج اینستاگرام" for-id="instagram">
<UiInput id="instagram" v-model="instagram" dir="ltr" placeholder="@username یا لینک" />
</UiField>
<UiField label="تاریخ تولد" for-id="birth-date">
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
</UiField>
<UiField label="معرف" for-id="referrer">
<UiInput id="referrer" v-model="referrer" placeholder="نام معرف" />
</UiField>
<div class="crm-col-span-full addresses">
<div class="addresses-head">
<span>آدرس‌ها</span>
<button type="button" class="add-addr" @click="addAddress">+ افزودن آدرس</button>
</div>
<div v-for="(_, idx) in addresses" :key="idx" class="addr-row">
<UiInput
:id="`address-${idx}`"
v-model="addresses[idx]"
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
/>
<button type="button" class="rm-addr" aria-label="حذف آدرس" @click="removeAddress(idx)">
×
</button>
</div>
</div>
<div class="crm-col-span-full">
<UiField label="وضعیت مشتری">
<div class="moods">
<button
v-for="item in PERSON_MOODS"
:key="item"
class="mood-btn"
type="button"
:class="{ on: mood === item }"
<a-divider orientation="right">وضعیت و شرکت</a-divider>
<a-form-item label="وضعیت مشتری">
<a-row :gutter="[8, 8]">
<a-col v-for="item in PERSON_MOODS" :key="item" :xs="12" :sm="6">
<a-button
block
:type="mood === item ? 'primary' : 'default'"
:ghost="mood === item"
:style="{ height: 'auto', paddingBlock: '12px' }"
@click="mood = item"
>
<CustomerMoodFace :mood="item" :size="40" />
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
</button>
<a-flex vertical align="center" :gap="6">
<CustomerMoodFace :mood="item" :size="40" />
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
</a-flex>
</a-button>
</a-col>
</a-row>
</a-form-item>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="شرکت">
<a-select
v-model:value="companyId"
show-search
option-filter-prop="label"
:options="companyOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="برچسب‌ها">
<a-input
v-model:value="tagsRaw"
placeholder="VIP، معرفی (با کاما جدا کنید)"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="یادداشت">
<a-textarea v-model:value="notes" :rows="3" />
</a-form-item>
</a-col>
</a-row>
<a-card
v-if="!isNew"
:bordered="true"
:style="{ marginTop: '8px' }"
type="inner"
>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<div>
<a-typography-text type="secondary">باشگاه مشتریان</a-typography-text>
<div>
<a-typography-text strong>
{{ toFaDigits(loyaltyPoints) }} امتیاز
<template v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</template>
</a-typography-text>
</div>
</div>
</UiField>
</div>
<RouterLink :to="{ name: 'loyalty' }">
<a-button type="link">مدیریت باشگاه</a-button>
</RouterLink>
</a-flex>
</a-card>
<div class="crm-col-span-full">
<UiField label="شرکت" for-id="companyId">
<UiSelect id="companyId" v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
</div>
<CustomFieldsSection entity-type="person" v-model="customFields" />
</a-card>
<div class="crm-col-span-full">
<UiField label="برچسب‌ها" for-id="tags">
<UiInput
id="tags"
v-model="tagsRaw"
placeholder="VIP، معرفی (با کاما جدا کنید)"
/>
</UiField>
</div>
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
<div class="crm-col-span-full">
<UiField label="یادداشت" for-id="notes">
<UiTextarea id="notes" v-model="notes" :rows="3" />
</UiField>
</div>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '16px' }"
/>
<div v-if="!isNew" class="crm-col-span-full loyalty-card">
<div class="loyalty-info">
<span class="muted">باشگاه مشتریان</span>
<strong>{{ loyaltyPoints }} امتیاز<span v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</span></strong>
</div>
<RouterLink class="loyalty-link" :to="{ name: 'loyalty' }">مدیریت باشگاه</RouterLink>
</div>
</div>
</UiCard>
<a-space
v-if="embedded"
wrap
:size="8"
:style="{ marginTop: '16px' }"
>
<a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
</a-space>
<CustomFieldsSection entity-type="person" v-model="customFields" />
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
<a-space
v-else
wrap
:size="8"
:style="{ marginTop: '16px' }"
>
<a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">حذف</a-button>
</a-space>
</a-form>
</a-spin>
<p v-if="error" class="crm-field-error">{{ error }}</p>
<a-upload
ref="vcfUploadRef"
:key="vcfInputKey"
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="onVcfBeforeUpload"
:style="{ display: 'none' }"
>
<span />
</a-upload>
<div class="mobile-actions mob-only">
<UiButton variant="primary" block type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton v-if="!isNew" variant="danger" block type="button" :disabled="saving" @click="remove">
حذف
</UiButton>
</div>
</form>
<a-modal
:open="sourceOpen"
title="از مخاطبین"
:footer="null"
destroy-on-close
@cancel="closeSource"
>
<a-typography-paragraph type="secondary">
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
</a-typography-paragraph>
<a-flex vertical :gap="8">
<a-button type="primary" block :loading="pickingContact" @click="pickFromDevice">
<template #icon><PhoneOutlined /></template>
از دفترچه تلفن
</a-button>
<a-button block :disabled="pickingContact" @click="pickFromVcf">از فایل .vcf</a-button>
<a-button type="link" @click="closeSource">انصراف</a-button>
</a-flex>
</a-modal>
<!-- Embedded Modal Actions (Fixed at bottom) -->
<div v-if="embedded" class="embed-actions">
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
</div>
</section>
<a-modal
:open="pickOpen"
title="انتخاب مخاطب"
destroy-on-close
@cancel="closePick"
>
<a-typography-paragraph type="secondary">
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتب‌شده الفبایی).
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
</a-typography-paragraph>
<a-input
v-model:value="pickQuery"
allow-clear
placeholder="جستجو نام یا موبایل…"
:style="{ marginBottom: '12px' }"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-list
v-if="visiblePickDrafts.length"
:data-source="visiblePickDrafts"
:style="{ maxHeight: '360px', overflow: 'auto' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-button type="text" block style="text-align: start; height: auto" @click="choosePick(item)">
<a-list-item-meta :title="item.fullName" :description="draftLine(item)" />
</a-button>
</a-list-item>
</template>
</a-list>
<a-typography-text v-else type="secondary">مخاطبی با این جستجو پیدا نشد.</a-typography-text>
<template #footer>
<a-button @click="closePick">انصراف</a-button>
<a-button @click="triggerVcfPick">فایل دیگر</a-button>
</template>
</a-modal>
</div>
</template>
<style scoped>
.crumb {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--muted);
font-size: var(--fs-xs);
}
.crumb a {
color: var(--accent-deep);
font-weight: var(--fw-bold);
}
.embed-actions {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.addresses {
display: grid;
gap: var(--sp-2);
}
.addresses-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.add-addr {
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
cursor: pointer;
padding: 0;
}
.addr-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
align-items: center;
}
.rm-addr {
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--muted);
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.rm-addr:hover {
color: #b91c1c;
border-color: #fecaca;
}
.moods {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.mood-btn {
display: grid;
justify-items: center;
gap: 4px;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius);
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
font: inherit;
color: var(--muted);
font-size: var(--fs-xs);
transition: all var(--transition-fast);
}
.mood-btn.on {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-bold);
}
.loyalty-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4);
background: var(--surface-subtle);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.loyalty-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.loyalty-link {
color: var(--accent-deep);
font-weight: var(--fw-semibold);
font-size: var(--fs-xs);
}
.mobile-actions {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.from-contact {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
cursor: pointer;
padding: 0;
line-height: 1;
}
.from-contact:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hidden-file {
display: none;
}
.pick-lead {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: var(--fs-sm);
line-height: 1.7;
}
.pick-choose {
display: grid;
gap: 0.55rem;
}
.pick-search {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
padding: 0.45rem 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.pick-search input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
}
.pick-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
max-height: min(48vh, 360px);
overflow: auto;
}
.pick-row {
width: 100%;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
cursor: pointer;
font: inherit;
text-align: start;
color: inherit;
}
.pick-row:hover {
border-color: var(--accent);
background: var(--accent-subtle);
}
.pick-avatar {
width: 34px;
height: 34px;
border-radius: var(--radius);
background: var(--accent-subtle);
color: var(--accent-deep);
display: grid;
place-items: center;
font-weight: var(--fw-bold);
flex-shrink: 0;
}
.pick-meta {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.pick-meta strong {
font-size: var(--fs-sm);
}
.pick-meta small {
color: var(--muted);
font-size: var(--fs-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pick-empty {
margin: 0.75rem 0 0;
color: var(--muted);
font-size: var(--fs-sm);
text-align: center;
}
@media (min-width: 901px) {
.mob-only {
display: none !important;
}
}
</style>
+294 -359
View File
@@ -17,12 +17,28 @@ import {
updateDealStage,
} from '@/lib/pipeline-board';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import UiBusyOverlay from '@/components/ui/UiBusyOverlay.vue';
import UiButton from '@/components/ui/UiButton.vue';
import UiInput from '@/components/ui/UiInput.vue';
import UiModal from '@/components/ui/UiModal.vue';
import DealFormView from '@/views/DealFormView.vue';
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
import {
DeleteOutlined,
DownloadOutlined,
UploadOutlined,
} from '@ant-design/icons-vue';
import {
Alert as AAlert,
Button as AButton,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Menu as AMenu,
MenuItem as AMenuItem,
Modal,
Modal as AModal,
Space as ASpace,
Spin as ASpin,
theme,
} from 'ant-design-vue';
import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared';
import {
DEAL_EXCEL_ALIASES,
@@ -30,6 +46,9 @@ import {
EXCEL_CUSTOM_FIELD_PREFIX,
brand,
} from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({
key: c.key,
@@ -40,9 +59,24 @@ const dealClientReadOptions = {
rawValues: true,
customFieldPrefix: EXCEL_CUSTOM_FIELD_PREFIX,
};
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
const { token } = theme.useToken();
const GAP = 16;
/** پدینگ بیرونی فقط از `.content` شل = ۱۶px */
const pageStyle = {
boxSizing: 'border-box' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: `${GAP}px`,
flex: '1 1 auto',
minHeight: 0,
minWidth: 0,
width: '100%',
height: '100%',
padding: 0,
margin: 0,
};
const route = useRoute();
const router = useRouter();
@@ -62,7 +96,6 @@ const amountMax = ref('');
const showCreate = ref(false);
const newName = ref('');
const dealModal = ref<{ dealId?: string; stageId?: string } | null>(null);
const deleteStageId = ref<string | null>(null);
const stageBusy = ref(false);
const busy = ref(false);
const importMsg = ref('');
@@ -383,59 +416,47 @@ async function onAddStage(name: string) {
}
function onDeleteStage(stageId: string) {
deleteStageId.value = stageId;
const title = stages.value.find((s) => s.id === stageId)?.title ?? '';
Modal.confirm({
title: 'حذف ستون',
content: `ستون «${title}» حذف شود؟ اگر معامله‌ای داخل آن باشد باید اول آن‌ها را جابه‌جا کنید.`,
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
if (!boardPipelineId.value || stageBusy.value) return;
stageBusy.value = true;
error.value = '';
try {
await api.delete(`/pipelines/${boardPipelineId.value}/stages/${stageId}`);
await load();
} catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
throw e;
} finally {
stageBusy.value = false;
}
},
});
}
const deleteStageTitle = computed(() => {
const id = deleteStageId.value;
return stages.value.find((s) => s.id === id)?.title ?? '';
});
async function confirmDeleteStage() {
const stageId = deleteStageId.value;
if (!stageId || !boardPipelineId.value || stageBusy.value) return;
stageBusy.value = true;
error.value = '';
try {
await api.delete(`/pipelines/${boardPipelineId.value}/stages/${stageId}`);
deleteStageId.value = null;
await load();
} catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
} finally {
stageBusy.value = false;
}
}
const deleteDealIds = ref<string[]>([]);
const dealDeleting = ref(false);
const selectMode = ref(false);
const selectedIds = ref<Set<string>>(new Set());
const selectedCount = computed(() => selectedIds.value.size);
const deleteDealTitle = computed(() => {
const ids = deleteDealIds.value;
if (ids.length !== 1) return '';
function dealTitleById(dealId: string) {
for (const s of stages.value) {
const d = s.deals.find((x) => x.id === ids[0]);
const d = s.deals.find((x) => x.id === dealId);
if (d) return d.title;
}
return '';
});
const deleteConfirmCopy = computed(() => {
const n = deleteDealIds.value.length;
if (n <= 0) return '';
if (n === 1) {
return `معاملهٔ «${deleteDealTitle.value}» حذف شود؟ این کار قابل بازگشت نیست.`;
}
return `${toFaDigits(n)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
});
}
function askDeleteDeal(dealId: string) {
menuOpenId.value = null;
deleteDealIds.value = [dealId];
confirmDeleteDeals([dealId]);
}
function startSelect(dealId: string) {
@@ -460,31 +481,43 @@ function clearSelect() {
function askBulkDelete() {
if (!selectedIds.value.size) return;
menuOpenId.value = null;
deleteDealIds.value = [...selectedIds.value];
confirmDeleteDeals([...selectedIds.value]);
}
async function confirmDeleteDeal() {
const ids = deleteDealIds.value;
function confirmDeleteDeals(ids: string[]) {
if (!ids.length || dealDeleting.value) return;
dealDeleting.value = true;
error.value = '';
try {
const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
const failed = results.filter((r) => r.status === 'rejected').length;
deleteDealIds.value = [];
clearSelect();
await load();
if (failed) {
error.value =
failed === ids.length
? 'حذف معامله‌ها ناموفق بود.'
: `${toFaDigits(failed)} مورد حذف نشد.`;
}
} catch (e) {
error.value = apiMessage(e, 'حذف معامله ناموفق بود.');
} finally {
dealDeleting.value = false;
}
const copy =
ids.length === 1
? `معاملهٔ «${dealTitleById(ids[0]!)}» حذف شود؟ این کار قابل بازگشت نیست.`
: `${toFaDigits(ids.length)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
Modal.confirm({
title: ids.length > 1 ? 'حذف معامله‌ها' : 'حذف معامله',
content: copy,
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
dealDeleting.value = true;
error.value = '';
try {
const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
const failed = results.filter((r) => r.status === 'rejected').length;
clearSelect();
await load();
if (failed) {
error.value =
failed === ids.length
? 'حذف معامله‌ها ناموفق بود.'
: `${toFaDigits(failed)} مورد حذف نشد.`;
}
} catch (e) {
error.value = apiMessage(e, 'حذف معامله ناموفق بود.');
throw e;
} finally {
dealDeleting.value = false;
}
},
});
}
async function onDealSaved() {
@@ -593,6 +626,19 @@ function onMenuDeal(dealId: string) {
});
}
function onDealMenuClick({ key }: { key: string | number }) {
const id = menuOpenId.value;
if (!id) return;
if (key === 'select') startSelect(id);
else if (key === 'view') onOpenDeal(id);
else if (key === 'edit') {
dealModal.value = { dealId: id };
menuOpenId.value = null;
} else if (key === 'promote') void promotePriorDeal(id);
else if (key === 'delete') askDeleteDeal(id);
else menuOpenId.value = null;
}
const unbindMenuDismiss = bindMenuDismiss(
() => menuOpenId.value != null,
() => (menuOpenId.value = null),
@@ -627,102 +673,141 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
</script>
<template>
<section class="page" dir="rtl">
<UiBusyOverlay :open="pageBusy" :message="busyMessage" />
<PipelineToolbar
pipeline-name="مسیر فروش"
:pipeline-options="pipelineOptions"
v-model:selected-pipeline-id="selectedPipelineId"
v-model:view-mode="viewMode"
v-model:search="search"
v-model:amount-min="amountMin"
v-model:amount-max="amountMax"
:can-create="canCreatePipeline"
@create="showCreate = true"
>
<template #excel>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
خروجی اکسل
</button>
<ExcelImportFlow
preview-path="/deals/import/preview"
import-path="/deals/import"
rows-import-path="/deals/import/rows"
:client-aliases="DEAL_EXCEL_ALIASES"
:client-fields="dealExcelFields"
:client-read-options="dealClientReadOptions"
:disabled="loading || busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
ورود از اکسل
</button>
</ExcelImportFlow>
</template>
</PipelineToolbar>
<SavedFiltersBar
scope="deals"
:current-filters="dealsSavedFilters"
@apply="applyDealsSavedFilters"
/>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-else-if="loading && !stages.length" class="muted">در حال بارگذاری مسیر فروش…</p>
<div v-if="selectMode" class="select-bar">
<span>{{ toFaDigits(selectedCount) }} معامله انتخاب شده</span>
<button
v-if="selectedCount"
type="button"
class="select-del"
@click="askBulkDelete"
<div class="pipeline-page" dir="rtl" :style="pageStyle">
<a-flex vertical :gap="GAP" :style="{ flexShrink: 0 }">
<PipelineToolbar
pipeline-name="مسیر فروش"
:pipeline-options="pipelineOptions"
v-model:selected-pipeline-id="selectedPipelineId"
v-model:view-mode="viewMode"
v-model:search="search"
v-model:amount-min="amountMin"
v-model:amount-max="amountMax"
:can-create="canCreatePipeline"
@create="showCreate = true"
>
حذف انتخاب‌شده‌ها
</button>
<button type="button" class="select-cancel" @click="clearSelect">انصراف از انتخاب</button>
<template #excel>
<a-button :disabled="loading || busy" @click="exportExcel">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
<ExcelImportFlow
preview-path="/deals/import/preview"
import-path="/deals/import"
rows-import-path="/deals/import/rows"
:client-aliases="DEAL_EXCEL_ALIASES"
:client-fields="dealExcelFields"
:client-read-options="dealClientReadOptions"
:disabled="loading || busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<a-button :disabled="loading || busy">
<template #icon><UploadOutlined /></template>
ورود از اکسل
</a-button>
</ExcelImportFlow>
</template>
<template #filters>
<SavedFiltersBar
compact
scope="deals"
:current-filters="dealsSavedFilters"
@apply="applyDealsSavedFilters"
/>
</template>
</PipelineToolbar>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="importMsg" type="success" show-icon :message="importMsg" />
<a-spin v-if="loading && !stages.length" tip="در حال بارگذاری مسیر فروش…" />
<a-alert
v-if="selectMode"
type="info"
show-icon
:message="`${toFaDigits(selectedCount)} معامله انتخاب شده`"
>
<template #action>
<a-space>
<a-button
v-if="selectedCount"
type="primary"
danger
@click="askBulkDelete"
>
<template #icon><DeleteOutlined /></template>
حذف انتخاب‌شده‌ها
</a-button>
<a-button @click="clearSelect">انصراف از انتخاب</a-button>
</a-space>
</template>
</a-alert>
</a-flex>
<div
:style="{
position: 'relative',
flex: '1 1 auto',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
}"
>
<div
v-if="pageBusy"
:style="{
position: 'absolute',
inset: 0,
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorBgMask,
}"
>
<a-spin :tip="busyMessage" />
</div>
<PipelineList
v-if="viewMode === 'list' && (!loading || stages.length)"
:stages="visibleStages"
:select-mode="selectMode"
:selected-ids="selectedIds"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
/>
<PipelineBoard
v-else-if="viewMode === 'kanban' && stages.length"
:stages="visibleStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canCreatePipeline"
:select-mode="selectMode"
:selected-ids="selectedIds"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="onAddDeal"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="onColumnDragStart"
@column-drag-end="onColumnDragEnd"
@add-stage="onAddStage"
/>
</div>
<PipelineList
v-if="viewMode === 'list' && (!loading || stages.length)"
:stages="visibleStages"
:select-mode="selectMode"
:selected-ids="selectedIds"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
/>
<PipelineBoard
v-else-if="viewMode === 'kanban' && stages.length"
:stages="visibleStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canCreatePipeline"
:select-mode="selectMode"
:selected-ids="selectedIds"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="onAddDeal"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="onColumnDragStart"
@column-drag-end="onColumnDragEnd"
@add-stage="onAddStage"
/>
<Teleport to="body">
<DealCardOverlay
v-if="overlayDeal && draggingId"
@@ -734,109 +819,62 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
</Teleport>
<Teleport to="body">
<div v-if="menuOpenId" class="menu-pop" role="menu" :style="menuStyle ?? undefined">
<button type="button" role="menuitem" @click="startSelect(menuOpenId)">
انتخاب
</button>
<button type="button" role="menuitem" @click="onOpenDeal(menuOpenId)">
مشاهده معامله
</button>
<button
type="button"
role="menuitem"
@click="dealModal = { dealId: menuOpenId ?? undefined }; menuOpenId = null"
<div
v-if="menuOpenId"
:style="{
position: 'fixed',
zIndex: 1080,
...(menuStyle ?? {}),
}"
>
<a-menu
:style="{ minWidth: '160px', boxShadow: token.boxShadowSecondary }"
@click="onDealMenuClick"
>
ویرایش
</button>
<button
v-if="canPromotePrior(findDeal(menuOpenId))"
type="button"
role="menuitem"
@click="promotePriorDeal(menuOpenId)"
>
انتقال به کار جاری
</button>
<button type="button" role="menuitem" class="danger" @click="askDeleteDeal(menuOpenId)">
حذف
</button>
<button type="button" role="menuitem" @click="menuOpenId = null">بستن</button>
<a-menu-item key="select">انتخاب</a-menu-item>
<a-menu-item key="view">مشاهده معامله</a-menu-item>
<a-menu-item key="edit">ویرایش</a-menu-item>
<a-menu-item
v-if="canPromotePrior(findDeal(menuOpenId))"
key="promote"
>
انتقال به کار جاری
</a-menu-item>
<a-menu-item key="delete" danger>حذف</a-menu-item>
<a-menu-item key="close">بستن</a-menu-item>
</a-menu>
</div>
</Teleport>
<UiModal
<a-modal
:open="showCreate && canCreatePipeline"
title="مسیر فروش جدید"
@close="showCreate = false"
:confirm-loading="saving"
ok-text="ساخت"
cancel-text="انصراف"
:ok-button-props="{ disabled: newName.trim().length < 2 }"
destroy-on-close
@ok="createPipeline"
@cancel="showCreate = false"
>
<form class="create-form" @submit.prevent="createPipeline">
<label class="field">
<span>نام مسیر فروش</span>
<UiInput
v-model="newName"
<a-form layout="vertical" @finish="createPipeline">
<a-form-item label="نام مسیر فروش" required>
<a-input
v-model:value="newName"
placeholder="نام مسیر فروش جدید…"
:maxlength="120"
required
:minlength="2"
/>
</label>
</form>
<template #footer>
<UiButton
type="button"
:disabled="saving || newName.trim().length < 2"
@click="createPipeline"
>
{{ saving ? 'در حال ساخت…' : 'ساخت' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="showCreate = false">
انصراف
</UiButton>
</template>
</UiModal>
</a-form-item>
</a-form>
</a-modal>
<UiModal
:open="Boolean(deleteStageId)"
title="حذف ستون"
@close="deleteStageId = null"
>
<p class="delete-copy">
ستون «{{ deleteStageTitle }}» حذف شود؟ اگر معامله‌ای داخل آن باشد باید
اول آن‌ها را جابه‌جا کنید.
</p>
<template #footer>
<UiButton
type="button"
:disabled="stageBusy"
@click="confirmDeleteStage"
>
{{ stageBusy ? 'در حال حذف…' : 'حذف' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="deleteStageId = null">
انصراف
</UiButton>
</template>
</UiModal>
<UiModal
:open="deleteDealIds.length > 0"
:title="deleteDealIds.length > 1 ? 'حذف معامله‌ها' : 'حذف معامله'"
@close="deleteDealIds = []"
>
<p class="delete-copy">{{ deleteConfirmCopy }}</p>
<template #footer>
<UiButton type="button" :disabled="dealDeleting" @click="confirmDeleteDeal">
{{ dealDeleting ? 'در حال حذف…' : 'حذف' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="deleteDealIds = []">
انصراف
</UiButton>
</template>
</UiModal>
<UiModal
<a-modal
:open="Boolean(dealModal)"
:title="dealModal?.dealId ? 'ویرایش معامله' : 'ایجاد معامله جدید'"
@close="dealModal = null"
:footer="null"
destroy-on-close
width="720px"
@cancel="dealModal = null"
>
<DealFormView
v-if="dealModal"
@@ -847,117 +885,14 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
@saved="onDealSaved"
@cancel="dealModal = null"
/>
</UiModal>
</section>
</a-modal>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 0.15rem;
min-width: 0;
max-width: 100%;
direction: rtl;
text-align: right;
container-type: inline-size;
container-name: pipeline;
}
.create-form .field {
display: grid;
gap: 0.35rem;
}
.create-form .field span {
font-size: 0.85rem;
color: var(--muted);
}
.muted {
color: var(--muted);
}
.error {
color: var(--danger);
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.delete-copy {
/* پدینگ بیرونی فقط از `.content` شل = ۱۶px */
.pipeline-page {
margin: 0;
line-height: 1.7;
color: var(--ink);
}
.select-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.65rem 1rem;
padding: 0.55rem 0.85rem;
border-radius: var(--radius);
background: color-mix(in srgb, var(--accent) 10%, #fff);
border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--line));
font-size: 0.86rem;
font-weight: 700;
color: var(--ink);
}
.select-bar span {
flex: 1;
min-width: 8rem;
}
.select-bar button {
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.select-bar .select-del {
color: var(--danger);
}
.select-bar .select-cancel {
color: var(--accent);
}
.menu-pop {
position: fixed;
z-index: 80;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.35rem;
display: grid;
gap: 0.15rem;
}
.menu-pop button {
border: 0;
background: transparent;
padding: 0.45rem 0.85rem;
cursor: pointer;
font: inherit;
color: var(--ink);
text-align: right;
}
.menu-pop button:hover {
background: var(--surface-2, #f1f5f9);
border-radius: calc(var(--radius) - 4px);
}
.menu-pop button.danger {
color: var(--danger);
width: 100%;
}
</style>