Files
CRM_Montzar/apps/web/src/views/ReportsView.vue
T

2585 lines
71 KiB
Vue
Raw Normal View History

<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,
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 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 bootstrap() {
await Promise.all([loadSavedViews(), loadFilterOptions()]);
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="auth.user?.enabledModules?.includes('tahesab')"
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>