From 6c6e32e09977410be882b824ae390e75a9bd19f6 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 5 Nov 2026 14:46:35 +0330 Subject: [PATCH] feat(web): adopt vuesax icons and shared skeleton loading Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell. Co-authored-by: Cursor --- apps/web/src/components/AskMentoPanel.vue | 2 +- .../src/components/CustomFieldsSection.vue | 110 +- apps/web/src/components/EntityTimeline.vue | 8 +- apps/web/src/components/InvoiceDocPreview.vue | 2 +- apps/web/src/components/JalaliDateInput.vue | 2 +- apps/web/src/components/MentoMemoryPanel.vue | 8 +- apps/web/src/components/OrgSwitcher.vue | 344 ++- .../src/components/PhoneContactsImport.vue | 2 +- apps/web/src/components/QuickCreate.vue | 404 +++- apps/web/src/components/RoleDetailPanel.vue | 2 +- apps/web/src/components/SavedFiltersBar.vue | 2 +- apps/web/src/components/TableColumnsMenu.vue | 2 +- .../web/src/components/TeamsSettingsPanel.vue | 8 +- apps/web/src/components/UiIcon.vue | 353 +-- apps/web/src/components/VoiceToTextButton.vue | 2 +- apps/web/src/components/VsIcon.vue | 76 + .../activities/ActivitiesKanban.vue | 74 +- .../activities/ActivityFieldsEditor.vue | 2 +- .../activities/ActivityStagesManager.vue | 2 +- .../activities/ActivityTypesManager.vue | 2 +- .../automations/AutomationCanvas.vue | 2 +- .../components/automations/AutomationNode.vue | 2 +- .../components/dashboard/HomeAiAssistant.vue | 459 +++- .../dashboard/HomeAlertsSection.vue | 2 +- .../dashboard/HomeBirthdayStrip.vue | 2 +- .../web/src/components/dashboard/HomeHero.vue | 254 ++- .../src/components/dashboard/HomeKpiRow.vue | 2 +- .../components/dashboard/HomeMiniPipeline.vue | 2 +- .../dashboard/HomeRecentActivities.vue | 2 +- .../components/dashboard/HomeTopCustomers.vue | 2 +- .../gamification/ClubLeaderboard.vue | 642 +++--- .../src/components/gamification/club-icons.ts | 56 + apps/web/src/components/nav/NavHubPanel.vue | 8 +- .../person-profile/ProfileEmptyState.vue | 2 +- .../person-profile/ProfileMoreMenu.vue | 2 +- .../src/components/pipeline/AddDealButton.vue | 2 +- apps/web/src/components/pipeline/DealCard.vue | 2 +- .../src/components/pipeline/PipelineBoard.vue | 2 +- .../components/pipeline/PipelineColumn.vue | 2 +- .../src/components/pipeline/PipelineList.vue | 2 +- .../pipeline/PipelineMergeWizard.vue | 2 +- .../components/pipeline/PipelineToolbar.vue | 2 +- .../components/routines/EntityLinkPicker.vue | 2 +- apps/web/src/components/routines/KpiCard.vue | 2 +- .../components/routines/RelatedTasksPanel.vue | 10 +- .../components/routines/RoutineTaskRow.vue | 2 +- .../src/components/skeleton/CrmSkeleton.vue | 19 + .../components/skeleton/CrmSkeletonAvatar.vue | 31 + .../components/skeleton/CrmSkeletonBone.vue | 51 + .../components/skeleton/CrmSkeletonCard.vue | 30 + .../skeleton/CrmSkeletonControl.vue | 50 + .../components/skeleton/CrmSkeletonKpi.vue | 44 + .../components/skeleton/CrmSkeletonList.vue | 31 + .../skeleton/CrmSkeletonParagraph.vue | 26 + .../components/skeleton/CrmSkeletonTable.vue | 115 + .../components/skeleton/CrmSkeletonTitle.vue | 55 + apps/web/src/components/skeleton/context.ts | 13 + apps/web/src/components/skeleton/index.ts | 11 + apps/web/src/icons/ant-bridge.ts | 163 ++ apps/web/src/icons/index.ts | 6 + apps/web/src/icons/name-map.ts | 208 ++ apps/web/src/icons/vuesax-linear.json | 1 + apps/web/src/icons/vuesax.ts | 19 + apps/web/src/layouts/AppLayout.vue | 1028 ++++++--- apps/web/src/styles/main.css | 4 +- apps/web/src/theme/antdTheme.ts | 11 + apps/web/src/views/ActivitiesView.vue | 9 +- apps/web/src/views/ActivityFormView.vue | 10 +- apps/web/src/views/AttachmentsView.vue | 10 +- apps/web/src/views/AuditLogsView.vue | 10 +- .../web/src/views/AutomationsSettingsView.vue | 14 +- apps/web/src/views/ChequesView.vue | 10 +- apps/web/src/views/CommissionsView.vue | 94 +- apps/web/src/views/CompanyFormView.vue | 514 +++-- apps/web/src/views/ContactsView.vue | 11 +- apps/web/src/views/CurrencySettingsView.vue | 10 +- .../src/views/CustomFieldsSettingsView.vue | 10 +- apps/web/src/views/DealFormView.vue | 10 +- apps/web/src/views/DeletionArchivesView.vue | 8 +- apps/web/src/views/DiscReportView.vue | 120 +- apps/web/src/views/FaqSettingsView.vue | 10 +- apps/web/src/views/HomeView.vue | 75 - apps/web/src/views/InventoryView.vue | 579 ++--- apps/web/src/views/InvoiceFormView.vue | 12 +- apps/web/src/views/InvoicesView.vue | 162 +- apps/web/src/views/LeadBoardView.vue | 21 +- apps/web/src/views/LeadDetailView.vue | 10 +- apps/web/src/views/LeadFormBuilderView.vue | 10 +- apps/web/src/views/LeadFormsSettingsView.vue | 10 +- apps/web/src/views/LeadStagesSettingsView.vue | 459 ++-- apps/web/src/views/LeadsView.vue | 354 +-- apps/web/src/views/LoginView.vue | 2 +- apps/web/src/views/LoyaltyClubView.vue | 52 +- apps/web/src/views/MessageAutomationsView.vue | 16 +- apps/web/src/views/MontifySettingsView.vue | 10 +- apps/web/src/views/NotesView.vue | 9 +- apps/web/src/views/PersonFormView.vue | 12 +- apps/web/src/views/PersonProfileView.vue | 1956 ++++------------- apps/web/src/views/PipelineBoardView.vue | 17 +- apps/web/src/views/PipelineSettingsView.vue | 10 +- apps/web/src/views/ProductFormView.vue | 10 +- apps/web/src/views/ProductsSettingsView.vue | 201 +- apps/web/src/views/ProfileView.vue | 10 +- apps/web/src/views/ProjectBoardView.vue | 470 ++-- apps/web/src/views/ProjectCardFormView.vue | 12 +- apps/web/src/views/PublicApiSettingsView.vue | 10 +- apps/web/src/views/ReportsView.vue | 1016 +++++++-- apps/web/src/views/ReservationsView.vue | 244 +- apps/web/src/views/SalesForecastView.vue | 10 +- apps/web/src/views/SmsCampaignsView.vue | 394 ++-- apps/web/src/views/TahesabSalesReportView.vue | 2 +- apps/web/src/views/TahesabSettingsView.vue | 2 +- apps/web/src/views/TeamSettingsView.vue | 10 +- apps/web/src/views/TicketFormView.vue | 751 +++---- apps/web/src/views/TicketsView.vue | 170 +- apps/web/src/views/VoipSettingsView.vue | 10 +- .../web/src/views/WooCommerceSettingsView.vue | 10 +- .../views/gamification/GamificationView.vue | 959 ++++---- .../src/views/routines/RoutineFormView.vue | 582 +++-- .../views/routines/RoutinesCalendarView.vue | 154 +- .../src/views/routines/RoutinesListView.vue | 141 +- .../src/views/routines/RoutinesManageView.vue | 228 +- .../routines/RoutinesMyPerformanceView.vue | 232 +- .../views/routines/RoutinesOverdueView.vue | 172 +- .../views/routines/RoutinesSettingsView.vue | 255 +-- .../src/views/routines/RoutinesShellView.vue | 2 +- .../src/views/routines/RoutinesTeamView.vue | 265 +-- .../src/views/routines/RoutinesTodayView.vue | 333 +-- .../src/views/treasury/TreasuryHomeView.vue | 340 +-- .../views/treasury/TreasuryIncomesView.vue | 2 +- .../views/treasury/TreasuryReportsView.vue | 2 +- apps/web/tsconfig.json | 2 +- scripts/_setup-vuesax.mjs | 66 + 133 files changed, 9069 insertions(+), 7416 deletions(-) create mode 100644 apps/web/src/components/VsIcon.vue create mode 100644 apps/web/src/components/gamification/club-icons.ts create mode 100644 apps/web/src/components/skeleton/CrmSkeleton.vue create mode 100644 apps/web/src/components/skeleton/CrmSkeletonAvatar.vue create mode 100644 apps/web/src/components/skeleton/CrmSkeletonBone.vue create mode 100644 apps/web/src/components/skeleton/CrmSkeletonCard.vue create mode 100644 apps/web/src/components/skeleton/CrmSkeletonControl.vue create mode 100644 apps/web/src/components/skeleton/CrmSkeletonKpi.vue create mode 100644 apps/web/src/components/skeleton/CrmSkeletonList.vue create mode 100644 apps/web/src/components/skeleton/CrmSkeletonParagraph.vue create mode 100644 apps/web/src/components/skeleton/CrmSkeletonTable.vue create mode 100644 apps/web/src/components/skeleton/CrmSkeletonTitle.vue create mode 100644 apps/web/src/components/skeleton/context.ts create mode 100644 apps/web/src/components/skeleton/index.ts create mode 100644 apps/web/src/icons/ant-bridge.ts create mode 100644 apps/web/src/icons/index.ts create mode 100644 apps/web/src/icons/name-map.ts create mode 100644 apps/web/src/icons/vuesax-linear.json create mode 100644 apps/web/src/icons/vuesax.ts create mode 100644 scripts/_setup-vuesax.mjs 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(() =>
- -