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

1062 lines
28 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import 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 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 DONUT_COLORS = ['#0d9488', '#2563eb', '#8b5cf6', '#f59e0b', '#94a3b8', '#22c55e'];
const BAR_COLOR = '#0d9488';
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);
}
/* ——— 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 */
}
});
onMounted(() => {
applyPeriod('month');
});
</script>
<template>
<section class="page">
<header class="page-head">
<div class="head-copy">
<h1>گزارشات و داشبورد</h1>
<p class="lead">نمای عملکرد فروش، مشتریان، فعالیت‌ها و تیم</p>
</div>
<button class="btn primary export desk-only" type="button" :disabled="!data" @click="exportReport">
<UiIcon name="document" :size="18" />
خروجی گزارش
</button>
</header>
<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="تا تاریخ" />
<button class="btn primary" type="submit" :disabled="loading">اعمال</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else-if="data">
<!-- KPI row -->
<div class="kpis">
<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>
</article>
</div>
<div class="grid">
<!-- 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>
</div>
<p v-else class="empty">داده‌ای برای روند فروش نیست.</p>
</article>
<!-- 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>
</ul>
<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">
<li v-for="t in data.activities.byType" :key="t.type">
<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>
</li>
</ul>
<p v-else class="empty">فعالیتی در بازه نیست.</p>
</article>
<!-- 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>
</div>
<p v-else class="empty">داده‌ای نیست.</p>
</article>
<!-- 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>
</ul>
<p v-else class="empty">با ثبت فروش و مشتری، تحلیل‌ها اینجا می‌آیند.</p>
<div class="suggest">
<UiIcon name="lightbulb" :size="16" />
<span>پیشنهاد: روی دسته‌ها و منابع با رشد مثبت تمرکز کنید.</span>
</div>
</article>
</div>
</template>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.page-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
}
h1 {
margin: 0;
font-size: 1.45rem;
}
.lead {
margin: 0.25rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.btn {
border: 0;
border-radius: var(--radius);
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.95rem;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.35rem;
box-sizing: border-box;
}
.btn.primary {
background: var(--accent);
color: #fff;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
}
.period-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.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;
}
.kpis {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.75rem;
}
.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);
display: grid;
gap: 0.35rem;
}
.kpi-top {
display: flex;
align-items: center;
gap: 0.45rem;
color: var(--muted);
font-size: 0.82rem;
}
.kpi-ico {
width: 32px;
height: 32px;
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent);
display: grid;
place-items: center;
}
.kpi-val {
font-size: 1.15rem;
color: #0f172a;
}
.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);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.panel {
background: var(--surface);
border: 1px solid #eef2f7;
border-radius: 18px;
padding: 1rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
min-width: 0;
}
.panel.wide {
grid-column: 1 / -1;
}
.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 {
width: 100%;
height: auto;
}
.x-labels {
display: flex;
justify-content: space-between;
color: #94a3b8;
font-size: 0.72rem;
gap: 0.25rem;
overflow: hidden;
}
.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;
color: #94a3b8;
font-weight: 700;
}
.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;
margin: 0;
padding: 0;
display: grid;
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;
color: #334155;
}
.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;
}
.error {
color: #b91c1c;
}
.mob-only {
display: none;
}
@media (max-width: 1100px) {
.kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 800px) {
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid {
grid-template-columns: 1fr;
}
.panel.wide {
grid-column: auto;
}
.donut-wrap {
grid-template-columns: 1fr;
justify-items: center;
}
.desk-only {
display: none;
}
.mob-only {
display: inline-flex;
}
.hbars li,
.act-bars li {
grid-template-columns: 1fr;
}
}
</style>