- Implement unified design system tokens, 10px radius, and IRANSans typography - Modernize sidebar with Gemini-style collapsed expander, square 1:1 items, and elevated profile flyout - Standardize all modal dialogs with 16px padding, fixed header/footer, 16px medium title, and sticky bottom actions - Refactor DealFormView and CustomFieldsSection with standard UiField, UiInput, and UiSelect components - Add draggable bottom-left QuickCreate floating action button
1051 lines
27 KiB
Vue
1051 lines
27 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
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,
|
||
UiPageHeader,
|
||
UiChart,
|
||
CHART_PALETTE,
|
||
type UiChartOption,
|
||
} from '@/components/ui';
|
||
import type { OrganizationDto, ReportsDashboardDto } from '@mento/shared';
|
||
import { brand } from '@mento/shared';
|
||
|
||
type PeriodKey = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'custom';
|
||
|
||
const PERIODS: Array<{ key: PeriodKey; label: string }> = [
|
||
{ key: 'today', label: 'امروز' },
|
||
{ key: 'week', label: 'هفته' },
|
||
{ key: 'month', label: 'ماه' },
|
||
{ key: 'quarter', label: 'سهماهه' },
|
||
{ key: 'year', label: 'سال' },
|
||
];
|
||
|
||
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 currency = computed(
|
||
() => data.value?.currency || org.value?.baseCurrency || 'IRR',
|
||
);
|
||
|
||
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 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 '—';
|
||
}
|
||
}
|
||
|
||
function applyPeriod(key: PeriodKey) {
|
||
period.value = key;
|
||
showCustomDates.value = key === 'custom';
|
||
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();
|
||
}
|
||
|
||
function openCustom() {
|
||
period.value = 'custom';
|
||
showCustomDates.value = true;
|
||
}
|
||
|
||
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 (!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.topProducts.map((p) => `${p.name},${p.quantity},${p.amount}`),
|
||
'',
|
||
'تیم',
|
||
'نام,فعالیت,فروش',
|
||
...d.team.map(
|
||
(t) => `${t.fullName},${t.activitiesDone},${t.wonAmount}`,
|
||
),
|
||
];
|
||
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 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 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,
|
||
),
|
||
);
|
||
|
||
onMounted(() => {
|
||
applyPeriod('month');
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<section class="crm-page">
|
||
<UiPageHeader
|
||
title="گزارشات و داشبورد"
|
||
lead="نمای جامع عملکرد فروش، مشتریان، فعالیتها و تیم"
|
||
>
|
||
<template #actions>
|
||
<UiButton
|
||
variant="primary"
|
||
:disabled="!data"
|
||
@click="exportReport"
|
||
>
|
||
<UiIcon name="document" :size="16" />
|
||
خروجی اکسل / CSV
|
||
</UiButton>
|
||
</template>
|
||
</UiPageHeader>
|
||
|
||
<!-- 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>
|
||
</div>
|
||
|
||
<!-- Custom date inputs -->
|
||
<form v-if="showCustomDates" class="custom-range" @submit.prevent="load">
|
||
<JalaliDateInput v-model="from" placeholder="از تاریخ" />
|
||
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
|
||
<UiButton variant="primary" type="submit" :disabled="loading">اعمال</UiButton>
|
||
</form>
|
||
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
<div v-else-if="loading" class="loading-wrap">
|
||
<div class="spinner"></div>
|
||
<span>در حال بارگذاری گزارشات...</span>
|
||
</div>
|
||
|
||
<template v-else-if="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>
|
||
|
||
<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="260px"
|
||
/>
|
||
<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="عملکرد تیم">
|
||
<ul v-if="data.team?.length" class="team-list">
|
||
<li v-for="u in data.team.slice(0, 8)" :key="u.userId">
|
||
<span class="avatar">{{ u.fullName.slice(0, 1) }}</span>
|
||
<div class="team-meta">
|
||
<strong>{{ u.fullName }}</strong>
|
||
<small>{{ num(u.activitiesDone) }} فعالیت انجامشده</small>
|
||
</div>
|
||
<em class="team-won">{{ money(u.wonAmount) }}</em>
|
||
</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>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.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>
|