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
+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>