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
+95 -150
View File
@@ -1,10 +1,27 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Flex as AFlex,
List as AList,
Modal,
Space as ASpace,
Spin as ASpin,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const props = defineProps<{
personId: string;
}>();
@@ -86,15 +103,24 @@ async function saveEdit(id: string) {
}
}
async function removeItem(id: string) {
if (!confirm('این مورد از حافظه حذف شود؟')) return;
try {
await api.delete(`/memory/${id}`);
if (editingId.value === id) cancelEdit();
await load();
} catch {
error.value = 'حذف ناموفق بود';
}
function removeItem(id: string) {
Modal.confirm({
title: 'حذف از حافظه',
content: 'این مورد از حافظه حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
try {
await api.delete(`/memory/${id}`);
if (editingId.value === id) cancelEdit();
await load();
} catch {
error.value = 'حذف ناموفق بود';
return Promise.reject();
}
},
});
}
function formatWhen(value: string) {
@@ -110,152 +136,71 @@ onMounted(load);
</script>
<template>
<section class="memory-panel">
<header>
<div>
<p class="eyebrow">{{ brand.memoryEn }}</p>
<h2>حافظه مشتری</h2>
<p class="lead">نکات پایدار درباره این شخص — فقط با تأیید شما ذخیره می‌شود.</p>
</div>
</header>
<a-card :bordered="false" :style="{ marginTop: '16px' }">
<a-typography-text type="secondary" strong>{{ brand.memoryEn }}</a-typography-text>
<a-typography-title :level="5" :style="{ margin: '4px 0' }">حافظه مشتری</a-typography-title>
<a-typography-paragraph type="secondary" :style="{ marginBottom: '12px' }">
نکات پایدار درباره این شخص — فقط با تأیید شما ذخیره می‌شود.
</a-typography-paragraph>
<form class="add-form" @submit.prevent="addItem">
<textarea
v-model="draft"
rows="2"
<a-flex :gap="8" align="flex-start" :style="{ marginBottom: '12px' }">
<a-textarea
v-model:value="draft"
:rows="2"
placeholder="مثلاً: بودجه تا ۵۰۰ میلیون · ترجیح تماس صبح‌ها"
style="flex: 1"
@pressEnter.ctrl="addItem"
/>
<button class="btn btn-ghost" type="submit" :disabled="saving || !draft.trim()">
<a-button
type="primary"
:disabled="saving || !draft.trim()"
:loading="saving"
@click="addItem"
>
افزودن
</button>
</form>
</a-button>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!hasItems" class="muted">هنوز موردی در حافظه ثبت نشده.</p>
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '12px' }" />
<ul v-else class="items">
<li v-for="item in items" :key="item.id">
<template v-if="editingId === item.id">
<textarea v-model="editContent" rows="2" />
<div class="row-actions">
<button type="button" class="btn" :disabled="saving" @click="saveEdit(item.id)">
ذخیره
</button>
<button type="button" class="btn btn-ghost" @click="cancelEdit">انصراف</button>
</div>
<a-spin :spinning="loading">
<a-typography-text v-if="!loading && !hasItems" type="secondary">
هنوز موردی در حافظه ثبت نشده.
</a-typography-text>
<a-list v-else-if="hasItems" item-layout="vertical" :data-source="items">
<template #renderItem="{ item }">
<a-list-item>
<template v-if="editingId === item.id">
<a-flex vertical :gap="8" style="width: 100%">
<a-textarea v-model:value="editContent" :rows="2" />
<a-space :size="8">
<a-button type="primary" :loading="saving" @click="saveEdit(item.id)">ذخیره</a-button>
<a-button @click="cancelEdit">انصراف</a-button>
</a-space>
</a-flex>
</template>
<a-list-item-meta v-else>
<template #title>
<a-typography-paragraph :style="{ marginBottom: 0, whiteSpace: 'pre-wrap' }">
{{ item.content }}
</a-typography-paragraph>
</template>
<template #description>
<a-typography-text type="secondary">
{{ sourceLabel[item.sourceType] ?? item.sourceType }}
<template v-if="item.confirmedBy"> · {{ item.confirmedBy.fullName }}</template>
· {{ formatWhen(item.updatedAt) }}
</a-typography-text>
</template>
</a-list-item-meta>
<template v-if="editingId !== item.id" #actions>
<a-button type="link" @click="startEdit(item)">ویرایش</a-button>
<a-button type="link" danger @click="removeItem(item.id)">حذف</a-button>
</template>
</a-list-item>
</template>
<template v-else>
<p class="content">{{ item.content }}</p>
<p class="meta">
<span>{{ sourceLabel[item.sourceType] ?? item.sourceType }}</span>
<span v-if="item.confirmedBy"> · {{ item.confirmedBy.fullName }}</span>
<span> · {{ formatWhen(item.updatedAt) }}</span>
</p>
<div class="row-actions">
<button type="button" @click="startEdit(item)">ویرایش</button>
<button type="button" class="danger" @click="removeItem(item.id)">حذف</button>
</div>
</template>
</li>
</ul>
</section>
</a-list>
</a-spin>
</a-card>
</template>
<style scoped>
.memory-panel {
display: grid;
gap: 0.85rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
font-size: 0.85rem;
}
h2 {
margin: 0.15rem 0 0;
font-size: 1.15rem;
}
.lead {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.add-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.6rem;
align-items: start;
}
textarea {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 0.9rem;
background: #fff;
font: inherit;
width: 100%;
resize: vertical;
}
.items {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.items li {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 0.85rem;
background: #fff;
}
.content {
margin: 0;
white-space: pre-wrap;
}
.meta {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.row-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.row-actions button {
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.row-actions button.danger {
color: #b42318;
}
.muted {
color: var(--muted);
}
.error {
color: #b42318;
}
</style>