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

1058 lines
28 KiB
Vue
Raw Normal View History

<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';
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[] = [
'گزارشات منتو',
`بازه,${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 */
}
});
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: 12px;
padding: 0.6rem 0.95rem;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.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>