2026-08-29 21:06:30 +03:30
|
|
|
|
<script setup lang="ts">
|
2026-09-05 17:04:54 +03:30
|
|
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
|
|
|
|
|
import { formatMoney } from '@/lib/money';
|
2026-09-05 17:04:54 +03:30
|
|
|
|
import {
|
|
|
|
|
|
dateToJalali,
|
|
|
|
|
|
formatJalaliNumeric,
|
|
|
|
|
|
formatLocalDateInput,
|
|
|
|
|
|
jalaliToDate,
|
|
|
|
|
|
toFaDigits,
|
|
|
|
|
|
} from '@/lib/jalali';
|
2026-09-02 21:29:00 +03:30
|
|
|
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
2026-09-05 17:04:54 +03:30
|
|
|
|
import UiIcon from '@/components/UiIcon.vue';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import type { OrganizationDto, ReportsDashboardDto } from '@mento/shared';
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
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 DONUT_COLORS = ['#0d9488', '#2563eb', '#8b5cf6', '#f59e0b', '#94a3b8', '#22c55e'];
|
|
|
|
|
|
const BAR_COLOR = '#0d9488';
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const error = ref('');
|
|
|
|
|
|
const data = ref<ReportsDashboardDto | null>(null);
|
|
|
|
|
|
const org = ref<OrganizationDto | null>(null);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
const period = ref<PeriodKey>('month');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const from = ref('');
|
|
|
|
|
|
const to = ref('');
|
2026-09-05 17:04:54 +03:30
|
|
|
|
const showCustomDates = ref(false);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
const currency = computed(
|
|
|
|
|
|
() => data.value?.currency || org.value?.baseCurrency || 'IRR',
|
|
|
|
|
|
);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
function money(n: number | null | undefined) {
|
|
|
|
|
|
if (n == null || Number.isNaN(n)) return '—';
|
|
|
|
|
|
return formatMoney(n, currency.value);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
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;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
error.value = axios.isAxiosError(e)
|
|
|
|
|
|
? String(
|
|
|
|
|
|
(e.response?.data as { message?: string })?.message ??
|
|
|
|
|
|
'بارگذاری گزارش ناموفق بود.',
|
|
|
|
|
|
)
|
|
|
|
|
|
: 'بارگذاری گزارش ناموفق بود.';
|
|
|
|
|
|
data.value = null;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
function exportReport() {
|
|
|
|
|
|
if (!data.value) return;
|
|
|
|
|
|
const d = data.value;
|
|
|
|
|
|
const lines: string[] = [
|
|
|
|
|
|
'گزارشات منتو',
|
|
|
|
|
|
`بازه,${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 = `mento-report-${from.value || 'from'}-${to.value || 'to'}.csv`;
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ——— chart helpers ——— */
|
|
|
|
|
|
function areaPoints(
|
|
|
|
|
|
rows: Array<{ label: string; value: number }>,
|
|
|
|
|
|
w = 360,
|
|
|
|
|
|
h = 160,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const max = Math.max(...rows.map((r) => r.value), 1);
|
|
|
|
|
|
const padX = 14;
|
|
|
|
|
|
const padY = 28;
|
|
|
|
|
|
const pts = rows.map((r, i) => ({
|
|
|
|
|
|
...r,
|
|
|
|
|
|
x: padX + (i / Math.max(rows.length - 1, 1)) * (w - padX * 2),
|
|
|
|
|
|
y: h - padY - (r.value / max) * (h - padY * 2),
|
|
|
|
|
|
}));
|
|
|
|
|
|
const line = pts.map((p) => `${p.x},${p.y}`).join(' ');
|
|
|
|
|
|
const area =
|
|
|
|
|
|
pts.length === 0
|
|
|
|
|
|
? ''
|
|
|
|
|
|
: `${line} ${pts[pts.length - 1]!.x},${h} ${pts[0]!.x},${h}`;
|
|
|
|
|
|
const peak = pts.reduce(
|
|
|
|
|
|
(best, cur) => (cur.value >= best.value ? cur : best),
|
|
|
|
|
|
pts[0] ?? { x: 0, y: 0, value: 0, label: '' },
|
|
|
|
|
|
);
|
|
|
|
|
|
return { pts, line, area, peak, w, h };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function donutSegments(
|
|
|
|
|
|
rows: Array<{ label: string; count: number; percent: number }>,
|
|
|
|
|
|
) {
|
|
|
|
|
|
let offset = 0;
|
|
|
|
|
|
return rows.map((s, i) => {
|
|
|
|
|
|
const seg = {
|
|
|
|
|
|
...s,
|
|
|
|
|
|
color: DONUT_COLORS[i % DONUT_COLORS.length]!,
|
|
|
|
|
|
offset,
|
|
|
|
|
|
};
|
|
|
|
|
|
offset += s.percent;
|
|
|
|
|
|
return seg;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hBarWidths(rows: Array<{ amount?: number; quantity?: number; value?: number }>) {
|
|
|
|
|
|
const vals = rows.map((r) => r.amount ?? r.quantity ?? r.value ?? 0);
|
|
|
|
|
|
const max = Math.max(...vals, 1);
|
|
|
|
|
|
return vals.map((v) => Math.max(6, Math.round((v / max) * 100)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const salesTrendChart = computed(() =>
|
|
|
|
|
|
areaPoints(
|
|
|
|
|
|
(data.value?.salesTrend ?? []).map((p) => ({
|
|
|
|
|
|
label: p.label,
|
|
|
|
|
|
value: p.value,
|
|
|
|
|
|
})),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const growthChart = computed(() =>
|
|
|
|
|
|
areaPoints(
|
|
|
|
|
|
(data.value?.customerGrowth ?? []).map((p) => ({
|
|
|
|
|
|
label: p.label,
|
|
|
|
|
|
value: p.value,
|
|
|
|
|
|
})),
|
|
|
|
|
|
320,
|
|
|
|
|
|
140,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const sourceSegs = computed(() => donutSegments(data.value?.sources ?? []));
|
|
|
|
|
|
const statusSegs = computed(() =>
|
|
|
|
|
|
donutSegments(data.value?.customerStatus ?? []),
|
|
|
|
|
|
);
|
|
|
|
|
|
const categorySegs = computed(() =>
|
|
|
|
|
|
donutSegments(data.value?.salesByCategory ?? []),
|
|
|
|
|
|
);
|
|
|
|
|
|
const sourceTotal = computed(() =>
|
|
|
|
|
|
(data.value?.sources ?? []).reduce((a, s) => a + s.count, 0),
|
|
|
|
|
|
);
|
|
|
|
|
|
const statusTotal = computed(() =>
|
|
|
|
|
|
(data.value?.customerStatus ?? []).reduce((a, s) => a + s.count, 0),
|
|
|
|
|
|
);
|
|
|
|
|
|
const categoryAmount = computed(() =>
|
|
|
|
|
|
(data.value?.salesByCategory ?? []).reduce(
|
|
|
|
|
|
(a, s) => a + (s.amount ?? 0),
|
|
|
|
|
|
0,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const monthBars = computed(() => {
|
|
|
|
|
|
const rows = data.value?.monthlyComparison ?? [];
|
|
|
|
|
|
const max = Math.max(...rows.map((r) => r.value), 1);
|
|
|
|
|
|
return rows.map((r) => ({
|
|
|
|
|
|
...r,
|
|
|
|
|
|
h: Math.max(8, Math.round((r.value / max) * 120)),
|
|
|
|
|
|
}));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const topBarWidths = computed(() =>
|
|
|
|
|
|
hBarWidths(data.value?.topProducts ?? []),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const activityMax = computed(() =>
|
|
|
|
|
|
Math.max(
|
|
|
|
|
|
...(data.value?.activities.byType.map((t) => t.count) ?? [0]),
|
|
|
|
|
|
1,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
watch([from, to], () => {
|
|
|
|
|
|
if (period.value === 'custom') {
|
|
|
|
|
|
/* wait for apply button */
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
onMounted(() => {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
applyPeriod('month');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<section class="page">
|
|
|
|
|
|
<header class="page-head">
|
2026-09-05 02:29:41 +03:30
|
|
|
|
<div class="head-copy">
|
2026-08-29 21:06:30 +03:30
|
|
|
|
<h1>گزارشات و داشبورد</h1>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<p class="lead">نمای عملکرد فروش، مشتریان، فعالیتها و تیم</p>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</div>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<button class="btn primary export desk-only" type="button" :disabled="!data" @click="exportReport">
|
|
|
|
|
|
<UiIcon name="document" :size="18" />
|
|
|
|
|
|
خروجی گزارش
|
|
|
|
|
|
</button>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<div class="filters">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<button class="btn primary export mob-only" type="button" :disabled="!data" @click="exportReport">
|
|
|
|
|
|
خروجی
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<form v-if="showCustomDates" class="custom-range" @submit.prevent="load">
|
|
|
|
|
|
<JalaliDateInput v-model="from" placeholder="از تاریخ" />
|
|
|
|
|
|
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
|
2026-09-05 02:29:41 +03:30
|
|
|
|
<button class="btn primary" type="submit" :disabled="loading">اعمال</button>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
|
|
<p v-if="error" class="error">{{ error }}</p>
|
|
|
|
|
|
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else-if="data">
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<!-- KPI row -->
|
2026-08-29 21:06:30 +03:30
|
|
|
|
<div class="kpis">
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<article v-for="k in data.kpis" :key="k.key" class="kpi">
|
|
|
|
|
|
<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>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="grid">
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<!-- Sales trend -->
|
|
|
|
|
|
<article class="panel wide">
|
|
|
|
|
|
<header class="panel-head">
|
|
|
|
|
|
<h2>روند فروش</h2>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<div v-if="salesTrendChart.pts.length" class="chart-wrap">
|
|
|
|
|
|
<svg :viewBox="`0 0 ${salesTrendChart.w} ${salesTrendChart.h}`" class="chart">
|
|
|
|
|
|
<defs>
|
|
|
|
|
|
<linearGradient id="repTrend" x1="0" y1="0" x2="0" y2="1">
|
|
|
|
|
|
<stop offset="0%" stop-color="#14b8a6" stop-opacity="0.3" />
|
|
|
|
|
|
<stop offset="100%" stop-color="#14b8a6" stop-opacity="0" />
|
|
|
|
|
|
</linearGradient>
|
|
|
|
|
|
</defs>
|
|
|
|
|
|
<polygon v-if="salesTrendChart.area" :points="salesTrendChart.area" fill="url(#repTrend)" />
|
|
|
|
|
|
<polyline
|
|
|
|
|
|
v-if="salesTrendChart.line"
|
|
|
|
|
|
:points="salesTrendChart.line"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="#0d9488"
|
|
|
|
|
|
stroke-width="3"
|
|
|
|
|
|
stroke-linecap="round"
|
|
|
|
|
|
stroke-linejoin="round"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<g v-if="salesTrendChart.peak">
|
|
|
|
|
|
<circle :cx="salesTrendChart.peak.x" :cy="salesTrendChart.peak.y" r="5" fill="#fff" stroke="#0d9488" stroke-width="2.5" />
|
|
|
|
|
|
<rect
|
|
|
|
|
|
:x="Math.min(Math.max(salesTrendChart.peak.x - 48, 4), 264)"
|
|
|
|
|
|
:y="salesTrendChart.peak.y - 34"
|
|
|
|
|
|
width="96"
|
|
|
|
|
|
height="22"
|
|
|
|
|
|
rx="8"
|
|
|
|
|
|
fill="#0f766e"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<text
|
|
|
|
|
|
:x="Math.min(Math.max(salesTrendChart.peak.x, 52), 308)"
|
|
|
|
|
|
:y="salesTrendChart.peak.y - 19"
|
|
|
|
|
|
text-anchor="middle"
|
|
|
|
|
|
fill="#fff"
|
|
|
|
|
|
font-size="9"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ money(salesTrendChart.peak.value) }}
|
|
|
|
|
|
</text>
|
|
|
|
|
|
</g>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<div class="x-labels">
|
|
|
|
|
|
<span v-for="p in data.salesTrend" :key="p.key">{{ p.label }}</span>
|
|
|
|
|
|
</div>
|
2026-09-05 02:29:41 +03:30
|
|
|
|
</div>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<p v-else class="empty">دادهای برای روند فروش نیست.</p>
|
|
|
|
|
|
</article>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<!-- Sources -->
|
|
|
|
|
|
<article class="panel">
|
|
|
|
|
|
<header class="panel-head"><h2>منابع مشتریان</h2></header>
|
|
|
|
|
|
<div v-if="sourceSegs.length" class="donut-wrap">
|
|
|
|
|
|
<div class="donut-box">
|
|
|
|
|
|
<svg viewBox="0 0 140 140" class="donut">
|
|
|
|
|
|
<circle cx="70" cy="70" r="48" fill="none" stroke="#eef2f7" stroke-width="16" />
|
|
|
|
|
|
<circle
|
|
|
|
|
|
v-for="seg in sourceSegs"
|
|
|
|
|
|
:key="seg.label"
|
|
|
|
|
|
cx="70"
|
|
|
|
|
|
cy="70"
|
|
|
|
|
|
r="48"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
:stroke="seg.color"
|
|
|
|
|
|
stroke-width="16"
|
|
|
|
|
|
:stroke-dasharray="`${seg.percent * 3.016} 301.6`"
|
|
|
|
|
|
:stroke-dashoffset="`${-seg.offset * 3.016}`"
|
|
|
|
|
|
transform="rotate(-90 70 70)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<div class="donut-center">
|
|
|
|
|
|
<strong>{{ num(sourceTotal) }}</strong>
|
|
|
|
|
|
<span>مشتری</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ul class="legend">
|
|
|
|
|
|
<li v-for="seg in sourceSegs" :key="seg.label">
|
|
|
|
|
|
<i :style="{ background: seg.color }" />
|
|
|
|
|
|
<span>{{ seg.label }}</span>
|
|
|
|
|
|
<em>{{ toFaDigits(seg.percent) }}٪</em>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p v-else class="empty">منبعی ثبت نشده است.</p>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Monthly comparison -->
|
|
|
|
|
|
<article class="panel">
|
|
|
|
|
|
<header class="panel-head"><h2>مقایسه فروش ماههای اخیر</h2></header>
|
|
|
|
|
|
<div v-if="monthBars.length" class="vbars">
|
|
|
|
|
|
<div v-for="b in monthBars" :key="b.key" class="vbar">
|
|
|
|
|
|
<div class="vbar-col" :style="{ height: `${b.h}px` }" :title="money(b.value)" />
|
|
|
|
|
|
<span>{{ b.label }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p v-else class="empty">دادهای نیست.</p>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Top products -->
|
|
|
|
|
|
<article class="panel">
|
|
|
|
|
|
<header class="panel-head"><h2>پرفروشترین محصولات</h2></header>
|
|
|
|
|
|
<ul v-if="data.topProducts.length" class="hbars">
|
|
|
|
|
|
<li v-for="(p, i) in data.topProducts" :key="p.name">
|
|
|
|
|
|
<span class="hb-name">{{ p.name }}</span>
|
|
|
|
|
|
<div class="hb-track">
|
|
|
|
|
|
<div class="hb-fill" :style="{ width: `${topBarWidths[i]}%`, background: BAR_COLOR }" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<em>{{ num(p.quantity) }}</em>
|
|
|
|
|
|
</li>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</ul>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<p v-else class="empty">فروش محصولی در این بازه نیست.</p>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Category (mobile emphasis / also desktop) -->
|
|
|
|
|
|
<article class="panel">
|
|
|
|
|
|
<header class="panel-head"><h2>فروش بر اساس دستهبندی</h2></header>
|
|
|
|
|
|
<div v-if="categorySegs.length" class="donut-wrap">
|
|
|
|
|
|
<div class="donut-box">
|
|
|
|
|
|
<svg viewBox="0 0 140 140" class="donut">
|
|
|
|
|
|
<circle cx="70" cy="70" r="48" fill="none" stroke="#eef2f7" stroke-width="16" />
|
|
|
|
|
|
<circle
|
|
|
|
|
|
v-for="seg in categorySegs"
|
|
|
|
|
|
:key="seg.label"
|
|
|
|
|
|
cx="70"
|
|
|
|
|
|
cy="70"
|
|
|
|
|
|
r="48"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
:stroke="seg.color"
|
|
|
|
|
|
stroke-width="16"
|
|
|
|
|
|
:stroke-dasharray="`${seg.percent * 3.016} 301.6`"
|
|
|
|
|
|
:stroke-dashoffset="`${-seg.offset * 3.016}`"
|
|
|
|
|
|
transform="rotate(-90 70 70)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<div class="donut-center">
|
|
|
|
|
|
<strong class="sm">{{ money(categoryAmount) }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ul class="legend">
|
|
|
|
|
|
<li v-for="seg in categorySegs" :key="seg.label">
|
|
|
|
|
|
<i :style="{ background: seg.color }" />
|
|
|
|
|
|
<span>{{ seg.label }}</span>
|
|
|
|
|
|
<em>{{ toFaDigits(seg.percent) }}٪</em>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p v-else class="empty">دستهبندی فروشی نیست.</p>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Customer status -->
|
|
|
|
|
|
<article class="panel">
|
|
|
|
|
|
<header class="panel-head"><h2>وضعیت مشتریان</h2></header>
|
|
|
|
|
|
<div v-if="statusSegs.length" class="donut-wrap">
|
|
|
|
|
|
<div class="donut-box">
|
|
|
|
|
|
<svg viewBox="0 0 140 140" class="donut">
|
|
|
|
|
|
<circle cx="70" cy="70" r="48" fill="none" stroke="#eef2f7" stroke-width="16" />
|
|
|
|
|
|
<circle
|
|
|
|
|
|
v-for="seg in statusSegs"
|
|
|
|
|
|
:key="seg.label"
|
|
|
|
|
|
cx="70"
|
|
|
|
|
|
cy="70"
|
|
|
|
|
|
r="48"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
:stroke="seg.color"
|
|
|
|
|
|
stroke-width="16"
|
|
|
|
|
|
:stroke-dasharray="`${seg.percent * 3.016} 301.6`"
|
|
|
|
|
|
:stroke-dashoffset="`${-seg.offset * 3.016}`"
|
|
|
|
|
|
transform="rotate(-90 70 70)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<div class="donut-center">
|
|
|
|
|
|
<strong>{{ num(statusTotal) }}</strong>
|
|
|
|
|
|
<span>مشتری</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ul class="legend">
|
|
|
|
|
|
<li v-for="seg in statusSegs" :key="seg.label">
|
|
|
|
|
|
<i :style="{ background: seg.color }" />
|
|
|
|
|
|
<span>{{ seg.label }}</span>
|
|
|
|
|
|
<em>{{ num(seg.count) }}</em>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p v-else class="empty">مشتری ثبت نشده است.</p>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Team -->
|
|
|
|
|
|
<article class="panel">
|
|
|
|
|
|
<header class="panel-head"><h2>عملکرد تیم</h2></header>
|
|
|
|
|
|
<ul v-if="data.team.length" class="team">
|
|
|
|
|
|
<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>{{ money(u.wonAmount) }}</em>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<p v-else class="empty">داده تیمی نیست.</p>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Activities -->
|
|
|
|
|
|
<article class="panel">
|
|
|
|
|
|
<header class="panel-head"><h2>فعالیتها</h2></header>
|
|
|
|
|
|
<ul class="act-stats">
|
|
|
|
|
|
<li><span>باز</span><strong>{{ num(data.activities.open) }}</strong></li>
|
|
|
|
|
|
<li><span>انجامشده</span><strong>{{ num(data.activities.done) }}</strong></li>
|
|
|
|
|
|
<li><span>عقبافتاده</span><strong>{{ num(data.activities.overdue) }}</strong></li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<ul v-if="data.activities.byType.length" class="act-bars">
|
2026-08-29 21:06:30 +03:30
|
|
|
|
<li v-for="t in data.activities.byType" :key="t.type">
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<span>{{ 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>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<p v-else class="empty">فعالیتی در بازه نیست.</p>
|
|
|
|
|
|
</article>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<!-- Customer growth -->
|
|
|
|
|
|
<article class="panel">
|
|
|
|
|
|
<header class="panel-head"><h2>رشد مشتریان</h2></header>
|
|
|
|
|
|
<div v-if="growthChart.pts.length" class="chart-wrap">
|
|
|
|
|
|
<svg :viewBox="`0 0 ${growthChart.w} ${growthChart.h}`" class="chart">
|
|
|
|
|
|
<defs>
|
|
|
|
|
|
<linearGradient id="repGrowth" x1="0" y1="0" x2="0" y2="1">
|
|
|
|
|
|
<stop offset="0%" stop-color="#2563eb" stop-opacity="0.25" />
|
|
|
|
|
|
<stop offset="100%" stop-color="#2563eb" stop-opacity="0" />
|
|
|
|
|
|
</linearGradient>
|
|
|
|
|
|
</defs>
|
|
|
|
|
|
<polygon v-if="growthChart.area" :points="growthChart.area" fill="url(#repGrowth)" />
|
|
|
|
|
|
<polyline
|
|
|
|
|
|
v-if="growthChart.line"
|
|
|
|
|
|
:points="growthChart.line"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="#2563eb"
|
|
|
|
|
|
stroke-width="2.8"
|
|
|
|
|
|
stroke-linecap="round"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<div class="x-labels">
|
|
|
|
|
|
<span v-for="p in data.customerGrowth" :key="p.key">{{ p.label }}</span>
|
|
|
|
|
|
</div>
|
2026-09-05 02:29:41 +03:30
|
|
|
|
</div>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<p v-else class="empty">دادهای نیست.</p>
|
|
|
|
|
|
</article>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<!-- Insights -->
|
|
|
|
|
|
<article class="panel insights">
|
|
|
|
|
|
<header class="panel-head">
|
|
|
|
|
|
<h2>
|
|
|
|
|
|
<UiIcon name="sparkle" :size="16" />
|
|
|
|
|
|
تحلیل هوشمند
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<ul v-if="data.insights.length" class="insight-list">
|
|
|
|
|
|
<li v-for="(line, i) in data.insights" :key="i">{{ line }}</li>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</ul>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<p v-else class="empty">با ثبت فروش و مشتری، تحلیلها اینجا میآیند.</p>
|
|
|
|
|
|
<div class="suggest">
|
|
|
|
|
|
<UiIcon name="lightbulb" :size="16" />
|
|
|
|
|
|
<span>پیشنهاد: روی دستهها و منابع با رشد مثبت تمرکز کنید.</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.page {
|
|
|
|
|
|
display: grid;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
gap: 1rem;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
.page-head {
|
|
|
|
|
|
display: flex;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
flex-wrap: wrap;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
justify-content: space-between;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
gap: 0.75rem;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
h1 {
|
2026-09-05 02:29:41 +03:30
|
|
|
|
margin: 0;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
font-size: 1.45rem;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.lead {
|
|
|
|
|
|
margin: 0.25rem 0 0;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: 0.92rem;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
.btn {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
border: 0;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 0.6rem 0.95rem;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
cursor: pointer;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
gap: 0.35rem;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
|
|
|
|
|
.btn.primary {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
background: var(--accent);
|
2026-09-05 02:29:41 +03:30
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.filters {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 0.55rem;
|
|
|
|
|
|
align-items: center;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.period-chips {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 0.35rem;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.chip {
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
padding: 0.4rem 0.75rem;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: #475569;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chip.on {
|
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
border-color: transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
.range-btn {
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 0.45rem 0.75rem;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
color: #334155;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
.custom-range {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
align-items: end;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
.kpis {
|
|
|
|
|
|
display: grid;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
2026-08-29 21:06:30 +03:30
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.kpi {
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border: 1px solid #eef2f7;
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
padding: 0.85rem 0.95rem;
|
|
|
|
|
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.03);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
display: grid;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
gap: 0.35rem;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.kpi-top {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: 0.82rem;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.kpi-ico {
|
|
|
|
|
|
width: 32px;
|
|
|
|
|
|
height: 32px;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
background: var(--accent-soft);
|
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.kpi-val {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
font-size: 1.15rem;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
color: #0f172a;
|
|
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.kpi-change {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
|
color: #16a34a;
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
.kpi-change.down {
|
|
|
|
|
|
color: #dc2626;
|
|
|
|
|
|
}
|
|
|
|
|
|
.kpi-change.flat {
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
}
|
|
|
|
|
|
.kpi-change small {
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: var(--muted);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
.grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
|
gap: 0.85rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.panel {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border: 1px solid #eef2f7;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
border-radius: 18px;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
padding: 1rem;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
|
|
|
|
|
min-width: 0;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.panel.wide {
|
|
|
|
|
|
grid-column: 1 / -1;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.panel-head {
|
|
|
|
|
|
margin-bottom: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.panel-head h2 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 0.95rem;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chart-wrap {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chart {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
width: 100%;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
height: auto;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.x-labels {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
overflow: hidden;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.x-labels span {
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.donut-wrap {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 132px 1fr;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.donut-box {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
width: 132px;
|
|
|
|
|
|
height: 132px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.donut {
|
|
|
|
|
|
width: 132px;
|
|
|
|
|
|
height: 132px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.donut-center {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-content: center;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.donut-center strong {
|
|
|
|
|
|
font-size: 1.1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.donut-center strong.sm {
|
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
|
max-width: 90px;
|
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
|
}
|
|
|
|
|
|
.donut-center span {
|
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
}
|
|
|
|
|
|
.legend {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.legend li {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.legend i {
|
|
|
|
|
|
width: 8px;
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
}
|
|
|
|
|
|
.legend span {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
color: #475569;
|
|
|
|
|
|
}
|
|
|
|
|
|
.legend em {
|
|
|
|
|
|
font-style: normal;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
font-weight: 700;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.vbars {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: end;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
min-height: 150px;
|
|
|
|
|
|
padding-top: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.vbar {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
justify-items: center;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.vbar-col {
|
|
|
|
|
|
width: 70%;
|
|
|
|
|
|
max-width: 36px;
|
|
|
|
|
|
border-radius: 10px 10px 4px 4px;
|
|
|
|
|
|
background: linear-gradient(180deg, #2dd4bf, #0d9488);
|
|
|
|
|
|
}
|
|
|
|
|
|
.vbar span {
|
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
}
|
|
|
|
|
|
.hbars,
|
|
|
|
|
|
.act-bars,
|
|
|
|
|
|
.team,
|
|
|
|
|
|
.act-stats,
|
|
|
|
|
|
.insight-list {
|
|
|
|
|
|
list-style: none;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: grid;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
gap: 0.55rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.hbars li,
|
|
|
|
|
|
.act-bars li {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 1fr 2fr auto;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.hb-name {
|
|
|
|
|
|
color: #334155;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.hb-track {
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
background: #f1f5f9;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.hb-fill {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
|
}
|
|
|
|
|
|
.hbars em,
|
|
|
|
|
|
.act-bars em {
|
|
|
|
|
|
font-style: normal;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: #64748b;
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.team li {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: auto 1fr auto;
|
|
|
|
|
|
gap: 0.65rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.avatar {
|
|
|
|
|
|
width: 36px;
|
|
|
|
|
|
height: 36px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: var(--accent-soft);
|
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
}
|
|
|
|
|
|
.team-meta strong {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
font-size: 0.88rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.team-meta small {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
.team em {
|
|
|
|
|
|
font-style: normal;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.act-stats {
|
|
|
|
|
|
grid-template-columns: repeat(3, 1fr);
|
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.act-stats li {
|
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 0.55rem;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.2rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.act-stats span {
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
.insights .insight-list li {
|
|
|
|
|
|
padding: 0.55rem 0.7rem;
|
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
font-size: 0.88rem;
|
|
|
|
|
|
line-height: 1.6;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
color: #334155;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.suggest {
|
|
|
|
|
|
margin-top: 0.75rem;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
background: var(--accent-soft);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 0.65rem 0.75rem;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty,
|
|
|
|
|
|
.muted {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
.error {
|
2026-09-05 02:29:41 +03:30
|
|
|
|
color: #b91c1c;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.mob-only {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (max-width: 1100px) {
|
|
|
|
|
|
.kpis {
|
|
|
|
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (max-width: 800px) {
|
2026-09-05 02:29:41 +03:30
|
|
|
|
.kpis {
|
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
.grid {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.panel.wide {
|
|
|
|
|
|
grid-column: auto;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.donut-wrap {
|
2026-09-05 02:29:41 +03:30
|
|
|
|
grid-template-columns: 1fr;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
justify-items: center;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.desk-only {
|
|
|
|
|
|
display: none;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.mob-only {
|
|
|
|
|
|
display: inline-flex;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.hbars li,
|
|
|
|
|
|
.act-bars li {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
</style>
|