diff --git a/apps/web/src/components/AskMentoPanel.vue b/apps/web/src/components/AskMentoPanel.vue index 59fcffa..222ca99 100644 --- a/apps/web/src/components/AskMentoPanel.vue +++ b/apps/web/src/components/AskMentoPanel.vue @@ -15,7 +15,7 @@ import { Tag as ATag, Typography, } from 'ant-design-vue'; -import { ThunderboltOutlined } from '@ant-design/icons-vue'; +import { ThunderboltOutlined } from '@/icons/ant-bridge'; const { Paragraph: ATypographyParagraph } = Typography; diff --git a/apps/web/src/components/CustomFieldsSection.vue b/apps/web/src/components/CustomFieldsSection.vue index 54ab569..c6ffa46 100644 --- a/apps/web/src/components/CustomFieldsSection.vue +++ b/apps/web/src/components/CustomFieldsSection.vue @@ -8,11 +8,11 @@ import { InputNumber as AInputNumber, Row as ARow, Select as ASelect, - Spin as ASpin, Typography, } from 'ant-design-vue'; import { api } from '@/lib/api'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; +import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton'; import type { CustomFieldDefinitionDto, CustomFieldEntity, @@ -73,61 +73,63 @@ onMounted(load); diff --git a/apps/web/src/components/EntityTimeline.vue b/apps/web/src/components/EntityTimeline.vue index c1ff1a1..fb5dea3 100644 --- a/apps/web/src/components/EntityTimeline.vue +++ b/apps/web/src/components/EntityTimeline.vue @@ -10,11 +10,11 @@ import { Input as AInput, List as AList, Space as ASpace, - Spin as ASpin, Tag as ATag, Textarea as ATextarea, Typography, } from 'ant-design-vue'; +import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton'; const ASpaceCompact = ASpace.Compact; const AListItem = AList.Item; @@ -259,7 +259,8 @@ onMounted(load); :style="{ marginTop: '12px' }" /> - + + - + + diff --git a/apps/web/src/components/InvoiceDocPreview.vue b/apps/web/src/components/InvoiceDocPreview.vue index a362535..420a6f6 100644 --- a/apps/web/src/components/InvoiceDocPreview.vue +++ b/apps/web/src/components/InvoiceDocPreview.vue @@ -8,7 +8,7 @@ import { Space as ASpace, Typography, } from 'ant-design-vue'; -import { PrinterOutlined } from '@ant-design/icons-vue'; +import { PrinterOutlined } from '@/icons/ant-bridge'; import { brand } from '@mento/shared'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatMoney, currencySuffix } from '@/lib/money'; diff --git a/apps/web/src/components/JalaliDateInput.vue b/apps/web/src/components/JalaliDateInput.vue index 645b107..f239c32 100644 --- a/apps/web/src/components/JalaliDateInput.vue +++ b/apps/web/src/components/JalaliDateInput.vue @@ -6,7 +6,7 @@ import { ClockCircleOutlined, LeftOutlined, RightOutlined, -} from '@ant-design/icons-vue'; +} from '@/icons/ant-bridge'; import { addJalaliMonths, dateToJalali, diff --git a/apps/web/src/components/MentoMemoryPanel.vue b/apps/web/src/components/MentoMemoryPanel.vue index 72c964f..57f1237 100644 --- a/apps/web/src/components/MentoMemoryPanel.vue +++ b/apps/web/src/components/MentoMemoryPanel.vue @@ -9,10 +9,10 @@ import { List as AList, Modal, Space as ASpace, - Spin as ASpin, Textarea as ATextarea, Typography, } from 'ant-design-vue'; +import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton'; import { api } from '@/lib/api'; import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared'; import { formatJalaliDateTime } from '@/lib/jalali'; @@ -163,7 +163,8 @@ onMounted(load); - + + هنوز موردی در حافظه ثبت نشده. @@ -201,6 +202,7 @@ onMounted(load); - + + diff --git a/apps/web/src/components/OrgSwitcher.vue b/apps/web/src/components/OrgSwitcher.vue index e9daeb0..9d9b7ff 100644 --- a/apps/web/src/components/OrgSwitcher.vue +++ b/apps/web/src/components/OrgSwitcher.vue @@ -8,8 +8,20 @@ import type { BusinessField, OrganizationMembershipDto } from '@mento/shared'; import UiIcon from '@/components/UiIcon.vue'; import UiModal from '@/components/ui/UiModal.vue'; -defineProps<{ - compact?: boolean; +const props = withDefaults( + defineProps<{ + compact?: boolean; + variant?: 'default' | 'workspace'; + }>(), + { + compact: false, + variant: 'default', + }, +); + +const emit = defineEmits<{ + open: []; + close: []; }>(); const auth = useAuthStore(); @@ -28,10 +40,28 @@ const menuStyle = ref>({}); let ignoreDocClickUntil = 0; const currentName = computed( - () => auth.user?.organizationName || orgs.value.find((o) => o.id === auth.user?.organizationId)?.name || 'محیط کاری', + () => + auth.user?.organizationName || + orgs.value.find((o) => o.id === auth.user?.organizationId)?.name || + 'محیط کاری', ); +const currentSubtitle = computed(() => { + const current = orgs.value.find((o) => o.id === auth.user?.organizationId); + if (current?.orgRoleName) return current.orgRoleName; + if (auth.user?.role) return USER_ROLE_LABELS[auth.user.role]; + return 'محیط کاری'; +}); + +const currentInitial = computed(() => { + const name = currentName.value.trim(); + return name ? name.charAt(0) : 'م'; +}); + const canSwitch = computed(() => orgs.value.length > 1); +const isWorkspace = computed(() => props.variant === 'workspace'); +/** در حالت workspace منو همیشه باز می‌شود (لیست org + محیط جدید)؛ در حالت پیش‌فرض فقط وقتی بیش از یکی باشد */ +const menuEnabled = computed(() => isWorkspace.value || canSwitch.value); function apiError(e: unknown, fallback: string) { if (axios.isAxiosError(e)) { @@ -49,34 +79,62 @@ async function loadOrgs() { } } +function closeMenu() { + if (!open.value) return; + open.value = false; + emit('close'); +} + async function toggle() { - if (!canSwitch.value) return; + if (!menuEnabled.value) return; error.value = ''; if (open.value) { - open.value = false; + closeMenu(); return; } ignoreDocClickUntil = Date.now() + 400; open.value = true; + emit('open'); await nextTick(); placeMenu(); await loadOrgs(); + await nextTick(); + placeMenu(); } function placeMenu() { const btn = rootEl.value?.querySelector('button'); if (!(btn instanceof HTMLElement)) return; const box = btn.getBoundingClientRect(); + const minWidth = Math.round(Math.max(box.width, isWorkspace.value ? 240 : 200)); + const sidebar = document.getElementById('app-sidebar'); + const rail = sidebar?.getBoundingClientRect(); + // لبهٔ راست منو = لبهٔ چپ سایدبار − ۴px + const edge = rail?.left ?? box.left; + const beside = { + right: 'auto', + left: `${Math.round(edge - 4)}px`, + transform: 'translateX(-100%)', + minWidth: `${minWidth}px`, + }; + if (isWorkspace.value) { + menuStyle.value = { + ...beside, + top: `${Math.round(box.top)}px`, + bottom: 'auto', + }; + return; + } menuStyle.value = { - bottom: `${Math.round(window.innerHeight - box.top + 6)}px`, - right: `${Math.round(window.innerWidth - box.right)}px`, - minWidth: `${Math.round(Math.max(box.width, 200))}px`, + ...beside, + top: 'auto', + bottom: `${Math.round(window.innerHeight - box.bottom)}px`, }; } async function choose(org: OrganizationMembershipDto) { if (org.id === auth.user?.organizationId) { - open.value = false; + closeMenu(); return; } loading.value = true; @@ -91,7 +149,7 @@ async function choose(org: OrganizationMembershipDto) { } function openCreate() { - open.value = false; + closeMenu(); newName.value = ''; newField.value = 'personal'; error.value = ''; @@ -123,9 +181,11 @@ function onDocClick(event: MouseEvent) { const target = event.target; if (!(target instanceof Node)) return; if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return; - open.value = false; + closeMenu(); } +defineExpose({ close: closeMenu }); + onMounted(() => { void loadOrgs(); document.addEventListener('click', onDocClick); @@ -137,27 +197,46 @@ onUnmounted(() => { diff --git a/apps/web/src/views/AuditLogsView.vue b/apps/web/src/views/AuditLogsView.vue index 866cbad..35b305b 100644 --- a/apps/web/src/views/AuditLogsView.vue +++ b/apps/web/src/views/AuditLogsView.vue @@ -30,15 +30,15 @@ import { Row as ARow, Select as ASelect, Space as ASpace, - Spin as ASpin, Table as ATable, Tabs as ATabs, TabPane as ATabPane, Tag as ATag, Typography, } from 'ant-design-vue'; +import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton'; import type { TableColumnsType } from 'ant-design-vue'; -import { ReloadOutlined } from '@ant-design/icons-vue'; +import { ReloadOutlined } from '@/icons/ant-bridge'; const { Title: ATypographyTitle, Text: ATypographyText } = Typography; @@ -526,7 +526,8 @@ onMounted(() => { - + + لطفاً صبر کنید. @@ -724,7 +725,8 @@ onMounted(() => { - + + { - + + { - + + - + + - + + - - - - - + + diff --git a/apps/web/src/views/DealFormView.vue b/apps/web/src/views/DealFormView.vue index 949067d..82bc800 100644 --- a/apps/web/src/views/DealFormView.vue +++ b/apps/web/src/views/DealFormView.vue @@ -24,7 +24,6 @@ import { Select as ASelect, Space, Space as ASpace, - Spin as ASpin, Textarea as ATextarea, Typography, } from 'ant-design-vue'; @@ -35,7 +34,8 @@ import { FileTextOutlined, PlusOutlined, UserAddOutlined, -} from '@ant-design/icons-vue'; +} from '@/icons/ant-bridge'; +import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton'; import { api } from '@/lib/api'; import { useAuthStore } from '@/stores/auth'; import { canAssignToOthers } from '@/lib/assign-access'; @@ -607,7 +607,8 @@ onMounted(load); - + + -import { onMounted, ref } from 'vue'; -import { RouterLink } from 'vue-router'; +import { computed, onMounted, ref } from 'vue'; +import { RouterLink, useRouter } from 'vue-router'; import type { DiscDistributionRow } from '@mento/shared'; +import { + Alert as AAlert, + Button as AButton, + Card as ACard, + Col as ACol, + Empty as AEmpty, + Flex as AFlex, + Progress as AProgress, + Row as ARow, + Space as ASpace, + Typography, +} from 'ant-design-vue'; +import { ArrowRightOutlined, DownloadOutlined } from '@/icons/ant-bridge'; import { api } from '@/lib/api'; import { toFaDigits } from '@/lib/jalali'; -import { UiButton, UiPageHeader } from '@/components/ui'; +import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton'; +const { Title: ATypographyTitle, Text: ATypographyText } = Typography; + +const router = useRouter(); const rows = ref([]); const error = ref(''); const loading = ref(true); @@ -40,36 +56,76 @@ async function exportExcel() { } } -const total = () => rows.value.reduce((sum, row) => sum + row.count, 0); +const totalCount = computed(() => rows.value.reduce((sum, row) => sum + row.count, 0)); + +function percentOf(count: number) { + return totalCount.value ? Math.round((count / totalCount.value) * 100) : 0; +} + + + +
+ + توزیع تیپ‌های DISC + + + فقط ارزیابی‌های تأییدشده و قابل اتکا، در محدودهٔ دسترسی شما + +
+ + + + بازگشت + + + + خروجی اکسل + + +
+
- + + + + + + + + + + + {{ row.label }} + + + + {{ toFaDigits(row.count) }} + + + + + + + + + + + + + + درصدها سهم مشتریان دارای ارزیابی معتبر هستند، نه دقت یک آزمون روان‌شناسی. + + +
+ diff --git a/apps/web/src/views/FaqSettingsView.vue b/apps/web/src/views/FaqSettingsView.vue index 9bdc164..cdd0dd1 100644 --- a/apps/web/src/views/FaqSettingsView.vue +++ b/apps/web/src/views/FaqSettingsView.vue @@ -21,12 +21,12 @@ import { InputNumber as AInputNumber, Row as ARow, Space as ASpace, - Spin as ASpin, Tag as ATag, Textarea as ATextarea, Typography, } from 'ant-design-vue'; -import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue'; +import { DeleteOutlined, EditOutlined } from '@/icons/ant-bridge'; +import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton'; const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography; @@ -201,7 +201,8 @@ onMounted(load); - + +
- + + diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue index 00895a1..b619139 100644 --- a/apps/web/src/views/HomeView.vue +++ b/apps/web/src/views/HomeView.vue @@ -9,13 +9,6 @@ import HomeHero from '@/components/dashboard/HomeHero.vue'; import HomeAlertsSection from '@/components/dashboard/HomeAlertsSection.vue'; import HomeKpiRow from '@/components/dashboard/HomeKpiRow.vue'; import HomeBirthdayStrip from '@/components/dashboard/HomeBirthdayStrip.vue'; -import HomeSalesTrend from '@/components/dashboard/HomeSalesTrend.vue'; -import HomeCustomerGrowth from '@/components/dashboard/HomeCustomerGrowth.vue'; -import HomeDealOutcomeChart from '@/components/dashboard/HomeDealOutcomeChart.vue'; -import HomeFunnelChart from '@/components/dashboard/HomeFunnelChart.vue'; -import HomeSourcesChart from '@/components/dashboard/HomeSourcesChart.vue'; -import HomeLostReasonsChart from '@/components/dashboard/HomeLostReasonsChart.vue'; -import HomeActivityMixChart from '@/components/dashboard/HomeActivityMixChart.vue'; import HomeAiAssistant from '@/components/dashboard/HomeAiAssistant.vue'; import HomeRecentActivities from '@/components/dashboard/HomeRecentActivities.vue'; import HomeMiniPipeline from '@/components/dashboard/HomeMiniPipeline.vue'; @@ -88,26 +81,12 @@ function formatKpiValue(key: string, value: number) { return toFaDigits(value); } -const emptySalesSummary = { - openCount: 0, - wonCount: 0, - lostCount: 0, - openAmount: 0, - wonAmount: 0, - lostAmount: 0, - winRate: 0, -}; - function formatChange(value: number | null) { if (value == null) return ''; const sign = value >= 0 ? '+' : ''; return `${sign}${toFaDigits(value)}٪`; } -function formatTrendAmount(value: number) { - return formatMoney(value, home.value?.currency ?? 'IRR'); -} - function clearAsk() { askAnswer.value = ''; askError.value = ''; @@ -276,60 +255,6 @@ onUnmounted(() => { -
-
-
-

تحلیل فروش

-

روند، نتیجه معاملات و قیف مراحل باز

-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-

منابع و عملیات

-

ورود مشتری، دلایل باخت و ترکیب پیگیری‌ها

-
-
- -
- - - -
-
diff --git a/apps/web/src/views/InventoryView.vue b/apps/web/src/views/InventoryView.vue index bd58a36..09e9b97 100644 --- a/apps/web/src/views/InventoryView.vue +++ b/apps/web/src/views/InventoryView.vue @@ -7,21 +7,48 @@ import { formatMoney } from '@/lib/money'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { useAuthStore } from '@/stores/auth'; import { canManageSettings } from '@/lib/roles'; +import { registerTableColumns, useTableColumns } from '@/lib/tableColumns'; import { - UiBadge, - UiButton, - UiCard, - UiInput, - UiPageHeader, - UiSelect, -} from '@/components/ui'; -import UiIcon from '@/components/UiIcon.vue'; + Alert as AAlert, + Button as AButton, + Card as ACard, + Col as ACol, + Empty as AEmpty, + Flex as AFlex, + Form as AForm, + FormItem as AFormItem, + Input as AInput, + InputNumber as AInputNumber, + Row as ARow, + Select as ASelect, + Statistic as AStatistic, + Table as ATable, + Tag as ATag, + Typography, +} from 'ant-design-vue'; +import type { TableColumnsType } from 'ant-design-vue'; +import { + BankOutlined, + BoxPlotOutlined, + HistoryOutlined, + ReloadOutlined, + WalletOutlined, +} from '@/icons/ant-bridge'; +import { CrmSkeleton, CrmSkeletonKpi, CrmSkeletonTable } from '@/components/skeleton'; import type { + InventoryStockRowDto, InventorySummaryDto, StockMovementDto, StockMovementType, } from '@mento/shared'; +const { Title: ATypographyTitle, Text: ATypographyText } = Typography; + +const STOCK_COL_LABELS = ['نام کالا', 'واحد', 'موجودی'] as const; + +registerTableColumns('inventory.stock', [...STOCK_COL_LABELS]); +const tableCols = useTableColumns(() => 'inventory.stock'); + const auth = useAuthStore(); const canWrite = computed(() => canManageSettings(auth.user?.role)); @@ -31,7 +58,7 @@ const summary = ref(null); const movements = ref([]); const productId = ref(''); const moveType = ref<'purchase' | 'adjustment'>('purchase'); -const quantity = ref(''); +const quantity = ref(undefined); const note = ref(''); const saving = ref(false); @@ -43,12 +70,59 @@ const typeLabel: Record = { adjustment: 'اصلاح', }; +const moveTypeOptions = [ + { value: 'purchase', label: 'ورود خرید' }, + { value: 'adjustment', label: 'اصلاح موجودی (عدد جدید)' }, +]; + +const productOptions = computed(() => + (summary.value?.products ?? []).map((p) => ({ + value: p.productId, + label: p.name, + })), +); + const selectedStock = computed( () => summary.value?.products.find((p) => p.productId === productId.value) ?.stockQty ?? 0, ); +const stockColumns = computed>(() => { + const all: TableColumnsType = [ + { title: 'نام کالا', key: 'نام کالا' }, + { + title: 'واحد', + key: 'واحد', + customRender: ({ record }) => record.unit || 'عدد', + }, + { title: 'موجودی', key: 'موجودی' }, + ]; + return all.filter((col) => { + const key = String(col.key ?? ''); + if (!STOCK_COL_LABELS.includes(key as (typeof STOCK_COL_LABELS)[number])) { + return true; + } + return tableCols.isVisible(key); + }); +}); + +const movementColumns: TableColumnsType = [ + { title: 'کالا', key: 'کالا' }, + { + title: 'نوع', + key: 'نوع', + customRender: ({ record }) => typeLabel[record.type], + }, + { title: 'مقدار', key: 'مقدار' }, + { + title: 'تاریخ', + key: 'تاریخ', + customRender: ({ record }) => + formatJalaliNumeric(new Date(record.occurredAt)), + }, +]; + async function load() { loading.value = true; error.value = ''; @@ -94,7 +168,7 @@ async function submitMove() { quantity: sendQty, note: note.value.trim() || undefined, }); - quantity.value = ''; + quantity.value = undefined; note.value = ''; await load(); } catch (e) { @@ -111,278 +185,217 @@ onMounted(load); diff --git a/apps/web/src/views/LeadStagesSettingsView.vue b/apps/web/src/views/LeadStagesSettingsView.vue index f6079f8..a33fbc3 100644 --- a/apps/web/src/views/LeadStagesSettingsView.vue +++ b/apps/web/src/views/LeadStagesSettingsView.vue @@ -1,22 +1,42 @@ - - diff --git a/apps/web/src/views/LeadsView.vue b/apps/web/src/views/LeadsView.vue index 1aa1b01..ae5397e 100644 --- a/apps/web/src/views/LeadsView.vue +++ b/apps/web/src/views/LeadsView.vue @@ -9,6 +9,7 @@ import { Card as ACard, Checkbox as ACheckbox, Col as ACol, + Divider as ADivider, Dropdown as ADropdown, Empty as AEmpty, Flex as AFlex, @@ -48,8 +49,7 @@ import { PlusOutlined, SearchOutlined, UnorderedListOutlined, - UserOutlined, -} from '@ant-design/icons-vue'; +} from '@/icons/ant-bridge'; import { api } from '@/lib/api'; import { formatJalaliNumeric, @@ -63,6 +63,7 @@ import { registerTableColumns, useTableColumns } from '@/lib/tableColumns'; import TableColumnsMenu from '@/components/TableColumnsMenu.vue'; import ExcelImportFlow from '@/components/ExcelImportFlow.vue'; import PhoneContactsImport from '@/components/PhoneContactsImport.vue'; +import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton'; import { filterPhoneContacts, isContactPickerSupported, @@ -88,6 +89,7 @@ const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyPa const { token } = theme.useToken(); const screens = Grid.useBreakpoint(); const isMobile = computed(() => !screens.value.md); +const emptySimple = AEmpty.PRESENTED_IMAGE_SIMPLE; const PAGE_SIZE = 10; @@ -1136,6 +1138,20 @@ const tintColor = (tint: StageTint) => { return map[tint] ?? 'default'; }; +function tintAccent(tint: StageTint) { + const t = token.value; + const map: Record = { + mint: t.colorSuccess, + teal: t.colorInfo, + amber: t.colorWarning, + peach: t.colorWarning, + sky: t.colorInfo, + violet: t.colorPrimary, + rose: t.colorError, + }; + return map[tint] ?? t.colorPrimary; +} + const tableColumns = computed>(() => { const all: TableColumnsType = [ { @@ -1181,13 +1197,15 @@ onBeforeUnmount(() => { @@ -1032,7 +1034,8 @@ onUnmounted(() => { - + + قوانین تعریف‌شده @@ -1089,7 +1092,8 @@ onUnmounted(() => { - + + { - + + @@ -513,6 +514,7 @@ onBeforeUnmount(() => { - + + diff --git a/apps/web/src/views/NotesView.vue b/apps/web/src/views/NotesView.vue index 360bfc5..369a430 100644 --- a/apps/web/src/views/NotesView.vue +++ b/apps/web/src/views/NotesView.vue @@ -75,7 +75,8 @@ import { PushpinOutlined, ThunderboltOutlined, UnorderedListOutlined, -} from '@ant-design/icons-vue'; +} from '@/icons/ant-bridge'; +import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton'; const ACheckableTag = ATag.CheckableTag; @@ -880,7 +881,8 @@ onBeforeUnmount(() => { - + + {
- + + - - + + + اطلاعات اصلی @@ -688,7 +689,8 @@ onMounted(load); حذف - + + import { computed, ref, watch } from 'vue'; import { RouterLink, useRoute, useRouter } from 'vue-router'; +import { + Alert as AAlert, + Badge as ABadge, + Button as AButton, + Card as ACard, + Col as ACol, + Descriptions as ADescriptions, + DescriptionsItem as ADescriptionsItem, + Dropdown as ADropdown, + Empty as AEmpty, + Flex as AFlex, + FloatButton as AFloatButton, + List as AList, + ListItem as AListItem, + ListItemMeta as AListItemMeta, + Menu as AMenu, + MenuItem as AMenuItem, + Modal, + Row as ARow, + Space as ASpace, + Tabs as ATabs, + TabPane as ATabPane, + Tag as ATag, + Timeline as ATimeline, + TimelineItem as ATimelineItem, + Typography, +} from 'ant-design-vue'; +import { + CheckOutlined, + ClockCircleOutlined, + EditOutlined, + FileOutlined, + FilterOutlined, + MailOutlined, + MessageOutlined, + PhoneOutlined, + PlusOutlined, + ThunderboltOutlined, + DeleteOutlined, +} from '@/icons/ant-bridge'; +import { CrmSkeletonCard } from '@/components/skeleton'; import { api } from '@/lib/api'; import { useAuthStore } from '@/stores/auth'; import { formatCompactMoney, formatMoney } from '@/lib/money'; @@ -16,7 +57,6 @@ import { isVipPerson, purchaseProbability, } from '@/lib/person-insights'; -import UiIcon from '@/components/UiIcon.vue'; import CustomerMoodFace from '@/components/CustomerMoodFace.vue'; import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue'; import { brand } from '@mento/shared'; @@ -55,6 +95,9 @@ import CustomerGroupChips from '@/components/CustomerGroupChips.vue'; import DiscPanel from '@/components/behavior/DiscPanel.vue'; import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue'; +const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph, Link: ATypographyLink } = + Typography; + type TabKey = 'activities' | 'tasks' | 'deals' | 'cards' | 'assets' | 'info' | 'disc' | 'copilot'; type TimelineTone = 'call' | 'quote' | 'note' | 'meeting' | 'task'; type HistoryFilter = 'all' | TimelineTone; @@ -125,7 +168,6 @@ const tahesabMoshtariCode = ref(null); const moreOpen = ref(false); const smsOpen = ref(false); -const mobMoreOpen = ref(false); const filterOpen = ref(false); const historyFilter = ref('all'); const tab = ref('activities'); @@ -157,7 +199,6 @@ const mood = computed(() => person.value?.mood || 'newcomer'); const moodLabel = computed(() => PERSON_MOOD_LABELS[mood.value]); const phoneNumber = computed(() => person.value?.mobile || person.value?.phone || ''); const phoneDisplay = computed(() => (phoneNumber.value ? toFaDigits(phoneNumber.value) : '—')); -const emailDisplay = computed(() => person.value?.email || '—'); const telHref = computed(() => (phoneNumber.value ? `tel:${phoneNumber.value}` : '')); const mailHref = computed(() => person.value?.email ? `mailto:${person.value.email}` : '', @@ -351,12 +392,12 @@ function activityTone(a: ActivityDto): TimelineTone { return 'task'; } -function timelineIcon(tone: TimelineTone) { - if (tone === 'call') return 'phone' as const; - if (tone === 'meeting') return 'user' as const; - if (tone === 'task') return 'calendar' as const; - if (tone === 'quote') return 'mail' as const; - return 'document' as const; +function timelineToneColor(tone: TimelineTone) { + if (tone === 'call') return 'green'; + if (tone === 'meeting') return 'cyan'; + if (tone === 'task') return 'blue'; + if (tone === 'quote') return 'purple'; + return 'gold'; } const timeline = computed(() => { @@ -374,7 +415,6 @@ const timeline = computed(() => { prior: isPriorActivity(a), }); } - // یادداشت و پیشنهاد در هر دو لنز می‌مانند (فقط معامله/فعالیت جدا می‌شوند) if (priorLens.value !== 'archive') { for (const n of notes.value) { rows.push({ @@ -458,6 +498,13 @@ const dealStatusLabel: Record = { lost: 'باخته', }; +const moodTagColor: Record = { + happy: 'purple', + unhappy: 'red', + special: 'gold', + newcomer: 'cyan', +}; + function formatSize(bytes: number) { if (bytes < 1024) return `${toFaDigits(bytes)} B`; if (bytes < 1024 * 1024) return `${toFaDigits((bytes / 1024).toFixed(1))} KB`; @@ -491,12 +538,15 @@ function setTab(next: TabKey) { void router.replace({ query: { ...route.query, tab: next === 'activities' ? undefined : next } }); } +function onTabChange(key: string | number) { + setTab(String(key) as TabKey); +} + async function load() { if (!id.value) return; loading.value = true; error.value = ''; moreOpen.value = false; - mobMoreOpen.value = false; filterOpen.value = false; aiSummary.value = null; tahesabMoshtariCode.value = null; @@ -563,14 +613,7 @@ async function load() { } } -function closeMenus() { - moreOpen.value = false; - mobMoreOpen.value = false; - filterOpen.value = false; -} - async function goCall() { - closeMenus(); const number = phoneNumber.value; if (!number) return; try { @@ -581,19 +624,12 @@ async function goCall() { if (data.mode === 'tel' && data.dialUrl) { window.location.href = data.dialUrl; } - // mode url: سانترال تماس را شروع کرده؛ نیازی به tel نیست } catch { if (telHref.value) window.location.href = telHref.value; } } -function goMail() { - closeMenus(); - if (mailHref.value) window.location.href = mailHref.value; -} - function goAddActivity() { - closeMenus(); void router.push({ name: 'activity-new', query: { personId: id.value }, @@ -656,15 +692,27 @@ async function downloadAttachment(noteId: string, att: NoteAttachmentDto) { URL.revokeObjectURL(url); } -async function removePerson() { - closeMenus(); - if (!id.value || !confirm('این شخص حذف شود؟')) return; - try { - await api.delete(`/people/${id.value}`); - await router.push({ name: 'contacts' }); - } catch { - error.value = 'حذف ناموفق بود.'; - } +function removePerson() { + if (!id.value) return; + Modal.confirm({ + title: 'این شخص حذف شود؟', + okText: 'حذف', + cancelText: 'انصراف', + okButtonProps: { danger: true }, + async onOk() { + try { + await api.delete(`/people/${id.value}`); + await router.push({ name: 'contacts' }); + } catch { + error.value = 'حذف ناموفق بود.'; + } + }, + }); +} + +function onHistoryFilter(key: string) { + historyFilter.value = key as HistoryFilter; + filterOpen.value = false; } watch(id, () => void load(), { immediate: true }); @@ -672,204 +720,127 @@ watch(() => route.query.tab, readTab, { immediate: true }); - - diff --git a/apps/web/src/views/PipelineBoardView.vue b/apps/web/src/views/PipelineBoardView.vue index 94f1dd6..7682395 100644 --- a/apps/web/src/views/PipelineBoardView.vue +++ b/apps/web/src/views/PipelineBoardView.vue @@ -23,7 +23,7 @@ import { DeleteOutlined, DownloadOutlined, UploadOutlined, -} from '@ant-design/icons-vue'; +} from '@/icons/ant-bridge'; import { Alert as AAlert, Button as AButton, @@ -48,6 +48,7 @@ import { } from '@mento/shared'; import { useAuthStore } from '@/stores/auth'; import { canManageSettings } from '@/lib/roles'; +import { CrmSkeletonCard } from '@/components/skeleton'; import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu'; const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({ @@ -729,7 +730,19 @@ defineExpose({ updateDealStage: applyUpdateDealStage }); - + +
+ +
+
فهرست کاریزها - + + - + + { - + + مشخصات اصلی کالا / خدمت @@ -412,6 +413,7 @@ onMounted(async () => { - + + diff --git a/apps/web/src/views/ProductsSettingsView.vue b/apps/web/src/views/ProductsSettingsView.vue index 9622f2d..2041d57 100644 --- a/apps/web/src/views/ProductsSettingsView.vue +++ b/apps/web/src/views/ProductsSettingsView.vue @@ -46,9 +46,10 @@ import { ShoppingOutlined, ToolOutlined, UploadOutlined, -} from '@ant-design/icons-vue'; +} from '@/icons/ant-bridge'; import ExcelImportFlow from '@/components/ExcelImportFlow.vue'; import ProductFormView from '@/views/ProductFormView.vue'; +import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton'; import type { ImportContactsResult, ProductDto } from '@mento/shared'; import { brand } from '@mento/shared'; @@ -516,105 +517,109 @@ onMounted(load); :style="{ marginTop: '16px' }" /> - - - - diff --git a/apps/web/src/views/PublicApiSettingsView.vue b/apps/web/src/views/PublicApiSettingsView.vue index 987c81a..b458590 100644 --- a/apps/web/src/views/PublicApiSettingsView.vue +++ b/apps/web/src/views/PublicApiSettingsView.vue @@ -30,13 +30,13 @@ import { ListItemMeta as AListItemMeta, Row as ARow, Space as ASpace, - Spin as ASpin, Table as ATable, Tag as ATag, Typography, } from 'ant-design-vue'; +import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton'; import type { TableColumnsType } from 'ant-design-vue'; -import { CopyOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'; +import { CopyOutlined, PlusOutlined, ReloadOutlined } from '@/icons/ant-bridge'; const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography; const ACheckableTag = ATag.CheckableTag; @@ -240,7 +240,8 @@ onMounted(() => { - + + کلیدهای API @@ -453,6 +454,7 @@ onMounted(() => { - + + diff --git a/apps/web/src/views/ReportsView.vue b/apps/web/src/views/ReportsView.vue index 8f807cd..2ddd997 100644 --- a/apps/web/src/views/ReportsView.vue +++ b/apps/web/src/views/ReportsView.vue @@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'; import axios from 'axios'; import { api } from '@/lib/api'; import { useAuthStore } from '@/stores/auth'; -import { formatMoney } from '@/lib/money'; +import { formatCompactMoney, formatMoney } from '@/lib/money'; import { dateToJalali, formatJalaliNumeric, @@ -37,11 +37,15 @@ import { Space as ASpace, Spin as ASpin, Statistic as AStatistic, + TabPane as ATabPane, Table as ATable, + Tabs as ATabs, + Tooltip as ATooltip, Typography, theme, } from 'ant-design-vue'; import type { TableColumnsType } from 'ant-design-vue'; +import { CrmSkeleton, CrmSkeletonCard, CrmSkeletonKpi } from '@/components/skeleton'; import { CalendarOutlined, CheckCircleOutlined, @@ -50,11 +54,12 @@ import { FileExcelOutlined, FileTextOutlined, LinkOutlined, + QuestionCircleOutlined, RiseOutlined, SaveOutlined, TeamOutlined, ThunderboltOutlined, -} from '@ant-design/icons-vue'; +} from '@/icons/ant-bridge'; import { use } from 'echarts/core'; import { CanvasRenderer } from 'echarts/renderers'; import { BarChart, LineChart, PieChart } from 'echarts/charts'; @@ -135,10 +140,380 @@ const CHART_PALETTE = [ const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography; const { token } = theme.useToken(); +const emptySimple = AEmpty.PRESENTED_IMAGE_SIMPLE; + +/** Stretch cards in a row to equal height (Ant Col + Card). */ +const stretchColStyle = { display: 'flex' }; +const stretchCardStyle = { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column' as const, +}; +const stretchCardStyles = { + body: { + flex: 1, + display: 'flex', + flexDirection: 'column' as const, + minHeight: 0, + }, +}; +const chartBoxStyle = { + height: '260px', + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}; +const chartFillStyle = { height: '100%', width: '100%' }; type PeriodKey = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'custom'; type PageMode = 'dashboard' | 'builder'; +/** Rich mock for layout review. Disable with ?mock=0 */ +function buildMockReportsDashboard(): ReportsDashboardDto { + const days = Array.from({ length: 13 }, (_, i) => { + const d = i + 1; + const incoming = 8 + ((i * 3) % 7); + const won = Math.max(1, Math.round(incoming * (0.22 + (i % 5) * 0.04))); + return { + key: `1405-08-${String(d).padStart(2, '0')}`, + label: `${toFaDigits(d)} آبان`, + incomingCount: incoming, + wonCount: won, + conversionPercent: Math.round((won / incoming) * 100), + }; + }); + const trend = days.map((d, i) => ({ + key: d.key, + label: d.label, + value: 18_000_000 + i * 4_500_000 + (i % 3) * 2_000_000, + })); + return { + from: '2026-10-23', + to: '2026-11-04', + currency: 'IRR', + kpis: [ + { + key: 'new_customers', + label: 'مشتریان جدید', + value: 42, + format: 'number', + changePercent: 18, + changeLabel: 'نسبت به دوره قبل', + }, + { + key: 'invoice_count', + label: 'تعداد فاکتور', + value: 67, + format: 'number', + changePercent: 9, + changeLabel: 'نسبت به دوره قبل', + }, + { + key: 'sales_amount', + label: 'مبلغ فروش', + value: 485_000_000, + format: 'money', + changePercent: 14, + changeLabel: 'نسبت به دوره قبل', + }, + { + key: 'avg_invoice', + label: 'میانگین فاکتور', + value: 7_240_000, + format: 'money', + changePercent: -3, + changeLabel: 'نسبت به دوره قبل', + }, + { + key: 'avg_sale', + label: 'میانگین فروش', + value: 12_100_000, + format: 'money', + changePercent: 6, + changeLabel: 'نسبت به دوره قبل', + }, + ], + salesTrend: trend, + monthlyComparison: [ + { key: 'm1', label: 'مرداد', value: 320_000_000 }, + { key: 'm2', label: 'شهریور', value: 410_000_000 }, + { key: 'm3', label: 'مهر', value: 455_000_000 }, + { key: 'm4', label: 'آبان', value: 485_000_000 }, + ], + customerGrowth: [ + { key: 'g1', label: 'مرداد', value: 18 }, + { key: 'g2', label: 'شهریور', value: 24 }, + { key: 'g3', label: 'مهر', value: 31 }, + { key: 'g4', label: 'آبان', value: 42 }, + ], + sources: [ + { label: 'اینستاگرام', count: 28, percent: 34, amount: 120_000_000 }, + { label: 'معرفی', count: 19, percent: 23, amount: 150_000_000 }, + { label: 'سایت', count: 16, percent: 20, amount: 95_000_000 }, + { label: 'تبلیغات', count: 12, percent: 15, amount: 70_000_000 }, + { label: 'سایر', count: 7, percent: 8, amount: 50_000_000 }, + ], + lostReasons: [ + { label: 'قیمت', count: 11, percent: 40 }, + { label: 'رقیب', count: 8, percent: 29 }, + { label: 'زمان‌بندی', count: 5, percent: 18 }, + { label: 'عدم نیاز', count: 4, percent: 13 }, + ], + salesByCategory: [ + { label: 'نرم‌افزار', count: 22, percent: 45, amount: 220_000_000 }, + { label: 'سرویس', count: 15, percent: 30, amount: 145_000_000 }, + { label: 'سخت‌افزار', count: 12, percent: 25, amount: 120_000_000 }, + ], + topProducts: [ + { name: 'پلن حرفه‌ای', quantity: 18, amount: 180_000_000 }, + { name: 'پلن پایه', quantity: 24, amount: 96_000_000 }, + { name: 'پشتیبانی سالانه', quantity: 12, amount: 72_000_000 }, + { name: 'ماژول پیامک', quantity: 9, amount: 45_000_000 }, + { name: 'آموزش', quantity: 7, amount: 28_000_000 }, + ], + customerStatus: [ + { label: 'فعال', count: 86, percent: 58 }, + { label: 'در حال پیگیری', count: 34, percent: 23 }, + { label: 'راکد', count: 28, percent: 19 }, + ], + activities: { + open: 24, + done: 58, + overdue: 7, + byType: [ + { type: 'call', label: 'تماس', count: 32 }, + { type: 'meeting', label: 'جلسه', count: 18 }, + { type: 'task', label: 'وظیفه', count: 21 }, + { type: 'email', label: 'ایمیل', count: 11 }, + { type: 'note', label: 'یادداشت', count: 7 }, + ], + }, + team: [ + { + userId: 'u1', + fullName: 'مدیر نمونه', + wonAmount: 165_000_000, + wonCount: 9, + activitiesDone: 22, + }, + { + userId: 'u2', + fullName: 'کارشناس فروش نمونه', + wonAmount: 142_000_000, + wonCount: 11, + activitiesDone: 31, + }, + { + userId: 'u3', + fullName: 'آقای بنی لوحی', + wonAmount: 98_000_000, + wonCount: 6, + activitiesDone: 14, + }, + { + userId: 'u4', + fullName: 'مدیر سازمانی نمونه', + wonAmount: 80_000_000, + wonCount: 5, + activitiesDone: 10, + }, + ], + insights: [ + 'نرخ تبدیل اینستاگرام ۱۸٪ بالاتر از میانگین منابع است؛ بودجه تبلیغ را اینجا نگه دارید.', + 'مرحله «قیمت و نهایی کردن» بیشترین ریزش را دارد؛ اسکریپت تخفیف کنترل‌شده پیشنهاد می‌شود.', + 'دو فروشنده بالای میانگین تیم هستند؛ جلسات کپی‌روش فروش را با آن‌ها برگزار کنید.', + ], + sales: { + openCount: 38, + wonCount: 31, + lostCount: 28, + openAmount: 620_000_000, + wonAmount: 485_000_000, + lostAmount: 210_000_000, + winRate: 52, + }, + funnel: [ + { + stageId: 's1', + stageName: 'لید جدید', + pipelineId: 'p1', + pipelineName: 'مسیر فروش اصلی', + count: 24, + amount: 180_000_000, + conversionFromPrevPercent: null, + shareOfPipelinePercent: 35, + }, + { + stageId: 's2', + stageName: 'اولین تماس', + pipelineId: 'p1', + pipelineName: 'مسیر فروش اصلی', + count: 16, + amount: 140_000_000, + conversionFromPrevPercent: 67, + shareOfPipelinePercent: 24, + }, + { + stageId: 's3', + stageName: 'مذاکرات اولیه', + pipelineId: 'p1', + pipelineName: 'مسیر فروش اصلی', + count: 12, + amount: 160_000_000, + conversionFromPrevPercent: 75, + shareOfPipelinePercent: 18, + }, + { + stageId: 's4', + stageName: 'پیشنهاد قیمت', + pipelineId: 'p1', + pipelineName: 'مسیر فروش اصلی', + count: 9, + amount: 190_000_000, + conversionFromPrevPercent: 75, + shareOfPipelinePercent: 13, + }, + { + stageId: 's5', + stageName: 'قیمت و نهایی کردن', + pipelineId: 'p1', + pipelineName: 'مسیر فروش اصلی', + count: 7, + amount: 210_000_000, + conversionFromPrevPercent: 78, + shareOfPipelinePercent: 10, + }, + ], + conversionRate: { + incomingCount: 94, + wonCount: 31, + conversionPercent: 33, + previousIncomingCount: 81, + previousWonCount: 22, + previousConversionPercent: 27, + changePercent: 6, + byTime: days, + byUser: [ + { + key: 'u2', + label: 'کارشناس فروش نمونه', + incomingCount: 34, + wonCount: 14, + conversionPercent: 41, + }, + { + key: 'u1', + label: 'مدیر نمونه', + incomingCount: 28, + wonCount: 9, + conversionPercent: 32, + }, + { + key: 'u3', + label: 'آقای بنی لوحی', + incomingCount: 20, + wonCount: 5, + conversionPercent: 25, + }, + { + key: 'u4', + label: 'مدیر سازمانی نمونه', + incomingCount: 12, + wonCount: 3, + conversionPercent: 25, + }, + ], + bySource: [ + { + key: 'ig', + label: 'اینستاگرام', + incomingCount: 30, + wonCount: 12, + conversionPercent: 40, + }, + { + key: 'ref', + label: 'معرفی', + incomingCount: 22, + wonCount: 9, + conversionPercent: 41, + }, + { + key: 'web', + label: 'سایت', + incomingCount: 24, + wonCount: 6, + conversionPercent: 25, + }, + { + key: 'ads', + label: 'تبلیغات', + incomingCount: 18, + wonCount: 4, + conversionPercent: 22, + }, + ], + }, + periodCompare: [ + { + key: 'won_amount', + label: 'مبلغ فروش موفق', + format: 'money', + current: 485_000_000, + previous: 410_000_000, + changePercent: 18, + drillKind: 'period_won', + }, + { + key: 'won_count', + label: 'تعداد فروش موفق', + format: 'number', + current: 31, + previous: 26, + changePercent: 19, + drillKind: 'period_won', + }, + { + key: 'open_amount', + label: 'معاملات باز', + format: 'money', + current: 620_000_000, + previous: 540_000_000, + changePercent: 15, + }, + { + key: 'win_rate', + label: 'نرخ برد', + format: 'percent', + current: 52, + previous: 47, + changePercent: 5, + }, + { + key: 'new_people', + label: 'مشتریان جدید', + format: 'number', + current: 42, + previous: 35, + changePercent: 20, + }, + ], + tickets: { + open: 6, + resolved: 14, + closed: 9, + slaBreached: 1, + avgCsat: 4.2, + csatCount: 11, + }, + peopleCreated: 42, + invoiceCount: 67, + invoiceAmount: 485_000_000, + }; +} + const PERIODS: Array<{ key: PeriodKey; label: string }> = [ { key: 'today', label: 'امروز' }, { key: 'week', label: 'هفته' }, @@ -239,7 +614,7 @@ function num(n: number | null | undefined) { } function formatKpi(value: number, format: 'money' | 'number') { - return format === 'money' ? money(value) : num(value); + return format === 'money' ? formatCompactMoney(value, currency.value) : num(value); } function formatChange(p: number | null | undefined) { @@ -826,7 +1201,14 @@ onMounted(() => { async function load() { loading.value = true; error.value = ''; + const useMock = route.query.mock !== '0'; try { + if (useMock) { + const orgRes = await api.get('/organizations/current'); + org.value = orgRes.data; + data.value = buildMockReportsDashboard(); + return; + } const [dash, orgRes] = await Promise.all([ api.get('/reports/dashboard', { params: { @@ -839,6 +1221,11 @@ async function load() { data.value = dash.data; org.value = orgRes.data; } catch (e) { + if (useMock) { + data.value = buildMockReportsDashboard(); + error.value = ''; + return; + } error.value = axios.isAxiosError(e) ? String( (e.response?.data as { message?: string })?.message ?? @@ -1555,15 +1942,32 @@ const activityMax = computed(() =>
- -