Files
tehran_taj/apps/admin-panel/src/views/Dashboard.vue
T

1060 lines
30 KiB
Vue

<template>
<!--
Replaces legacy DashboardECommerce.vue ("آمار عمده فروشی") and
DashboardAnalytics.vue ("آمار تک فروشی") — two menu entries, two rids,
two near-identical screens (the retail one rendered almost nothing real;
see admin-dashboard.service.ts's header comment). Merged into one
dashboard behind one rid (dashboard.view) and one endpoint
(GET dashboard/overview), which returns the union of both legacy
payloads unchanged plus the new chart aggregates used below.
-->
<a-spin :spinning="loading">
<div class="dashboard">
<!-- ── header ── -->
<header class="dash-head">
<div>
<h1 class="dash-head__title">داشبورد</h1>
<p class="dash-head__sub">خلاصه عملکرد فروش در ۳۰ روز گذشته</p>
</div>
<div class="dash-head__date">
<CalendarOutlined />
<span>{{ todayLabel }}</span>
</div>
</header>
<!-- ── KPI row ── -->
<section class="kpi-grid">
<div class="kpi kpi--success">
<div class="kpi__top">
<span class="kpi__icon"><ShoppingCartOutlined /></span>
<span class="kpi__label">تعداد سفارشات (۳۰ روز گذشته)</span>
</div>
<div class="kpi__value">{{ fmt(data.ordercount) }}<small>سفارش</small></div>
<apexchart
v-if="trendTotals.length"
class="kpi__spark"
type="area"
height="56"
:options="sparkOptions"
:series="[{ name: 'سفارش', data: trendTotals }]"
/>
</div>
<div class="kpi kpi--primary">
<div class="kpi__top">
<span class="kpi__icon"><GoldOutlined /></span>
<span class="kpi__label">طلای فروش رفته (۳۰ روز گذشته)</span>
</div>
<div class="kpi__value">{{ fmt(data.allgram) }}<small>گرم</small></div>
<div class="kpi__foot">
میانگین هر سفارش تحویل‌شده: <b>{{ avgWeightPerOrder }} گرم</b>
</div>
</div>
<div class="kpi kpi--warning">
<div class="kpi__top">
<span class="kpi__icon"><UserAddOutlined /></span>
<span class="kpi__label">مشتریان جدید (۳۰ روز گذشته)</span>
</div>
<div class="kpi__value">{{ fmt(data.customerCount) }}<small>نفر</small></div>
<div class="kpi__foot">
مجموع بدهی طلای مشتریان: <b>{{ totalDebtLabel }} گرم</b>
</div>
</div>
<div class="kpi kpi--danger">
<div class="kpi__top">
<span class="kpi__icon"><DollarOutlined /></span>
<span class="kpi__label">نرخ هر گرم طلای ۱۸ عیار</span>
</div>
<div class="kpi__value kpi__value--price">{{ goldPriceLabel }}</div>
<div class="kpi__foot">
سفارشات تحویل‌شده (۳۰ روز گذشته): <b>{{ fmt(data.sumLastOrder) }} سفارش</b>
</div>
</div>
</section>
<!-- ── trend + channel ── -->
<section class="row row--main">
<div class="panel">
<div class="panel__head">
<div>
<h3 class="panel__title">روند سفارش‌ها</h3>
<p class="panel__sub">۳۰ روز گذشته، تفکیک تک و عمده</p>
</div>
<a-segmented v-model:value="trendType" :options="trendTypeOptions" size="small" />
</div>
<div class="trend-totals">
<div class="trend-totals__item">
<span class="dot" :style="{ background: COLOR_PRIMARY }"></span>
<span>تک فروشی</span>
<b>{{ fmt(retailTotal) }}</b>
</div>
<div class="trend-totals__item">
<span class="dot" :style="{ background: COLOR_SUCCESS }"></span>
<span>عمده فروشی</span>
<b>{{ fmt(wholesaleTotal) }}</b>
</div>
</div>
<apexchart
:key="trendType"
:type="trendType"
:height="chartHeight"
:options="trendChartOptions"
:series="trendSeries"
/>
</div>
<div class="panel">
<div class="panel__head">
<div>
<h3 class="panel__title">تفکیک عمده / تک</h3>
<p class="panel__sub">۳۰ روز گذشته</p>
</div>
</div>
<template v-if="channelSeries.some((v: number) => v > 0)">
<apexchart type="donut" :height="donutHeight" :options="channelChartOptions" :series="channelSeries" />
<ul class="legend-list">
<li v-for="(c, i) in data.channelBreakdown" :key="c.channel">
<span class="dot" :style="{ background: CHANNEL_COLORS[i % CHANNEL_COLORS.length] }"></span>
<span class="legend-list__label">{{ c.label }}</span>
<span class="legend-list__meta">{{ fmt(c.grams) }} گرم</span>
<b>{{ fmt(c.count) }}</b>
</li>
</ul>
</template>
<a-empty v-else description="داده‌ای برای نمایش وجود ندارد" />
</div>
</section>
<!-- ── status + sales summary ── -->
<section class="row row--main">
<div class="panel">
<div class="panel__head">
<div>
<h3 class="panel__title">وضعیت سفارشات</h3>
<p class="panel__sub">۳۰ روز گذشته</p>
</div>
<span class="pill">{{ fmt(statusTotal) }} سفارش</span>
</div>
<apexchart
v-if="statusSeries[0].data.length"
type="bar"
:height="statusChartHeight"
:options="statusChartOptions"
:series="statusSeries"
/>
<a-empty v-else description="داده‌ای برای نمایش وجود ندارد" />
</div>
<div class="panel">
<div class="panel__head">
<div>
<h3 class="panel__title">میزان فروش</h3>
<p class="panel__sub">۳۰ روز گذشته</p>
</div>
</div>
<ul class="metric-list">
<li>
<span class="metric-list__icon tone-primary"><CheckCircleOutlined /></span>
<span class="metric-list__label">تعداد سفارش (۳۰ روز)</span>
<b>{{ fmt(data.sumLastOrder) }}</b>
</li>
<li>
<span class="metric-list__icon tone-success"><GoldOutlined /></span>
<span class="metric-list__label">مجموع وزن (۳۰ روز)</span>
<b>{{ fmt(data.sumLastOrderMonth) }} گرم</b>
</li>
<li>
<span class="metric-list__icon tone-warning"><FundOutlined /></span>
<span class="metric-list__label">میانگین هر سفارش</span>
<b>{{ avgWeightPerOrder }} گرم</b>
</li>
<li>
<span class="metric-list__icon tone-danger"><CreditCardOutlined /></span>
<span class="metric-list__label">مجموع بدهی طلای مشتریان</span>
<b>{{ totalDebtLabel }} گرم</b>
</li>
</ul>
</div>
</section>
<!-- ── agents + products ── -->
<section class="row row--half">
<div class="panel">
<div class="panel__head">
<div>
<h3 class="panel__title">عملکرد کارشناسان فروش</h3>
<p class="panel__sub">بر اساس گرم فروخته‌شده</p>
</div>
</div>
<ol v-if="data.reagent?.length" class="rank-list">
<li v-for="(r, i) in data.reagent" :key="r.id ?? i">
<span class="rank-list__pos" :class="{ 'is-top': i < 3 }">{{ i + 1 }}</span>
<div class="rank-list__body">
<div class="rank-list__row">
<span class="rank-list__name">{{ r.name }}</span>
<b>{{ fmt(r.grams_sell) }} گرم</b>
</div>
<div class="rank-list__bar">
<span :style="{ width: `${agentShare(r)}%` }"></span>
</div>
<span class="rank-list__meta">{{ fmt(r.order_count) }} سفارش</span>
</div>
</li>
</ol>
<a-empty v-else description="داده‌ای برای نمایش وجود ندارد" />
</div>
<div class="panel">
<div class="panel__head">
<div>
<h3 class="panel__title">محصولات پرفروش</h3>
<p class="panel__sub">۳۰ روز گذشته، بر اساس وزن</p>
</div>
</div>
<apexchart
v-if="productChartSeries[0]?.data.length"
type="bar"
:height="barChartHeight"
:options="productChartOptions"
:series="productChartSeries"
/>
<a-empty v-else description="داده‌ای برای نمایش وجود ندارد" />
</div>
</section>
<!-- ── weekly orders ── -->
<section class="panel">
<div class="panel__head">
<div>
<h3 class="panel__title">سفارشات هفته جاری</h3>
<p class="panel__sub">مرتب‌شده بر اساس وزن</p>
</div>
<span class="pill">{{ fmt(data.lastOrder?.length ?? 0) }} سفارش</span>
</div>
<a-list
v-if="layout.isMobile"
class="order-list"
:data-source="data.lastOrder"
:pagination="data.lastOrder?.length > 10 ? { pageSize: 10, size: 'small', align: 'center' } : false"
row-key="id"
>
<template #renderItem="{ item }">
<a-list-item class="order-item">
<div class="order-item__head">
<span class="order-item__name">{{ item.name }}</span>
<a-tag :color="tagColor(item.statusColor)" class="order-item__tag">{{ item.sts_str }}</a-tag>
</div>
<div class="order-item__customer">{{ item.customer }}</div>
<div class="order-item__meta">
<span>{{ item.w }} گرم</span>
<span>{{ jdate(item.caj) }}</span>
</div>
</a-list-item>
</template>
</a-list>
<a-table
v-else
class="dash-table"
size="middle"
:data-source="data.lastOrder"
:columns="orderColumns"
:pagination="{ pageSize: 10 }"
:scroll="{ x: 'max-content' }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'sts_str'">
<a-tag :color="tagColor(record.statusColor)">{{ record.sts_str }}</a-tag>
</template>
<template v-else-if="column.dataIndex === 'caj'">
{{ jdate(record.caj) }}
</template>
<template v-else-if="column.dataIndex === 'w'">{{ record.w }} گرم</template>
</template>
</a-table>
</section>
</div>
</a-spin>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
CalendarOutlined,
CheckCircleOutlined,
CreditCardOutlined,
DollarOutlined,
FundOutlined,
GoldOutlined,
ShoppingCartOutlined,
UserAddOutlined,
} from '@ant-design/icons-vue';
import { jdate, price } from '../utils/filters';
import { useLayoutStore } from '../stores/layout';
import api from '../api/client';
const loading = ref(false);
const layout = useLayoutStore();
const chartHeight = computed(() => (layout.isMobile ? 240 : 300));
const donutHeight = computed(() => (layout.isMobile ? 220 : 240));
const barChartHeight = computed(() => (layout.isMobile ? 260 : 320));
interface OrdersTrendPoint {
date: string;
total: number;
retail: number;
wholesale: number;
}
interface StatusBreakdownRow {
status: string;
label: string;
count: number;
}
interface ChannelBreakdownRow {
channel: string;
label: string;
count: number;
grams: number;
}
interface TopProductRow {
title: string;
weight: number;
qty: number;
}
const data = reactive<Record<string, any>>({
customerCount: 0,
allgram: 0,
ordercount: 0,
reagent: [],
lastOrder: [],
sumLastOrder: 0,
sumLastOrderMonth: 0,
ordersTrend: [] as OrdersTrendPoint[],
statusBreakdown: [] as StatusBreakdownRow[],
channelBreakdown: [] as ChannelBreakdownRow[],
topProducts: [] as TopProductRow[],
goldPricePerGram: null as number | null,
totalCustomerDebtGrams: 0,
});
// ── colours matching the rest of the admin panel (StatisticsCardLine's own
// icon-color classes and ant's card chrome) ────────────────────────────────
const COLOR_PRIMARY = '#1c2d56';
const COLOR_SUCCESS = '#28c76f';
const COLOR_WARNING = '#ff9f43';
const COLOR_DANGER = '#ea5455';
const CHANNEL_COLORS = [COLOR_PRIMARY, COLOR_SUCCESS];
const STATUS_COLORS = [
COLOR_WARNING,
COLOR_PRIMARY,
COLOR_PRIMARY,
COLOR_WARNING,
COLOR_WARNING,
COLOR_SUCCESS,
COLOR_SUCCESS,
COLOR_DANGER,
];
const BASE_CHART = { fontFamily: 'inherit', parentHeightOffset: 0, toolbar: { show: false } };
const AXIS_LABEL_COLOR = '#8a94a6';
const GRID = { borderColor: '#eef0f5', strokeDashArray: 4 };
const orderColumns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'مشتری', dataIndex: 'customer' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'وضعیت', dataIndex: 'sts_str' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
function tagColor(c: string) {
return { success: 'green', warning: 'orange', danger: 'red', primary: 'blue' }[c] ?? '';
}
function fmt(v: unknown) {
return price(v ?? 0) || '0';
}
const todayLabel = computed(() => jdate(new Date().toISOString()));
const goldPriceLabel = computed(() =>
data.goldPricePerGram ? `${price(Math.round(Number(data.goldPricePerGram)))} تومان` : '—',
);
const totalDebtLabel = computed(() => price(Number(data.totalCustomerDebtGrams ?? 0).toFixed(2)));
const avgWeightPerOrder = computed(() => {
const count = Number(data.sumLastOrder ?? 0);
const weight = Number(data.sumLastOrderMonth ?? 0);
return count > 0 ? (weight / count).toFixed(2) : '0';
});
// ── KPI sparkline ─────────────────────────────────────────────────────────
const trendTotals = computed(() => (data.ordersTrend ?? []).map((d: OrdersTrendPoint) => d.total));
const sparkOptions = {
chart: { ...BASE_CHART, sparkline: { enabled: true }, animations: { enabled: false } },
colors: [COLOR_SUCCESS],
stroke: { curve: 'smooth', width: 2 },
fill: { type: 'gradient', gradient: { opacityFrom: 0.35, opacityTo: 0 } },
tooltip: { enabled: false },
};
// ── روند سفارش‌ها ──────────────────────────────────────────────────────────
const trendType = ref<'area' | 'bar'>('area');
const trendTypeOptions = [
{ label: 'ناحیه‌ای', value: 'area' },
{ label: 'ستونی', value: 'bar' },
];
const retailTotal = computed(() =>
(data.ordersTrend ?? []).reduce((s: number, d: OrdersTrendPoint) => s + Number(d.retail ?? 0), 0),
);
const wholesaleTotal = computed(() =>
(data.ordersTrend ?? []).reduce((s: number, d: OrdersTrendPoint) => s + Number(d.wholesale ?? 0), 0),
);
const trendSeries = computed(() => {
const trend: OrdersTrendPoint[] = data.ordersTrend ?? [];
return [
{ name: 'تک فروشی', data: trend.map((d) => d.retail) },
{ name: 'عمده فروشی', data: trend.map((d) => d.wholesale) },
];
});
const trendChartOptions = computed(() => ({
chart: { ...BASE_CHART, stacked: trendType.value === 'bar' },
colors: [COLOR_PRIMARY, COLOR_SUCCESS],
dataLabels: { enabled: false },
stroke: trendType.value === 'area' ? { curve: 'smooth', width: 2.5 } : { width: 0 },
fill:
trendType.value === 'area'
? { type: 'gradient', gradient: { opacityFrom: 0.3, opacityTo: 0.02 } }
: { opacity: 1 },
plotOptions: { bar: { columnWidth: '55%', borderRadius: 3, borderRadiusApplication: 'end' } },
legend: { show: false },
grid: { ...GRID, padding: layout.isMobile ? { left: 4, right: 4 } : {} },
markers: { size: 0, hover: { size: 5 } },
xaxis: {
// the axis drops the year (1405/07/04 -> 07/04) so 30 labels fit
// unrotated; the tooltip below still shows the full date
categories: (data.ordersTrend ?? []).map((d: OrdersTrendPoint) => String(jdate(d.date) ?? '').slice(5)),
tickAmount: layout.isMobile ? 6 : 10,
axisBorder: { show: false },
axisTicks: { show: false },
labels: {
rotate: 0,
hideOverlappingLabels: true,
style: { colors: AXIS_LABEL_COLOR, fontSize: layout.isMobile ? '10px' : '12px' },
},
},
yaxis: {
labels: {
style: { colors: AXIS_LABEL_COLOR, fontSize: layout.isMobile ? '10px' : '12px' },
formatter: (v: number) => String(Math.round(v)),
},
},
tooltip: {
shared: true,
intersect: false,
x: {
formatter: (_v: unknown, opts: { dataPointIndex: number }) =>
jdate((data.ordersTrend ?? [])[opts?.dataPointIndex]?.date),
},
},
}));
// ── تفکیک عمده/تک ────────────────────────────────────────────────────────
const channelSeries = computed(() =>
(data.channelBreakdown ?? []).map((c: ChannelBreakdownRow) => Number(c.count ?? 0)),
);
const channelChartOptions = computed(() => ({
chart: { ...BASE_CHART },
labels: (data.channelBreakdown ?? []).map((c: ChannelBreakdownRow) => c.label),
colors: CHANNEL_COLORS,
legend: { show: false },
dataLabels: { enabled: false },
stroke: { width: 3, colors: ['#fff'] },
plotOptions: {
pie: {
donut: {
size: '74%',
labels: {
show: true,
name: { fontSize: '13px', color: AXIS_LABEL_COLOR, offsetY: 18 },
value: { fontSize: '16px', fontWeight: 500, color: COLOR_PRIMARY, offsetY: -14 },
total: {
show: true,
label: 'کل سفارش‌ها',
color: AXIS_LABEL_COLOR,
fontSize: '13px',
formatter: (w: { globals: { seriesTotals: number[] } }) =>
String(w.globals.seriesTotals.reduce((a, b) => a + b, 0)),
},
},
},
},
},
}));
// ── وضعیت سفارشات: distributed horizontal bar ───────────────────────────
const statusTotal = computed(() =>
(data.statusBreakdown ?? []).reduce((s: number, r: StatusBreakdownRow) => s + Number(r.count ?? 0), 0),
);
const statusSeries = computed(() => [
{ name: 'تعداد', data: (data.statusBreakdown ?? []).map((s: StatusBreakdownRow) => Number(s.count ?? 0)) },
]);
const statusChartHeight = computed(() =>
Math.max(200, (data.statusBreakdown?.length ?? 0) * (layout.isMobile ? 38 : 44) + 40),
);
const statusChartOptions = computed(() => ({
chart: { ...BASE_CHART },
colors: STATUS_COLORS,
plotOptions: {
bar: { horizontal: true, distributed: true, borderRadius: 6, borderRadiusApplication: 'end', barHeight: '58%' },
},
legend: { show: false },
dataLabels: {
enabled: true,
textAnchor: 'start',
offsetX: 8,
style: { colors: ['#fff'], fontWeight: 500, fontSize: '12px' },
},
grid: { ...GRID, xaxis: { lines: { show: true } }, yaxis: { lines: { show: false } } },
xaxis: {
categories: (data.statusBreakdown ?? []).map((s: StatusBreakdownRow) => s.label),
axisBorder: { show: false },
axisTicks: { show: false },
labels: { style: { colors: AXIS_LABEL_COLOR, fontSize: '11px' } },
},
yaxis: {
labels: { maxWidth: layout.isMobile ? 110 : 160, style: { colors: '#475467', fontSize: '12px', fontWeight: 500 } },
},
tooltip: { y: { formatter: (v: number) => `${v} سفارش` } },
}));
// ── عملکرد کارشناسان: ranked list ───────────────────────────────────────
const agentMax = computed(() =>
Math.max(1, ...(data.reagent ?? []).map((r: any) => Number(r.grams_sell ?? 0))),
);
function agentShare(r: any) {
return Math.max(2, Math.round((Number(r.grams_sell ?? 0) / agentMax.value) * 100));
}
// ── محصولات پرفروش: bar (horizontal on phones, so long titles stay readable)
const productChartSeries = computed(() => [
{ name: 'وزن فروخته‌شده (گرم)', data: (data.topProducts ?? []).map((p: TopProductRow) => Number(p.weight ?? 0)) },
]);
const productChartOptions = computed(() => ({
chart: { ...BASE_CHART },
plotOptions: {
bar: layout.isMobile
? { horizontal: true, borderRadius: 6, borderRadiusApplication: 'end', barHeight: '60%' }
: { borderRadius: 8, borderRadiusApplication: 'end', columnWidth: '45%' },
},
colors: [COLOR_SUCCESS],
fill: {
type: 'gradient',
gradient: { shade: 'light', type: 'vertical', shadeIntensity: 0.2, opacityFrom: 1, opacityTo: 0.75 },
},
dataLabels: { enabled: false },
grid: GRID,
xaxis: {
categories: (data.topProducts ?? []).map((p: TopProductRow) => p.title),
axisBorder: { show: false },
axisTicks: { show: false },
labels: {
trim: true,
style: { colors: AXIS_LABEL_COLOR, fontSize: layout.isMobile ? '10px' : '12px' },
},
},
yaxis: {
labels: { maxWidth: layout.isMobile ? 120 : 160, style: { colors: AXIS_LABEL_COLOR, fontSize: layout.isMobile ? '11px' : '12px' } },
},
tooltip: { y: { formatter: (v: number) => `${v} گرم` } },
}));
onMounted(async () => {
try {
loading.value = true;
const { data: res } = await api.get('dashboard/overview');
Object.assign(data, res);
// legacy sorted both lists client-side after the fetch
data.reagent = [...(data.reagent ?? [])].sort(
(a: any, b: any) => Number(b.grams_sell) - Number(a.grams_sell),
);
data.lastOrder = [...(data.lastOrder ?? [])].sort(
(a: any, b: any) => Number(b.w) - Number(a.w),
);
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
});
</script>
<style scoped>
b {
font-weight: 500;
}
.dashboard {
--c-primary: #1c2d56;
--c-success: #28c76f;
--c-warning: #ff9f43;
--c-danger: #ea5455;
--ink: #101828;
--muted: #8a94a6;
--line: #eef0f5;
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
/* ── header ── */
.dash-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.dash-head__title {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--ink);
}
.dash-head__sub {
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
}
.dash-head__date {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 12px;
background: #fff;
border: 1px solid var(--line);
color: var(--c-primary);
font-weight: 500;
font-size: 13px;
}
/* ── panels ── */
.panel,
.kpi {
min-width: 0;
background: #fff;
border: 1px solid var(--line);
border-radius: 18px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 8px 24px -12px rgba(16, 24, 40, 0.08);
}
.panel {
padding: 20px;
display: flex;
flex-direction: column;
}
.panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.panel__title {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--ink);
}
.panel__sub {
margin: 2px 0 0;
font-size: 12px;
color: var(--muted);
}
.pill {
flex-shrink: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(28, 45, 86, 0.06);
color: var(--c-primary);
font-size: 12px;
font-weight: 500;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.row {
display: grid;
gap: 20px;
}
.row--main {
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
}
.row--half {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* ── KPI cards ── */
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
}
.kpi {
--tone: var(--c-primary);
--tone-bg: rgba(28, 45, 86, 0.08);
position: relative;
overflow: hidden;
padding: 18px 18px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.kpi::before {
content: '';
position: absolute;
inset-inline-start: 0;
top: 18px;
bottom: 18px;
width: 4px;
border-radius: 4px;
background: var(--tone);
}
.kpi--success { --tone: var(--c-success); --tone-bg: rgba(40, 199, 111, 0.12); }
.kpi--warning { --tone: var(--c-warning); --tone-bg: rgba(255, 159, 67, 0.14); }
.kpi--danger { --tone: var(--c-danger); --tone-bg: rgba(234, 84, 85, 0.12); }
.kpi__top {
display: flex;
align-items: center;
gap: 10px;
}
.kpi__icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
flex-shrink: 0;
font-size: 16px;
color: var(--tone);
background: var(--tone-bg);
}
.kpi__label {
font-size: 12.5px;
color: var(--muted);
line-height: 1.6;
}
.kpi__value {
font-size: 16px;
font-weight: 500;
line-height: 1.2;
color: var(--ink);
display: flex;
align-items: baseline;
gap: 6px;
}
.kpi__value small {
font-size: 13px;
font-weight: 500;
color: var(--muted);
}
.kpi__value--price {
font-size: 16px;
}
.kpi__foot {
margin-top: auto;
padding-top: 10px;
border-top: 1px dashed var(--line);
font-size: 12px;
color: var(--muted);
}
.kpi__foot b {
color: var(--ink);
font-weight: 500;
}
.kpi__spark {
margin: auto -18px -14px;
}
/* ── trend totals ── */
.trend-totals {
display: flex;
gap: 24px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.trend-totals__item {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
}
.trend-totals__item b {
font-size: 16px;
color: var(--ink);
}
/* ── legend list (channel donut) ── */
.legend-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.legend-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
background: #f8f9fc;
font-size: 13px;
}
.legend-list__label {
flex: 1;
color: var(--ink);
font-weight: 500;
}
.legend-list__meta {
color: var(--muted);
font-size: 12px;
}
.legend-list b {
min-width: 32px;
text-align: left;
color: var(--ink);
}
/* ── metric list (sales summary) ── */
.metric-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
justify-content: center;
}
.metric-list li {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 14px;
border: 1px solid var(--line);
}
.metric-list__icon {
width: 38px;
height: 38px;
border-radius: 11px;
display: grid;
place-items: center;
font-size: 16px;
flex-shrink: 0;
}
.metric-list__label {
flex: 1;
font-size: 13px;
color: var(--muted);
}
.metric-list b {
font-size: 15px;
color: var(--ink);
white-space: nowrap;
}
.tone-primary { color: var(--c-primary); background: rgba(28, 45, 86, 0.08); }
.tone-success { color: var(--c-success); background: rgba(40, 199, 111, 0.12); }
.tone-warning { color: var(--c-warning); background: rgba(255, 159, 67, 0.14); }
.tone-danger { color: var(--c-danger); background: rgba(234, 84, 85, 0.12); }
/* ── ranked agents ── */
.rank-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 14px;
max-height: 340px;
overflow-y: auto;
padding-inline-end: 4px;
}
.rank-list li {
display: flex;
align-items: flex-start;
gap: 12px;
}
.rank-list__pos {
width: 30px;
height: 30px;
border-radius: 10px;
display: grid;
place-items: center;
flex-shrink: 0;
font-size: 13px;
font-weight: 500;
color: var(--muted);
background: #f2f4f7;
}
.rank-list__pos.is-top {
color: #fff;
background: var(--c-primary);
}
.rank-list__body {
flex: 1;
min-width: 0;
}
.rank-list__row {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 13px;
}
.rank-list__name {
color: var(--ink);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-list__row b {
color: var(--ink);
white-space: nowrap;
}
.rank-list__bar {
height: 6px;
margin: 6px 0 4px;
border-radius: 999px;
background: #f2f4f7;
overflow: hidden;
}
.rank-list__bar span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--c-primary);
transition: width 0.6s ease;
}
.rank-list__meta {
font-size: 11.5px;
color: var(--muted);
}
/* ── weekly orders ── */
.dash-table :deep(.ant-table-thead > tr > th) {
background: #f8f9fc;
color: var(--muted);
font-weight: 500;
font-size: 12.5px;
}
.dash-table :deep(.ant-table-thead > tr > th::before) {
display: none;
}
.order-list :deep(.ant-list-pagination) {
margin-top: 12px;
}
.order-item {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 12px 0;
}
.order-item__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.order-item__name {
font-weight: 500;
color: var(--c-primary);
}
.order-item__tag {
margin: 0;
}
.order-item__customer {
font-size: 13px;
color: #475467;
}
.order-item__meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--muted);
}
/* ── responsive ── */
@media (max-width: 1280px) {
.kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 991px) {
.dashboard,
.row,
.kpi-grid {
gap: 12px;
}
.row--main,
.row--half {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 575px) {
.dash-head__title {
font-size: 16px;
}
.panel {
padding: 14px;
border-radius: 16px;
}
.kpi {
padding: 14px 14px 12px;
border-radius: 16px;
}
.kpi__spark {
margin: auto -14px -12px;
}
.kpi__icon {
width: 34px;
height: 34px;
font-size: 16px;
}
.kpi__value {
font-size: 16px;
}
.kpi__value--price {
font-size: 16px;
}
.kpi__label,
.kpi__foot {
font-size: 11px;
}
.trend-totals__item b {
font-size: 15px;
}
}
@media (max-width: 420px) {
.kpi-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>