Files
CRM_Montzar/apps/web/src/views/ReportsView.vue
T
Mahdi 7d9fc8447f fix(reports): show Tahesab sales link only when connection exists
Require both the tahesab org module and at least one enabled accounting connection.
2026-10-24 09:39:45 +03:30

2613 lines
71 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
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 {
dateToJalali,
formatJalaliNumeric,
formatLocalDateInput,
jalaliToDate,
toFaDigits,
} from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import UiIcon from '@/components/UiIcon.vue';
import {
UiButton,
UiCard,
UiField,
UiInput,
UiModal,
UiPageHeader,
UiChart,
CHART_PALETTE,
type UiChartOption,
} from '@/components/ui';
import type {
OrganizationDto,
OrgUserDto,
ReportsBuilderDimension,
ReportsBuilderMetric,
ReportsBuilderResultDto,
ReportsDashboardDto,
ReportsDrillKind,
ReportsDrilldownDto,
ReportsFunnelStageDto,
ReportsPeriodCompareRowDto,
ReportsViewFilters,
SavedViewDto,
TahesabConnectionDto,
TeamDto,
} from '@mento/shared';
import {
brand,
REPORTS_BUILDER_DIMENSION_LABELS,
REPORTS_BUILDER_DIMENSIONS,
REPORTS_BUILDER_METRIC_LABELS,
REPORTS_BUILDER_METRICS,
} from '@mento/shared';
type PeriodKey = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'custom';
type PageMode = 'dashboard' | 'builder';
const PERIODS: Array<{ key: PeriodKey; label: string }> = [
{ key: 'today', label: 'امروز' },
{ key: 'week', label: 'هفته' },
{ key: 'month', label: 'ماه' },
{ key: 'quarter', label: 'سه‌ماهه' },
{ key: 'year', label: 'سال' },
];
const PERIOD_KEYS = new Set<PeriodKey>([
'today',
'week',
'month',
'quarter',
'year',
'custom',
]);
const router = useRouter();
const route = useRoute();
const auth = useAuthStore();
const loading = ref(false);
const error = ref('');
const data = ref<ReportsDashboardDto | null>(null);
const org = ref<OrganizationDto | null>(null);
const period = ref<PeriodKey>('month');
const from = ref('');
const to = ref('');
const showCustomDates = ref(false);
const drillOpen = ref(false);
const drillLoading = ref(false);
const drillError = ref('');
const drill = ref<ReportsDrilldownDto | null>(null);
const savedViews = ref<SavedViewDto[]>([]);
const activeViewId = ref<string | null>(null);
const viewsLoading = ref(false);
const viewsError = ref('');
const saveOpen = ref(false);
const saveName = ref('');
const saveShared = ref(true);
const saveBusy = ref(false);
const saveError = ref('');
const shareNote = ref('');
const pageMode = ref<PageMode>('dashboard');
const builderMetric = ref<ReportsBuilderMetric>('won_amount');
const builderDimension = ref<ReportsBuilderDimension>('owner');
const builderUserId = ref('');
const builderTeamId = ref('');
const builderLoading = ref(false);
const builderError = ref('');
const builderResult = ref<ReportsBuilderResultDto | null>(null);
const orgUsers = ref<OrgUserDto[]>([]);
const orgTeams = ref<TeamDto[]>([]);
const teamsAvailable = ref(true);
/** ماژول ته‌حساب فعال + حداقل یک اتصال تنظیم‌شده */
const hasTahesabSetup = ref(false);
const showTahesabSalesReport = computed(
() =>
Boolean(auth.user?.enabledModules?.includes('tahesab')) &&
hasTahesabSetup.value,
);
const currency = computed(
() =>
builderResult.value?.currency ||
data.value?.currency ||
org.value?.baseCurrency ||
'IRR',
);
const activeView = computed(
() => savedViews.value.find((v) => v.id === activeViewId.value) ?? null,
);
const canManageActiveView = computed(() => {
const v = activeView.value;
if (!v) return false;
const role = auth.user?.role;
if (role === 'owner' || role === 'admin') return true;
return v.owner?.id === auth.user?.id;
});
const funnelByPipeline = computed(() => {
const rows = data.value?.funnel ?? [];
const map = new Map<string, { name: string; stages: ReportsFunnelStageDto[] }>();
for (const row of rows) {
const key = row.pipelineId || row.pipelineName;
const cur = map.get(key) ?? { name: row.pipelineName, stages: [] };
cur.stages.push(row);
map.set(key, cur);
}
return [...map.values()];
});
function money(n: number | null | undefined) {
if (n == null || Number.isNaN(n)) return '—';
return formatMoney(n, currency.value);
}
function num(n: number | null | undefined) {
if (n == null || Number.isNaN(n)) return '—';
return toFaDigits(Math.round(n).toLocaleString('en-US'));
}
function formatKpi(value: number, format: 'money' | 'number') {
return format === 'money' ? money(value) : num(value);
}
function formatChange(p: number | null | undefined) {
if (p == null) return '—';
const sign = p > 0 ? '+' : '';
return `${sign}${toFaDigits(p)}٪`;
}
function formatCompareValue(
value: number,
format: ReportsPeriodCompareRowDto['format'],
) {
if (format === 'money') return money(value);
if (format === 'percent') return `${toFaDigits(value)}٪`;
return num(value);
}
function rangeLabel() {
if (!from.value || !to.value) return '—';
try {
const a = formatJalaliNumeric(new Date(from.value));
const b = formatJalaliNumeric(new Date(to.value));
return `${a} – ${b}`;
} catch {
return '—';
}
}
async function openDrill(kind: ReportsDrillKind, id?: string) {
drillOpen.value = true;
drillLoading.value = true;
drillError.value = '';
drill.value = null;
try {
const { data: res } = await api.get<ReportsDrilldownDto>(
'/reports/drilldown',
{
params: {
kind,
id: id || undefined,
from: from.value || undefined,
to: to.value || undefined,
},
},
);
drill.value = res;
} catch (e) {
drillError.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری فهرست ناموفق بود.',
)
: 'بارگذاری فهرست ناموفق بود.';
} finally {
drillLoading.value = false;
}
}
function closeDrill() {
drillOpen.value = false;
drill.value = null;
drillError.value = '';
}
function openDeal(link: string) {
closeDrill();
void router.push(link);
}
function applyPeriod(key: PeriodKey, opts?: { keepView?: boolean }) {
period.value = key;
showCustomDates.value = key === 'custom';
if (!opts?.keepView) {
activeViewId.value = null;
shareNote.value = '';
syncViewQuery(null);
}
if (key === 'custom') return;
const now = new Date();
const j = dateToJalali(now);
to.value = formatLocalDateInput(now);
if (key === 'today') {
from.value = formatLocalDateInput(now);
} else if (key === 'week') {
const d = new Date(now);
d.setDate(d.getDate() - 6);
from.value = formatLocalDateInput(d);
} else if (key === 'month') {
from.value = formatLocalDateInput(jalaliToDate(j.jy, j.jm, 1));
} else if (key === 'quarter') {
let jm = j.jm - 2;
let jy = j.jy;
if (jm < 1) {
jm += 12;
jy -= 1;
}
from.value = formatLocalDateInput(jalaliToDate(jy, jm, 1));
} else if (key === 'year') {
from.value = formatLocalDateInput(jalaliToDate(j.jy, 1, 1));
}
void load().then(() => {
if (pageMode.value === 'builder') void runBuilder();
});
}
function openCustom() {
period.value = 'custom';
showCustomDates.value = true;
activeViewId.value = null;
syncViewQuery(null);
}
function currentFilters(): ReportsViewFilters {
return {
period: period.value,
from: from.value || undefined,
to: to.value || undefined,
mode: pageMode.value,
builder: {
metric: builderMetric.value,
dimension: builderDimension.value,
userId: builderUserId.value || undefined,
teamId: builderTeamId.value || undefined,
},
};
}
function parseFilters(raw: Record<string, unknown>): ReportsViewFilters {
const periodRaw = String(raw.period ?? 'month');
const p = PERIOD_KEYS.has(periodRaw as PeriodKey)
? (periodRaw as PeriodKey)
: 'month';
const mode = raw.mode === 'builder' ? 'builder' : 'dashboard';
const builderRaw =
raw.builder && typeof raw.builder === 'object'
? (raw.builder as Record<string, unknown>)
: {};
const metric = String(builderRaw.metric ?? 'won_amount');
const dimension = String(builderRaw.dimension ?? 'owner');
return {
period: p,
from: typeof raw.from === 'string' ? raw.from : undefined,
to: typeof raw.to === 'string' ? raw.to : undefined,
mode,
builder: {
metric: REPORTS_BUILDER_METRICS.includes(metric as ReportsBuilderMetric)
? metric
: 'won_amount',
dimension: REPORTS_BUILDER_DIMENSIONS.includes(
dimension as ReportsBuilderDimension,
)
? dimension
: 'owner',
userId:
typeof builderRaw.userId === 'string' ? builderRaw.userId : undefined,
teamId:
typeof builderRaw.teamId === 'string' ? builderRaw.teamId : undefined,
},
};
}
function syncViewQuery(viewId: string | null) {
const nextQuery = { ...route.query } as Record<string, string | string[]>;
if (viewId) nextQuery.view = viewId;
else delete nextQuery.view;
const cur = typeof route.query.view === 'string' ? route.query.view : null;
if (cur === viewId) return;
void router.replace({ query: nextQuery });
}
async function loadSavedViews() {
viewsLoading.value = true;
viewsError.value = '';
try {
const { data: rows } = await api.get<SavedViewDto[]>('/reports/views');
savedViews.value = rows;
} catch (e) {
savedViews.value = [];
viewsError.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری نماهای ذخیره‌شده ناموفق بود.',
)
: 'بارگذاری نماهای ذخیره‌شده ناموفق بود.';
} finally {
viewsLoading.value = false;
}
}
function applyFilters(filters: ReportsViewFilters, keepView: boolean) {
pageMode.value = filters.mode === 'builder' ? 'builder' : 'dashboard';
if (filters.builder) {
const m = filters.builder.metric;
const d = filters.builder.dimension;
if (
m &&
REPORTS_BUILDER_METRICS.includes(m as ReportsBuilderMetric)
) {
builderMetric.value = m as ReportsBuilderMetric;
}
if (
d &&
REPORTS_BUILDER_DIMENSIONS.includes(d as ReportsBuilderDimension)
) {
builderDimension.value = d as ReportsBuilderDimension;
}
builderUserId.value = filters.builder.userId ?? '';
builderTeamId.value = filters.builder.teamId ?? '';
}
const afterRange = () => {
if (pageMode.value === 'builder') void runBuilder();
};
const p = PERIOD_KEYS.has(filters.period as PeriodKey)
? (filters.period as PeriodKey)
: 'month';
if (p === 'custom') {
period.value = 'custom';
showCustomDates.value = true;
from.value = filters.from || formatLocalDateInput(new Date());
to.value = filters.to || formatLocalDateInput(new Date());
if (!keepView) {
activeViewId.value = null;
syncViewQuery(null);
}
void load().then(afterRange);
return;
}
applyPeriod(p, { keepView });
}
async function loadFilterOptions() {
try {
const { data: users } = await api.get<OrgUserDto[]>('/users');
orgUsers.value = users.filter((u) => u.isActive !== false);
} catch {
orgUsers.value = [];
}
try {
const { data: teams } = await api.get<TeamDto[]>('/teams');
orgTeams.value = teams;
teamsAvailable.value = true;
} catch (e) {
orgTeams.value = [];
teamsAvailable.value =
!(axios.isAxiosError(e) && e.response?.status === 403);
}
}
async function runBuilder() {
builderLoading.value = true;
builderError.value = '';
try {
const { data: res } = await api.post<ReportsBuilderResultDto>(
'/reports/builder',
{
metric: builderMetric.value,
dimension: builderDimension.value,
from: from.value || undefined,
to: to.value || undefined,
userId: builderUserId.value || undefined,
teamId: builderTeamId.value || undefined,
},
);
builderResult.value = res;
} catch (e) {
builderResult.value = null;
builderError.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'اجرای گزارش‌ساز ناموفق بود.',
)
: 'اجرای گزارش‌ساز ناموفق بود.';
} finally {
builderLoading.value = false;
}
}
function setPageMode(mode: PageMode) {
pageMode.value = mode;
if (mode === 'builder' && !builderResult.value) void runBuilder();
if (mode === 'dashboard' && !data.value) void load();
}
const builderChartOption = computed<UiChartOption>(() => {
const rows = (builderResult.value?.rows ?? []).slice(0, 12);
const isMoney = builderResult.value?.format === 'money';
return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
if (!item) return '';
const val = isMoney ? money(item.value) : num(item.value);
return `${item.name}<br/><strong>${val}</strong>`;
},
},
grid: { left: 16, right: 16, top: 24, bottom: 48, containLabel: true },
xAxis: {
type: 'category',
data: rows.map((r) => r.label),
axisLabel: { rotate: rows.length > 5 ? 30 : 0, fontSize: 11 },
},
yAxis: { type: 'value' },
series: [
{
type: 'bar',
data: rows.map((r) => r.value),
itemStyle: { color: CHART_PALETTE[0] },
barMaxWidth: 42,
},
],
};
});
async function selectSavedView(id: string) {
if (!id) {
activeViewId.value = null;
syncViewQuery(null);
return;
}
try {
const { data: view } = await api.get<SavedViewDto>(`/reports/views/${id}`);
activeViewId.value = view.id;
syncViewQuery(view.id);
applyFilters(parseFilters(view.filters), true);
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'باز کردن نما ناموفق بود.',
)
: 'باز کردن نما ناموفق بود.';
}
}
function openSaveModal() {
saveName.value = activeView.value?.name ?? '';
saveShared.value = activeView.value?.isShared ?? true;
saveError.value = '';
saveOpen.value = true;
}
async function saveCurrentView() {
const name = saveName.value.trim();
if (!name) {
saveError.value = 'نام نما را وارد کنید.';
return;
}
saveBusy.value = true;
saveError.value = '';
try {
const payload = {
name,
filters: currentFilters() as unknown as Record<string, unknown>,
isShared: saveShared.value,
};
let saved: SavedViewDto;
if (activeViewId.value && canManageActiveView.value) {
const { data: row } = await api.patch<SavedViewDto>(
`/reports/views/${activeViewId.value}`,
payload,
);
saved = row;
} else {
const { data: row } = await api.post<SavedViewDto>(
'/reports/views',
payload,
);
saved = row;
}
saveOpen.value = false;
await loadSavedViews();
activeViewId.value = saved.id;
syncViewQuery(saved.id);
shareNote.value = saved.isShared
? 'نما ذخیره و برای سازمان قابل اشتراک شد.'
: 'نما ذخیره شد (فقط برای شما).';
} catch (e) {
saveError.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'ذخیره نما ناموفق بود.',
)
: 'ذخیره نما ناموفق بود.';
} finally {
saveBusy.value = false;
}
}
async function deleteActiveView() {
if (!activeViewId.value || !canManageActiveView.value) return;
if (!confirm('این نما حذف شود؟')) return;
try {
await api.delete(`/reports/views/${activeViewId.value}`);
activeViewId.value = null;
syncViewQuery(null);
await loadSavedViews();
shareNote.value = 'نما حذف شد.';
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'حذف نما ناموفق بود.',
)
: 'حذف نما ناموفق بود.';
}
}
async function copyShareLink() {
shareNote.value = '';
try {
let view = activeView.value;
if (!view) {
openSaveModal();
saveShared.value = true;
shareNote.value = 'ابتدا نما را ذخیره کنید، بعد لینک را کپی کنید.';
return;
}
if (!view.isShared && canManageActiveView.value) {
const { data: row } = await api.patch<SavedViewDto>(
`/reports/views/${view.id}`,
{ isShared: true },
);
view = row;
await loadSavedViews();
activeViewId.value = row.id;
}
if (!view.isShared) {
shareNote.value = 'این نما اشتراکی نیست؛ فقط مالک می‌تواند اشتراک بگذارد.';
return;
}
const path = router.resolve({
name: 'reports',
query: { view: view.id },
}).href;
const url = `${window.location.origin}${path}`;
await navigator.clipboard.writeText(url);
shareNote.value = 'لینک اشتراک کپی شد.';
} catch {
shareNote.value = 'کپی لینک ناموفق بود.';
}
}
async function loadTahesabSetup() {
if (!auth.user?.enabledModules?.includes('tahesab')) {
hasTahesabSetup.value = false;
return;
}
try {
const { data } = await api.get<TahesabConnectionDto[]>(
'/integrations/tahesab/connections/public',
);
hasTahesabSetup.value = (data ?? []).some((c) => c.enabled);
} catch {
hasTahesabSetup.value = false;
}
}
async function bootstrap() {
await Promise.all([
loadSavedViews(),
loadFilterOptions(),
loadTahesabSetup(),
]);
const viewId =
typeof route.query.view === 'string' ? route.query.view.trim() : '';
if (viewId) {
await selectSavedView(viewId);
return;
}
applyPeriod('month');
}
watch(
() => route.query.view,
(next, prev) => {
const id = typeof next === 'string' ? next : '';
const prevId = typeof prev === 'string' ? prev : '';
if (id && id !== prevId && id !== activeViewId.value) {
void selectSavedView(id);
}
},
);
onMounted(() => {
void bootstrap();
});
async function load() {
loading.value = true;
error.value = '';
try {
const [dash, orgRes] = await Promise.all([
api.get<ReportsDashboardDto>('/reports/dashboard', {
params: {
from: from.value || undefined,
to: to.value || undefined,
},
}),
api.get<OrganizationDto>('/organizations/current'),
]);
data.value = dash.data;
org.value = orgRes.data;
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری گزارش ناموفق بود.',
)
: 'بارگذاری گزارش ناموفق بود.';
data.value = null;
} finally {
loading.value = false;
}
}
function exportReport() {
if (pageMode.value === 'builder') {
if (!builderResult.value) return;
const b = builderResult.value;
const lines: string[] = [
`گزارش‌ساز ${brand.nameFa}`,
`بازه,${rangeLabel()}`,
`متریک,${b.metricLabel}`,
`بعد,${b.dimensionLabel}`,
`جمع,${b.total}`,
'',
`${b.dimensionLabel},${b.metricLabel}`,
...b.rows.map((r) => `${r.label},${r.value}`),
];
const blob = new Blob(['\ufeff' + lines.join('\n')], {
type: 'text/csv;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${brand.filePrefix}-builder-${from.value || 'from'}-${to.value || 'to'}.csv`;
a.click();
URL.revokeObjectURL(url);
return;
}
if (!data.value) return;
const d = data.value;
const lines: string[] = [
`گزارشات ${brand.nameFa}`,
`بازه,${rangeLabel()}`,
'',
'شاخص,مقدار,تغییر٪',
...d.kpis.map(
(k) =>
`${k.label},${k.value},${k.changePercent ?? ''}`,
),
'',
'روند فروش',
'برچسب,مبلغ',
...d.salesTrend.map((p) => `${p.label},${p.value}`),
'',
'منابع',
'منبع,تعداد,درصد',
...d.sources.map((s) => `${s.label},${s.count},${s.percent}`),
'',
'دلایل شکست معاملات',
'دلیل,تعداد,درصد,مبلغ',
...(d.lostReasons ?? []).map(
(s) => `${s.label},${s.count},${s.percent},${s.amount ?? ''}`,
),
'',
'پرفروش‌ها',
'نام,تعداد,مبلغ',
...d.topProducts.map((p) => `${p.name},${p.quantity},${p.amount}`),
'',
'تیم',
'نام,فعالیت,فروش',
...d.team.map(
(t) => `${t.fullName},${t.activitiesDone},${t.wonAmount}`,
),
'',
'نرخ تبدیل (ورودی به موفق)',
`کل,${d.conversionRate?.conversionPercent ?? ''}٪`,
`ورودی,${d.conversionRate?.incomingCount ?? 0}`,
`موفق,${d.conversionRate?.wonCount ?? 0}`,
'',
'نرخ تبدیل بر اساس زمان',
'بازه,ورودی,موفق,نرخ٪',
...(d.conversionRate?.byTime ?? []).map(
(r) =>
`${r.label},${r.incomingCount},${r.wonCount},${r.conversionPercent}`,
),
'',
'نرخ تبدیل بر اساس کاربر',
'کاربر,ورودی,موفق,نرخ٪',
...(d.conversionRate?.byUser ?? []).map(
(r) =>
`${r.label},${r.incomingCount},${r.wonCount},${r.conversionPercent}`,
),
];
const blob = new Blob(['\ufeff' + lines.join('\n')], {
type: 'text/csv;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${brand.filePrefix}-report-${from.value || 'from'}-${to.value || 'to'}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/* ——— ECharts Options ——— */
const salesTrendOption = computed<UiChartOption>(() => {
const trend = data.value?.salesTrend ?? [];
return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
if (!item) return '';
return `${item.name}<br/><strong>${money(item.value)}</strong>`;
},
},
grid: { left: '3%', right: '4%', bottom: '8%', top: '12%', containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: trend.map((p) => p.label),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { color: '#64748b', fontFamily: 'IRANSans' },
},
yAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
axisLabel: {
color: '#64748b',
fontFamily: 'IRANSans',
formatter: (v: number) => num(v),
},
},
series: [
{
name: 'فروش',
type: 'line',
smooth: true,
showSymbol: false,
symbolSize: 6,
itemStyle: { color: '#14b8a6' },
lineStyle: { width: 3, color: '#0d9488' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(20, 184, 166, 0.35)' },
{ offset: 1, color: 'rgba(20, 184, 166, 0.01)' },
],
},
},
data: trend.map((p) => p.value),
},
],
};
});
const sourcesOption = computed<UiChartOption>(() => {
const sources = data.value?.sources ?? [];
return {
tooltip: {
trigger: 'item',
formatter: (p: any) => `${p.name}: <strong>${num(p.value)}</strong> (${toFaDigits(p.percent)}٪)`,
},
legend: {
bottom: '0%',
left: 'center',
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
},
color: CHART_PALETTE,
series: [
{
name: 'منابع مشتریان',
type: 'pie',
radius: ['45%', '70%'],
center: ['50%', '42%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: sources.map((s) => ({ name: s.label, value: s.count })),
},
],
};
});
const lostReasonsOption = computed<UiChartOption>(() => {
const rows = data.value?.lostReasons ?? [];
return {
tooltip: {
trigger: 'item',
formatter: (p: any) => {
const row = rows.find((r) => r.label === p.name);
const amountPart =
row?.amount != null ? `<br/>مبلغ: <strong>${money(row.amount)}</strong>` : '';
return `${p.name}: <strong>${num(p.value)}</strong> (${toFaDigits(p.percent)}٪)${amountPart}`;
},
},
legend: {
bottom: '0%',
left: 'center',
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
},
color: CHART_PALETTE,
series: [
{
name: 'دلایل شکست',
type: 'pie',
radius: ['45%', '70%'],
center: ['50%', '42%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: rows.map((s) => ({ name: s.label, value: s.count })),
},
],
};
});
const monthlyOption = computed<UiChartOption>(() => {
const rows = data.value?.monthlyComparison ?? [];
return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
if (!item) return '';
return `${item.name}<br/><strong>${money(item.value)}</strong>`;
},
},
grid: { left: '3%', right: '4%', bottom: '8%', top: '12%', containLabel: true },
xAxis: {
type: 'category',
data: rows.map((r) => r.label),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { color: '#64748b', fontFamily: 'IRANSans' },
},
yAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
axisLabel: { color: '#64748b', fontFamily: 'IRANSans', formatter: (v: number) => num(v) },
},
series: [
{
name: 'فروش ماهانه',
type: 'bar',
barWidth: '38%',
itemStyle: { color: '#14b8a6', borderRadius: [6, 6, 0, 0] },
data: rows.map((r) => r.value),
},
],
};
});
const conversionTimeOption = computed<UiChartOption>(() => {
const rows = data.value?.conversionRate?.byTime ?? [];
return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const list = Array.isArray(params) ? params : [params];
const name = list[0]?.name ?? '';
const row = rows.find((r) => r.label === name);
if (!row) return name;
return `${name}<br/>نرخ تبدیل: <strong>${toFaDigits(row.conversionPercent)}٪</strong><br/>موفق: ${num(row.wonCount)} از ${num(row.incomingCount)}`;
},
},
grid: { left: '3%', right: '4%', bottom: '8%', top: '14%', containLabel: true },
legend: {
top: 0,
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
},
xAxis: {
type: 'category',
data: rows.map((r) => r.label),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { color: '#64748b', fontFamily: 'IRANSans' },
},
yAxis: [
{
type: 'value',
name: 'تعداد',
axisLine: { show: false },
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
axisLabel: {
color: '#64748b',
fontFamily: 'IRANSans',
formatter: (v: number) => num(v),
},
},
{
type: 'value',
name: 'نرخ ٪',
min: 0,
max: 100,
axisLine: { show: false },
splitLine: { show: false },
axisLabel: {
color: '#64748b',
fontFamily: 'IRANSans',
formatter: (v: number) => `${toFaDigits(v)}٪`,
},
},
],
series: [
{
name: 'ورودی',
type: 'bar',
barMaxWidth: 28,
itemStyle: { color: '#94a3b8', borderRadius: [6, 6, 0, 0] },
data: rows.map((r) => r.incomingCount),
},
{
name: 'موفق',
type: 'bar',
barMaxWidth: 28,
itemStyle: { color: '#14b8a6', borderRadius: [6, 6, 0, 0] },
data: rows.map((r) => r.wonCount),
},
{
name: 'نرخ تبدیل',
type: 'line',
yAxisIndex: 1,
smooth: true,
showSymbol: true,
symbolSize: 7,
itemStyle: { color: '#0f766e' },
lineStyle: { width: 2, color: '#0f766e' },
data: rows.map((r) => r.conversionPercent),
},
],
};
});
const topProductsOption = computed<UiChartOption>(() => {
const prods = (data.value?.topProducts ?? []).slice(0, 5).reverse();
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
if (!item) return '';
return `${item.name}: <strong>${num(item.value)} عدد</strong>`;
},
},
grid: { left: '3%', right: '8%', bottom: '4%', top: '4%', containLabel: true },
xAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: '#f1f5f9' } },
axisLabel: { color: '#64748b', fontFamily: 'IRANSans', formatter: (v: number) => num(v) },
},
yAxis: {
type: 'category',
data: prods.map((p) => p.name),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { color: '#334155', fontFamily: 'IRANSans' },
},
series: [
{
name: 'تعداد فروش',
type: 'bar',
barWidth: '45%',
itemStyle: { color: '#0f766e', borderRadius: [0, 6, 6, 0] },
data: prods.map((p) => p.quantity),
},
],
};
});
const categoryOption = computed<UiChartOption>(() => {
const cats = data.value?.salesByCategory ?? [];
return {
tooltip: {
trigger: 'item',
formatter: (p: any) => `${p.name}: <strong>${money(p.value)}</strong> (${toFaDigits(p.percent)}٪)`,
},
legend: {
bottom: '0%',
left: 'center',
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
},
color: CHART_PALETTE,
series: [
{
name: 'دسته‌بندی',
type: 'pie',
radius: ['45%', '70%'],
center: ['50%', '42%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: cats.map((c) => ({ name: c.label, value: c.amount ?? 0 })),
},
],
};
});
const statusOption = computed<UiChartOption>(() => {
const st = data.value?.customerStatus ?? [];
return {
tooltip: {
trigger: 'item',
formatter: (p: any) => `${p.name}: <strong>${num(p.value)}</strong> (${toFaDigits(p.percent)}٪)`,
},
legend: {
bottom: '0%',
left: 'center',
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
},
color: ['#10b981', '#f59e0b', '#3b82f6', '#94a3b8', '#ef4444'],
series: [
{
name: 'وضعیت مشتریان',
type: 'pie',
radius: ['45%', '70%'],
center: ['50%', '42%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: st.map((s) => ({ name: s.label, value: s.count })),
},
],
};
});
const growthOption = computed<UiChartOption>(() => {
const gr = data.value?.customerGrowth ?? [];
return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
if (!item) return '';
return `${item.name}<br/>رشد: <strong>${num(item.value)} مشتری</strong>`;
},
},
grid: { left: '3%', right: '4%', bottom: '8%', top: '12%', containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: gr.map((p) => p.label),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { color: '#64748b', fontFamily: 'IRANSans' },
},
yAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
axisLabel: { color: '#64748b', fontFamily: 'IRANSans', formatter: (v: number) => num(v) },
},
series: [
{
name: 'رشد مشتریان',
type: 'line',
smooth: true,
showSymbol: false,
symbolSize: 6,
itemStyle: { color: '#3b82f6' },
lineStyle: { width: 3, color: '#2563eb' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(59, 130, 246, 0.3)' },
{ offset: 1, color: 'rgba(59, 130, 246, 0.01)' },
],
},
},
data: gr.map((p) => p.value),
},
],
};
});
const activityMax = computed(() =>
Math.max(
...(data.value?.activities.byType.map((t) => t.count) ?? [0]),
1,
),
);
</script>
<template>
<section class="crm-page">
<UiPageHeader
title="گزارشات و داشبورد"
lead="نمای جامع عملکرد فروش، مشتریان، فعالیت‌ها و تیم"
>
<template #actions>
<UiButton
v-if="showTahesabSalesReport"
variant="secondary"
@click="router.push({ name: 'tahesab-sales-report' })"
>
<UiIcon name="chart" :size="16" />
فروش ته‌حساب
</UiButton>
<UiButton variant="secondary" @click="openSaveModal">
<UiIcon name="layers" :size="16" />
ذخیره نما
</UiButton>
<UiButton variant="secondary" @click="copyShareLink">
<UiIcon name="send" :size="16" />
کپی لینک اشتراک
</UiButton>
<UiButton
variant="primary"
:disabled="pageMode === 'dashboard' ? !data : !builderResult"
@click="exportReport"
>
<UiIcon name="document" :size="16" />
خروجی اکسل / CSV
</UiButton>
</template>
</UiPageHeader>
<div class="mode-tabs">
<button
type="button"
class="mode-tab"
:class="{ on: pageMode === 'dashboard' }"
@click="setPageMode('dashboard')"
>
داشبورد
</button>
<button
type="button"
class="mode-tab"
:class="{ on: pageMode === 'builder' }"
@click="setPageMode('builder')"
>
گزارش‌ساز
</button>
</div>
<!-- Toolbar Filters -->
<div class="crm-toolbar filter-toolbar">
<div class="period-chips">
<button
v-for="p in PERIODS"
:key="p.key"
type="button"
class="chip"
:class="{ on: period === p.key }"
@click="applyPeriod(p.key)"
>
{{ p.label }}
</button>
</div>
<button type="button" class="range-btn" @click="openCustom">
<UiIcon name="calendar" :size="16" />
<span>{{ rangeLabel() }}</span>
</button>
<label class="view-select">
<span>نماهای ذخیره‌شده</span>
<select
:value="activeViewId ?? ''"
:disabled="viewsLoading"
@change="
selectSavedView(($event.target as HTMLSelectElement).value)
"
>
<option value="">پیش‌فرض</option>
<option v-for="v in savedViews" :key="v.id" :value="v.id">
{{ v.name }}{{ v.isShared ? ' (سازمانی)' : '' }}
</option>
</select>
</label>
<UiButton
v-if="activeViewId && canManageActiveView"
variant="secondary"
@click="deleteActiveView"
>
حذف نما
</UiButton>
</div>
<p v-if="shareNote" class="ok-note">{{ shareNote }}</p>
<p v-if="viewsError" class="error">{{ viewsError }}</p>
<!-- Custom date inputs -->
<form
v-if="showCustomDates"
class="custom-range"
@submit.prevent="
activeViewId = null;
syncViewQuery(null);
load().then(() => {
if (pageMode === 'builder') runBuilder();
});
"
>
<JalaliDateInput v-model="from" placeholder="از تاریخ" />
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
<UiButton variant="primary" type="submit" :disabled="loading || builderLoading">
اعمال
</UiButton>
</form>
<p v-if="error" class="error">{{ error }}</p>
<!-- Builder mode -->
<template v-if="pageMode === 'builder'">
<UiCard class="builder-card" title="گزارش‌ساز ساده">
<p class="deep-hint">
متریک و بُعد را انتخاب کنید؛ می‌توانید بازه بالا و فیلتر کاربر/تیم را هم اعمال کنید.
</p>
<div class="builder-form">
<UiField label="متریک">
<select v-model="builderMetric" class="builder-select">
<option
v-for="m in REPORTS_BUILDER_METRICS"
:key="m"
:value="m"
>
{{ REPORTS_BUILDER_METRIC_LABELS[m] }}
</option>
</select>
</UiField>
<UiField label="گروه‌بندی (بُعد)">
<select v-model="builderDimension" class="builder-select">
<option
v-for="d in REPORTS_BUILDER_DIMENSIONS"
:key="d"
:value="d"
>
{{ REPORTS_BUILDER_DIMENSION_LABELS[d] }}
</option>
</select>
</UiField>
<UiField label="کاربر">
<select v-model="builderUserId" class="builder-select">
<option value="">همه کاربران مجاز</option>
<option v-for="u in orgUsers" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</UiField>
<UiField label="تیم">
<select
v-model="builderTeamId"
class="builder-select"
:disabled="!teamsAvailable"
>
<option value="">همه تیم‌ها</option>
<option v-for="t in orgTeams" :key="t.id" :value="t.id">
{{ t.name }}
</option>
</select>
</UiField>
<div class="builder-actions">
<UiButton :loading="builderLoading" @click="runBuilder">
اجرای گزارش
</UiButton>
</div>
</div>
<p v-if="!teamsAvailable" class="deep-hint">
ماژول تیم‌ها برای این محیط فعال نیست؛ فیلتر تیم در دسترس نیست.
</p>
<p v-if="builderError" class="error">{{ builderError }}</p>
</UiCard>
<div v-if="builderLoading" class="loading-wrap">
<div class="spinner"></div>
<span>در حال ساخت گزارش...</span>
</div>
<template v-else-if="builderResult">
<div class="builder-kpi">
<article class="kpi-card">
<div class="kpi-top">
<span class="kpi-label">{{ builderResult.metricLabel }}</span>
</div>
<strong class="kpi-val">
{{
builderResult.format === 'money'
? money(builderResult.total)
: num(builderResult.total)
}}
</strong>
<div class="kpi-change flat">
<small>{{ builderResult.dimensionLabel }}</small>
</div>
</article>
</div>
<div class="charts-grid">
<UiCard class="chart-card span-2" title="نمودار">
<UiChart
v-if="builderResult.rows.length"
:option="builderChartOption"
height="300px"
/>
<p v-else class="empty-msg">داده‌ای برای نمایش نیست.</p>
</UiCard>
<UiCard class="chart-card" title="جدول">
<table v-if="builderResult.rows.length" class="compare-table">
<thead>
<tr>
<th>{{ builderResult.dimensionLabel }}</th>
<th>{{ builderResult.metricLabel }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in builderResult.rows" :key="row.key">
<td>{{ row.label }}</td>
<td>
{{
builderResult.format === 'money'
? money(row.value)
: num(row.value)
}}
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-msg">ردیفی نیست.</p>
</UiCard>
</div>
</template>
</template>
<!-- Dashboard mode -->
<div v-else-if="loading" class="loading-wrap">
<div class="spinner"></div>
<span>در حال بارگذاری گزارشات...</span>
</div>
<template v-else-if="pageMode === 'dashboard' && data">
<!-- KPI row -->
<div class="kpis">
<article v-for="k in data.kpis" :key="k.key" class="kpi-card">
<div class="kpi-top">
<span class="kpi-ico">
<UiIcon
:name="
k.key === 'new_customers'
? 'users'
: k.key === 'invoice_count'
? 'document'
: k.key === 'sales_amount'
? 'trend'
: 'dollar'
"
:size="18"
/>
</span>
<span class="kpi-label">{{ k.label }}</span>
</div>
<strong class="kpi-val">{{ formatKpi(k.value, k.format) }}</strong>
<div class="kpi-change" :class="{ down: (k.changePercent ?? 0) < 0, flat: k.changePercent == null }">
<span>{{ formatChange(k.changePercent) }}</span>
<small>{{ k.changeLabel }}</small>
</div>
</article>
</div>
<!-- Deep analytics (BI phase 1) -->
<div class="deep-grid">
<UiCard class="chart-card" title="تبدیل مراحل مسیر فروش">
<p class="deep-hint">
سهم هر مرحله از معاملات باز و نرخ عبور نسبت به مرحله قبل. برای دیدن پرونده‌ها روی مرحله کلیک کنید.
</p>
<div v-if="funnelByPipeline.length" class="funnel-wrap">
<div
v-for="pipe in funnelByPipeline"
:key="pipe.name"
class="funnel-pipe"
>
<h4 class="funnel-pipe-title">{{ pipe.name }}</h4>
<button
v-for="s in pipe.stages"
:key="s.stageId"
type="button"
class="funnel-row"
@click="openDrill('stage_open', s.stageId)"
>
<div class="funnel-row-top">
<strong>{{ s.stageName }}</strong>
<span>{{ num(s.count) }} · {{ money(s.amount) }}</span>
</div>
<div class="funnel-bar-track">
<div
class="funnel-bar-fill"
:style="{
width: `${Math.max(s.shareOfPipelinePercent, s.count ? 4 : 0)}%`,
}"
/>
</div>
<div class="funnel-row-meta">
<small>سهم از مسیر: {{ toFaDigits(s.shareOfPipelinePercent) }}٪</small>
<small v-if="s.conversionFromPrevPercent != null">
تبدیل از قبل: {{ toFaDigits(s.conversionFromPrevPercent) }}٪
</small>
<small v-else>شروع مسیر</small>
</div>
</button>
</div>
</div>
<p v-else class="empty-msg">معامله بازی در مسیر فروش نیست.</p>
</UiCard>
<UiCard class="chart-card" title="مقایسه با دوره قبل">
<p class="deep-hint">
بازه فعلی در برابر دوره هم‌طول قبلی. ردیف‌های قابل‌کلیک فهرست معاملات را باز می‌کنند.
</p>
<table v-if="data.periodCompare?.length" class="compare-table">
<thead>
<tr>
<th>شاخص</th>
<th>فعلی</th>
<th>قبل</th>
<th>تغییر</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in data.periodCompare"
:key="row.key"
:class="{ clickable: Boolean(row.drillKind) }"
@click="row.drillKind && openDrill(row.drillKind)"
>
<td>{{ row.label }}</td>
<td>{{ formatCompareValue(row.current, row.format) }}</td>
<td>{{ formatCompareValue(row.previous, row.format) }}</td>
<td
:class="{
up: (row.changePercent ?? 0) > 0,
down: (row.changePercent ?? 0) < 0,
}"
>
{{ formatChange(row.changePercent) }}
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-msg">داده‌ای برای مقایسه نیست.</p>
</UiCard>
</div>
<div class="deep-grid conversion-grid">
<UiCard class="chart-card span-2" title="نرخ تبدیل">
<p class="deep-hint">
از معاملات تازه‌ثبت‌شده در این بازه، چند درصد تا الان به فروش موفق رسیده‌اند
(متفاوت از «نرخ برد» که فقط معاملات بسته‌شده را می‌بیند).
</p>
<div
v-if="data.conversionRate"
class="conversion-summary"
>
<div class="conversion-stat">
<span>نرخ تبدیل</span>
<strong>
{{ toFaDigits(data.conversionRate.conversionPercent) }}٪
</strong>
<small>
{{ formatChange(data.conversionRate.changePercent) }} نسبت به دوره قبل
</small>
</div>
<div class="conversion-stat">
<span>ورودی دوره</span>
<strong>{{ num(data.conversionRate.incomingCount) }}</strong>
<small>
دوره قبل {{ num(data.conversionRate.previousIncomingCount) }}
</small>
</div>
<div class="conversion-stat">
<span>موفق از همین ورودی‌ها</span>
<strong>{{ num(data.conversionRate.wonCount) }}</strong>
<small>
دوره قبل {{ num(data.conversionRate.previousWonCount) }}
</small>
</div>
</div>
<UiChart
v-if="data.conversionRate?.byTime?.length"
:option="conversionTimeOption"
height="240px"
/>
<p v-else class="empty-msg">در این بازه معامله ورودی ثبت نشده است.</p>
</UiCard>
<UiCard class="chart-card" title="نرخ تبدیل بر اساس کاربر">
<p class="deep-hint">
کیفیت تبدیل فرصت‌های هر فروشنده در همین بازه.
</p>
<ul
v-if="data.conversionRate?.byUser?.length"
class="conversion-list"
>
<li
v-for="u in data.conversionRate.byUser.slice(0, 10)"
:key="u.key"
>
<div class="conversion-list-top">
<strong>{{ u.label }}</strong>
<em>{{ toFaDigits(u.conversionPercent) }}٪</em>
</div>
<div class="hb-track">
<div
class="hb-fill"
:style="{
width: `${Math.max(4, Math.min(100, u.conversionPercent))}%`,
}"
/>
</div>
<small>
{{ num(u.wonCount) }} موفق از {{ num(u.incomingCount) }} ورودی
</small>
</li>
</ul>
<p v-else class="empty-msg">داده کاربری نیست.</p>
</UiCard>
<UiCard class="chart-card" title="نرخ تبدیل بر اساس منبع">
<p class="deep-hint">
کدام شیوه آشنایی / منبع ورودی بهتر به فروش می‌رسد.
</p>
<ul
v-if="data.conversionRate?.bySource?.length"
class="conversion-list"
>
<li
v-for="s in data.conversionRate.bySource.slice(0, 10)"
:key="s.key"
>
<div class="conversion-list-top">
<strong>{{ s.label }}</strong>
<em>{{ toFaDigits(s.conversionPercent) }}٪</em>
</div>
<div class="hb-track">
<div
class="hb-fill"
:style="{
width: `${Math.max(4, Math.min(100, s.conversionPercent))}%`,
}"
/>
</div>
<small>
{{ num(s.wonCount) }} موفق از {{ num(s.incomingCount) }} ورودی
</small>
</li>
</ul>
<p v-else class="empty-msg">منبعی ثبت نشده است.</p>
</UiCard>
</div>
<div class="charts-grid">
<!-- Sales trend (wide) -->
<UiCard class="chart-card span-2" title="روند فروش">
<UiChart
v-if="data.salesTrend?.length"
:option="salesTrendOption"
height="260px"
/>
<p v-else class="empty-msg">داده‌ای برای روند فروش نیست.</p>
</UiCard>
<!-- Customer sources -->
<UiCard class="chart-card" title="منابع مشتریان">
<UiChart
v-if="data.sources?.length"
:option="sourcesOption"
height="220px"
/>
<ul v-if="data.sources?.length" class="drill-links">
<li v-for="s in data.sources" :key="s.label">
<button type="button" @click="openDrill('source', s.label)">
{{ s.label }}
<span>{{ num(s.count) }}</span>
</button>
</li>
</ul>
<p v-else class="empty-msg">منبعی ثبت نشده است.</p>
</UiCard>
<!-- Lost reasons -->
<UiCard class="chart-card" title="دلایل عدم موفقیت فروش">
<UiChart
v-if="data.lostReasons?.length"
:option="lostReasonsOption"
height="220px"
/>
<ul v-if="data.lostReasons?.length" class="drill-links">
<li v-for="s in data.lostReasons" :key="s.label">
<button type="button" @click="openDrill('lost_reason', s.label)">
{{ s.label }}
<span>{{ num(s.count) }}</span>
</button>
</li>
</ul>
<p v-else class="empty-msg">معامله شکست‌خورده‌ای در این بازه نیست.</p>
</UiCard>
<!-- Monthly comparison -->
<UiCard class="chart-card" title="مقایسه فروش ماه‌های اخیر">
<UiChart
v-if="data.monthlyComparison?.length"
:option="monthlyOption"
height="260px"
/>
<p v-else class="empty-msg">داده‌ای نیست.</p>
</UiCard>
<!-- Top products -->
<UiCard class="chart-card" title="پرفروش‌ترین محصولات">
<UiChart
v-if="data.topProducts?.length"
:option="topProductsOption"
height="260px"
/>
<p v-else class="empty-msg">فروش محصولی در این بازه نیست.</p>
</UiCard>
<!-- Sales by category -->
<UiCard class="chart-card" title="فروش بر اساس دسته‌بندی">
<UiChart
v-if="data.salesByCategory?.length"
:option="categoryOption"
height="260px"
/>
<p v-else class="empty-msg">دسته‌بندی فروشی نیست.</p>
</UiCard>
<!-- Customer status -->
<UiCard class="chart-card" title="وضعیت مشتریان">
<UiChart
v-if="data.customerStatus?.length"
:option="statusOption"
height="260px"
/>
<p v-else class="empty-msg">مشتری ثبت نشده است.</p>
</UiCard>
<!-- Customer growth -->
<UiCard class="chart-card" title="رشد مشتریان">
<UiChart
v-if="data.customerGrowth?.length"
:option="growthOption"
height="260px"
/>
<p v-else class="empty-msg">داده‌ای نیست.</p>
</UiCard>
<!-- Team performance -->
<UiCard class="chart-card" title="عملکرد تیم">
<p class="deep-hint">برای دیدن معاملات برنده هر نفر کلیک کنید.</p>
<ul v-if="data.team?.length" class="team-list">
<li v-for="u in data.team.slice(0, 8)" :key="u.userId">
<button
type="button"
class="team-row-btn"
@click="openDrill('team_won', u.userId)"
>
<span class="avatar">{{ u.fullName.slice(0, 1) }}</span>
<div class="team-meta">
<strong>{{ u.fullName }}</strong>
<small>
{{ num(u.wonCount) }} معامله ·
{{ num(u.activitiesDone) }} فعالیت
</small>
</div>
<em class="team-won">{{ money(u.wonAmount) }}</em>
</button>
</li>
</ul>
<p v-else class="empty-msg">داده تیمی نیست.</p>
</UiCard>
<!-- Activities stats -->
<UiCard class="chart-card" title="فعالیت‌ها">
<div class="act-stats">
<div class="act-stat-box">
<span>باز</span>
<strong>{{ num(data.activities.open) }}</strong>
</div>
<div class="act-stat-box done">
<span>انجام‌شده</span>
<strong>{{ num(data.activities.done) }}</strong>
</div>
<div class="act-stat-box overdue">
<span>عقب‌افتاده</span>
<strong>{{ num(data.activities.overdue) }}</strong>
</div>
</div>
<ul v-if="data.activities.byType.length" class="act-bars">
<li v-for="t in data.activities.byType" :key="t.type">
<span class="act-bar-label">{{ t.label }}</span>
<div class="hb-track">
<div
class="hb-fill"
:style="{
width: `${Math.max(6, Math.round((t.count / activityMax) * 100))}%`,
}"
/>
</div>
<em>{{ num(t.count) }}</em>
</li>
</ul>
<p v-else class="empty-msg">فعالیتی در بازه نیست.</p>
</UiCard>
<!-- AI Insights -->
<UiCard class="chart-card span-full" title="تحلیل هوشمند">
<template #header>
<div class="insights-head">
<UiIcon name="sparkle" :size="18" class="sparkle-ico" />
<h3 class="card-title">تحلیل هوشمند و پیش‌بینی</h3>
</div>
</template>
<ul v-if="data.insights?.length" class="insight-list">
<li v-for="(line, i) in data.insights" :key="i">
<UiIcon name="check" :size="16" class="check-ico" />
<span>{{ line }}</span>
</li>
</ul>
<p v-else class="empty-msg">با ثبت فروش و مشتری، تحلیل‌های هوشمند به طور خودکار نمایش داده می‌شوند.</p>
<div class="suggest-box">
<UiIcon name="lightbulb" :size="18" />
<span>پیشنهاد: روی دسته‌ها و منابع مشتری با نرخ رشد و حاشیه سود بالاتر تمرکز کنید.</span>
</div>
</UiCard>
</div>
</template>
<UiModal
:open="drillOpen"
:title="drill?.title || 'فهرست پرونده‌ها'"
@close="closeDrill"
>
<div v-if="drillLoading" class="loading-wrap">
<div class="spinner"></div>
<span>در حال بارگذاری...</span>
</div>
<p v-else-if="drillError" class="error">{{ drillError }}</p>
<template v-else-if="drill">
<p class="deep-hint">
{{ toFaDigits(drill.total) }} مورد
<span v-if="drill.total > drill.items.length">
(نمایش {{ toFaDigits(drill.items.length) }} مورد اول)
</span>
</p>
<ul v-if="drill.items.length" class="drill-list">
<li v-for="item in drill.items" :key="item.id">
<button type="button" class="drill-item" @click="openDeal(item.link)">
<div>
<strong>{{ item.title }}</strong>
<small>
{{ item.statusLabel }}
<template v-if="item.stageName"> · {{ item.stageName }}</template>
<template v-if="item.ownerName"> · {{ item.ownerName }}</template>
</small>
</div>
<em>{{ formatMoney(item.amount, item.currency || currency) }}</em>
</button>
</li>
</ul>
<p v-else class="empty-msg">موردی یافت نشد.</p>
</template>
</UiModal>
<UiModal
:open="saveOpen"
:title="activeViewId && canManageActiveView ? 'به‌روزرسانی نما' : 'ذخیره نمای گزارش'"
@close="saveOpen = false"
>
<div class="save-form">
<UiField label="نام نما" for-id="report-view-name">
<UiInput
id="report-view-name"
v-model="saveName"
placeholder="مثلاً فروش ماه جاری تیم"
/>
</UiField>
<label class="share-check">
<input v-model="saveShared" type="checkbox" />
<span>اشتراک با اعضای سازمان (قابل باز شدن با لینک)</span>
</label>
<p v-if="saveError" class="error">{{ saveError }}</p>
</div>
<template #footer>
<UiButton variant="secondary" @click="saveOpen = false">انصراف</UiButton>
<UiButton :loading="saveBusy" @click="saveCurrentView">ذخیره</UiButton>
</template>
</UiModal>
</section>
</template>
<style scoped>
.mode-tabs {
display: inline-flex;
gap: 2px;
background: var(--surface-subtle);
border: 1px solid var(--line);
padding: 3px;
border-radius: var(--radius);
margin-bottom: var(--sp-3);
}
.mode-tab {
border: 0;
background: transparent;
padding: 0 var(--sp-4);
min-height: calc(var(--control-h-md) - 6px);
border-radius: calc(var(--radius) - 2px);
font: inherit;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--muted);
cursor: pointer;
}
.mode-tab.on {
background: var(--surface);
color: var(--accent-deep);
box-shadow: var(--shadow-xs);
font-weight: var(--fw-bold);
}
.builder-card {
margin-bottom: var(--sp-4);
}
.builder-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
gap: var(--sp-3);
align-items: end;
}
.builder-select {
width: 100%;
font: inherit;
min-height: var(--control-h-md);
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.4rem 0.55rem;
background: #fff;
}
.builder-actions {
display: flex;
align-items: end;
}
.builder-kpi {
display: grid;
grid-template-columns: minmax(220px, 320px);
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
@media (max-width: 1000px) {
.builder-form {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) {
.builder-form {
grid-template-columns: 1fr;
}
}
.view-select {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
min-height: var(--control-h-md);
padding: 0 var(--sp-3);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--fs-xs);
color: var(--ink-secondary);
}
.view-select select {
font: inherit;
border: 0;
background: transparent;
color: var(--ink);
max-width: 220px;
}
.ok-note {
color: #0f766e;
font-weight: 600;
margin: 0 0 var(--sp-3);
}
.save-form {
display: grid;
gap: var(--sp-3);
}
.share-check {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: var(--fs-sm);
color: var(--ink-secondary);
}
.deep-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-4);
margin-bottom: var(--sp-4);
}
.deep-hint {
margin: 0 0 var(--sp-3);
color: var(--muted);
font-size: var(--fs-xs);
line-height: 1.6;
}
.conversion-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.conversion-stat {
display: grid;
gap: 0.25rem;
padding: 0.75rem 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-subtle, #f8fafc);
}
.conversion-stat span {
font-size: var(--fs-xs);
color: var(--muted);
}
.conversion-stat strong {
font-size: 1.35rem;
color: var(--accent-deep, #0f766e);
}
.conversion-stat small {
font-size: var(--fs-xs);
color: var(--ink-secondary);
}
.conversion-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.conversion-list li {
display: grid;
gap: 0.3rem;
}
.conversion-list-top {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: baseline;
}
.conversion-list-top em {
font-style: normal;
font-weight: 700;
color: var(--accent-deep, #0f766e);
}
.conversion-list small {
color: var(--muted);
font-size: var(--fs-xs);
}
.funnel-wrap {
display: grid;
gap: var(--sp-4);
}
.funnel-pipe-title {
margin: 0 0 var(--sp-2);
font-size: var(--fs-sm);
color: var(--ink-secondary);
}
.funnel-row {
display: grid;
gap: 0.35rem;
width: 100%;
text-align: start;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius);
padding: 0.65rem 0.75rem;
margin-bottom: 0.45rem;
cursor: pointer;
font: inherit;
color: inherit;
}
.funnel-row:hover {
border-color: var(--accent, #0f766e);
}
.funnel-row-top,
.funnel-row-meta {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.funnel-row-meta {
color: var(--muted);
font-size: var(--fs-xs);
}
.funnel-bar-track {
height: 6px;
border-radius: 999px;
background: var(--surface-subtle, #f1f5f9);
overflow: hidden;
}
.funnel-bar-fill {
height: 100%;
background: var(--accent, #0f766e);
border-radius: 999px;
}
.compare-table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-sm);
}
.compare-table th,
.compare-table td {
padding: 0.55rem 0.35rem;
border-bottom: 1px solid var(--line);
text-align: start;
}
.compare-table th {
color: var(--muted);
font-weight: var(--fw-semibold);
font-size: var(--fs-xs);
}
.compare-table tr.clickable {
cursor: pointer;
}
.compare-table tr.clickable:hover td {
background: var(--surface-subtle, #f8fafc);
}
.compare-table .up {
color: #0f766e;
font-weight: 600;
}
.compare-table .down {
color: #b91c1c;
font-weight: 600;
}
.drill-links {
list-style: none;
margin: var(--sp-3) 0 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.drill-links button {
width: 100%;
display: flex;
justify-content: space-between;
gap: 0.75rem;
border: 0;
background: var(--surface-subtle, #f8fafc);
border-radius: 8px;
padding: 0.45rem 0.6rem;
font: inherit;
cursor: pointer;
color: inherit;
}
.drill-links button:hover {
background: #eef2f7;
}
.team-row-btn {
width: 100%;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.65rem;
align-items: center;
border: 0;
background: transparent;
padding: 0.35rem 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: start;
}
.team-row-btn:hover strong {
color: var(--accent-deep, #0f766e);
}
.drill-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.drill-item {
width: 100%;
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
border: 1px solid var(--line);
background: #fff;
border-radius: 10px;
padding: 0.7rem 0.8rem;
font: inherit;
cursor: pointer;
text-align: start;
color: inherit;
}
.drill-item:hover {
border-color: var(--accent, #0f766e);
}
.drill-item small {
display: block;
margin-top: 0.2rem;
color: var(--muted);
}
.drill-item em {
font-style: normal;
font-weight: 700;
white-space: nowrap;
}
@media (max-width: 900px) {
.deep-grid {
grid-template-columns: 1fr;
}
.conversion-summary {
grid-template-columns: 1fr;
}
}
.filter-toolbar {
display: flex;
align-items: center;
gap: var(--sp-3);
flex-wrap: wrap;
}
.period-chips {
display: inline-flex;
align-items: center;
gap: 2px;
background: var(--surface-subtle);
border: 1px solid var(--line);
padding: 3px;
border-radius: var(--radius);
}
.chip {
border: 0;
background: transparent;
padding: 0 var(--sp-3);
min-height: calc(var(--control-h-md) - 6px);
border-radius: calc(var(--radius) - 2px);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
color: var(--muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.chip.on {
background: var(--surface);
color: var(--accent-deep);
box-shadow: var(--shadow-xs);
font-weight: var(--fw-bold);
}
.range-btn {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
min-height: var(--control-h-md);
padding: 0 var(--sp-3);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink-secondary);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
cursor: pointer;
}
.custom-range {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
flex-wrap: wrap;
}
.loading-wrap {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-3);
padding: var(--sp-10);
color: var(--muted);
font-size: var(--fs-base);
}
/* KPI Cards */
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--sp-4);
}
.kpi-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--sp-4);
box-shadow: var(--shadow-xs);
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.kpi-top {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.kpi-ico {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: var(--radius-sm);
background: var(--accent-soft);
color: var(--accent-deep);
}
.kpi-label {
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
color: var(--muted);
}
.kpi-val {
font-size: var(--fs-xl);
font-weight: var(--fw-extrabold);
color: var(--ink);
line-height: var(--lh-tight);
}
.kpi-change {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
color: var(--success);
}
.kpi-change.down {
color: var(--danger);
}
.kpi-change.flat {
color: var(--muted);
}
.kpi-change small {
color: var(--muted);
font-weight: normal;
}
/* Charts Grid */
.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--sp-4);
}
.chart-card.span-2 {
grid-column: span 2;
}
.chart-card.span-full {
grid-column: 1 / -1;
}
.empty-msg {
text-align: center;
padding: var(--sp-6);
color: var(--muted);
font-size: var(--fs-sm);
}
/* Team List */
.team-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.team-list li {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) 0;
border-bottom: 1px solid var(--line-light);
}
.team-list li:last-child {
border-bottom: none;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent-deep);
display: grid;
place-items: center;
font-weight: var(--fw-bold);
font-size: var(--fs-xs);
}
.team-meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.team-meta strong {
font-size: var(--fs-sm);
color: var(--ink);
}
.team-meta small {
font-size: var(--fs-xs);
color: var(--muted);
}
.team-won {
font-style: normal;
font-weight: var(--fw-bold);
font-size: var(--fs-sm);
color: var(--accent-deep);
}
/* Activity Stats */
.act-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--sp-2);
margin-bottom: var(--sp-4);
}
.act-stat-box {
background: var(--surface-subtle);
border-radius: var(--radius-sm);
padding: var(--sp-2);
text-align: center;
display: flex;
flex-direction: column;
gap: 2px;
}
.act-stat-box span {
font-size: var(--fs-xs);
color: var(--muted);
}
.act-stat-box strong {
font-size: var(--fs-base);
color: var(--ink);
}
.act-stat-box.done strong {
color: var(--success);
}
.act-stat-box.overdue strong {
color: var(--danger);
}
.act-bars {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.act-bars li {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-xs);
}
.act-bar-label {
flex: 0 0 70px;
color: var(--ink-secondary);
}
.hb-track {
flex: 1;
height: 8px;
background: var(--surface-subtle);
border-radius: var(--radius-full);
overflow: hidden;
}
.hb-fill {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
}
/* Insights */
.insights-head {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.sparkle-ico {
color: var(--accent);
}
.card-title {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-bold);
color: var(--ink);
}
.insight-list {
list-style: none;
margin: 0 0 var(--sp-4);
padding: 0;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.insight-list li {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-sm);
color: var(--ink-secondary);
background: var(--surface-subtle);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-sm);
}
.check-ico {
color: var(--success);
flex-shrink: 0;
}
.suggest-box {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3);
background: var(--accent-subtle);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
border-radius: var(--radius);
color: var(--accent-deep);
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
}
@media (max-width: 900px) {
.charts-grid {
grid-template-columns: 1fr;
}
.chart-card.span-2 {
grid-column: auto;
}
}
</style>