2026-09-04 10:22:37 +08:00

1498 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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 {
AlertTriangle,
BarChart3,
ChevronDown,
ClipboardList,
Factory,
FileWarning,
LoaderCircle,
RefreshCw,
ShieldCheck,
TrendingUp,
} from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { getDashboard } from "@/services/dashboard.service";
import type { DashboardData, DashboardYear } from "@/types/dashboard";
const router = useRouter();
const year = ref<DashboardYear>("all");
const data = ref<DashboardData | null>(null);
const loading = ref(false);
const error = ref("");
const selected = ref("");
const metricIcons = [
ClipboardList,
FileWarning,
Factory,
TrendingUp,
ShieldCheck,
];
const metricTones = ["neutral", "red", "amber", "green", "blue"];
const defectColors: Record<string, string> = {
A: "#dc2626",
B: "#fde68a",
C: "#bbf7d0",
D: "#e5e7eb",
};
const maxStage = computed(() =>
Math.max(...(data.value?.stages.map((item) => item.count) ?? [1]), 1),
);
const maxCategory = computed(() =>
Math.max(...(data.value?.categories.map((item) => item.count) ?? [1]), 1),
);
const maxSupplier = computed(() =>
Math.max(
...(data.value?.supplierRanking.map((item) => item.count) ?? [1]),
1,
),
);
const maxTrend = computed(() =>
Math.max(...(data.value?.trend.map((item) => item.count) ?? [1]), 1),
);
const totalDefects = computed(
() => data.value?.defects.reduce((sum, item) => sum + item.count, 0) ?? 0,
);
const donutStyle = computed(() => {
const items = data.value?.defects ?? [];
const total = totalDefects.value || 1;
let cursor = 0;
const stops = items.map((item) => {
const start = cursor;
cursor += (item.count / total) * 360;
return `${defectColors[item.level] ?? defectColors.D} ${start}deg ${cursor}deg`;
});
return { background: `conic-gradient(${stops.join(", ")})` };
});
function formatNumber(value: number | null | undefined) {
return value === null || value === undefined
? "--"
: value.toLocaleString("zh-CN");
}
function formatChange(value: number | null) {
return value === null
? "暂无同比"
: `${value >= 0 ? "↑" : "↓"}${Math.abs(value)}%`;
}
function tierClass(tier: string) {
return `tier-${tier}`;
}
function riskClass(level: string) {
if (level === "高风险") return "risk-high";
if (level === "中高") return "risk-elevated";
if (level === "中等") return "risk-medium";
return "risk-low";
}
function heatClass(value: number, max: number) {
if (!value) return "heat-0";
const ratio = value / Math.max(max, 1);
return ratio >= 0.75
? "heat-4"
: ratio >= 0.5
? "heat-3"
: ratio >= 0.25
? "heat-2"
: "heat-1";
}
function rankClass(index: number) {
return index < 2 ? "rank-red" : index < 4 ? "rank-amber" : "rank-blue";
}
function selectDrilldown(label: string) {
selected.value = label;
}
function openAssessment(label: string) {
router.push({ name: "assessment", query: { dashboardFilter: label } });
}
async function load() {
loading.value = true;
error.value = "";
try {
data.value = await getDashboard(year.value);
} catch {
error.value = "看板数据加载失败,请确认后端服务已启动。";
} finally {
loading.value = false;
}
}
async function changeYear(value: DashboardYear) {
year.value = value;
await load();
}
onMounted(() => void load());
</script>
<template>
<div class="dashboard-page">
<header class="dashboard-header">
<div>
<p class="eyebrow">RISK INTELLIGENCE</p>
<h1>数据看板 <small>v2.3</small></h1>
<p class="dashboard-subtitle">
历史异常、供应商风险与标准覆盖的全局视图
</p>
</div>
<div class="year-filter" role="group" aria-label="年份筛选">
<button
v-for="item in [
['all', '全部'],
['2024', '2024'],
['2025', '2025'],
['2026', '2026'],
] as const"
:key="item[0]"
type="button"
:class="{ active: year === item[0] }"
@click="changeYear(item[0])"
>
{{ item[1] }}</button
><ChevronDown :size="14" />
</div>
</header>
<div v-if="loading" class="dashboard-state">
<LoaderCircle :size="18" class="spin" />正在加载看板数据…
</div>
<div v-else-if="error" class="dashboard-state dashboard-state--error">
<AlertTriangle :size="18" />{{ error
}}<button type="button" @click="load">
<RefreshCw :size="14" />重试
</button>
</div>
<template v-else-if="data">
<section class="stats-row">
<article
v-for="(metric, index) in data.metrics"
:key="metric.label"
class="stat-card"
>
<span class="stat-icon" :class="`stat-icon--${metricTones[index]}`"
><component :is="metricIcons[index]" :size="17"
/></span>
<div class="stat-copy">
<span>{{ metric.label }}</span
><strong
>{{
metric.label === "预测准确率"
? "--"
: formatNumber(metric.value)
}}<small v-if="metric.label === '预测准确率'"
>暂无对照数据</small
></strong
>
</div>
<em
v-if="metric.changePercent !== null"
:class="metric.changePercent >= 0 ? 'change-up' : 'change-down'"
>{{ formatChange(metric.changePercent) }}</em
>
</article>
</section>
<div v-if="selected" class="selection-bar">
当前查看:<b>{{ selected }}</b
><button type="button" @click="selected = ''">清除</button
><button type="button" @click="openAssessment(selected)">
进入对话分析
</button>
</div>
<section class="grid-two dashboard-primary-grid">
<article class="panel">
<header class="panel-header">
<div>
<BarChart3 :size="15" />
<h2>五阶段风险热力图</h2>
</div>
<span>异常数量 / 占比</span>
</header>
<div class="stage-list">
<button
v-for="item in data.stages"
:key="item.name"
type="button"
class="stage-row"
@click="selectDrilldown(item.name)"
>
<span>{{ item.name }}</span
><span class="bar-track" :class="riskClass(item.riskLevel)"
><i
:class="riskClass(item.riskLevel)"
:style="{
width: `${Math.max(3, (item.count / maxStage) * 100)}%`,
}"
></i
><b
>{{ formatNumber(item.count) }} 条 · {{ item.share }}%</b
></span
>
</button>
</div>
<p class="panel-note">
产品开发阶段是风险高发期,点击阶段可带入对话继续分析。
</p>
</article>
<article class="panel">
<header class="panel-header">
<div>
<BarChart3 :size="15" />
<h2>品类维度风险分布</h2>
<span class="new-tag">新</span>
</div>
<span>全部分类 · 默认展示 5 条</span>
</header>
<div class="category-list">
<button
v-for="item in data.categories"
:key="item.name"
type="button"
class="category-row"
@click="selectDrilldown(item.name)"
>
<span class="category-name">{{ item.name }}</span
><span class="bar-track" :class="riskClass(item.riskLevel)"
><i
:class="riskClass(item.riskLevel)"
:style="{
width: `${Math.max(3, (item.count / maxCategory) * 100)}%`,
}"
></i
><b
>{{ formatNumber(item.count) }} 条 · {{ item.riskLevel }}</b
></span
>
</button>
</div>
<p class="panel-note">
产品名称按扩展品类字典归并;仅缺少产品品类但有物料字段的记录归入包材/物料专项,缺少必要字段的记录归入待补标品类。
</p>
</article>
</section>
<section class="panel panel-wide">
<header class="panel-header">
<div>
<Factory :size="15" />
<h2>供应商 / 加工厂 × 品类交叉热力图</h2>
<span class="new-tag">新</span>
</div>
<span>全部主体 × 全部品类 · 默认展示 5 条</span>
</header>
<div class="table-scroll matrix-scroll">
<table class="cross-table">
<thead>
<tr>
<th>主体</th>
<th v-for="category in data.matrixCategories" :key="category">
{{ category.replace("/洗护类", "").replace("类", "") }}
</th>
<th>合作总数</th>
<th>异常率</th>
<th>层级</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in data.matrix"
:key="`${row.partyType}-${row.name}`"
>
<th>
<button type="button" @click="selectDrilldown(row.name)">
{{ row.name }}<small>{{ row.partyType }}</small>
</button>
</th>
<td v-for="category in data.matrixCategories" :key="category">
<button
type="button"
:class="
heatClass(
row.categories[category] ?? 0,
row.cooperationTotal,
)
"
@click="selectDrilldown(`${row.name} · ${category}`)"
>
{{ row.categories[category] || "--" }}
</button>
</td>
<td>
<b>{{ formatNumber(row.cooperationTotal) }}</b>
</td>
<td>
<b>{{ row.anomalyRate }}%</b>
</td>
<td>
<span class="tier-badge" :class="tierClass(row.tier)">{{
row.tier
}}</span>
</td>
</tr>
</tbody>
</table>
</div>
<p class="panel-note">
色块越深表示对应品类异常频次越高;异常率按异常记录数 ÷ 合作总数计算。
</p>
</section>
<section class="grid-two">
<article class="panel">
<header class="panel-header">
<div>
<FileWarning :size="15" />
<h2>缺陷等级分布</h2>
</div>
<span>共 {{ formatNumber(totalDefects) }} 条</span>
</header>
<div class="defect-content">
<div class="donut" :style="donutStyle">
<div>
<strong>{{ formatNumber(totalDefects) }}</strong
><span>总异常</span>
</div>
</div>
<div class="defect-legend">
<button
v-for="item in data.defects"
:key="item.level"
type="button"
@click="selectDrilldown(item.label)"
>
<i :style="{ background: defectColors[item.level] }"></i
>{{ item.label }}<b>{{ item.share }}% · {{ item.count }}</b>
</button>
</div>
</div>
<p class="panel-note">
未标注缺陷等级的早期记录按 D 级/未标注展示,保留原始数据。
</p>
</article>
<article class="panel">
<header class="panel-header">
<div>
<TrendingUp :size="15" />
<h2>高风险供应商 / 加工厂排行</h2>
<span class="new-tag">新</span>
</div>
<span>全部主体 · 默认展示 5 条</span>
</header>
<div class="rank-list">
<button
v-for="(item, index) in data.supplierRanking"
:key="`${item.partyType}-${item.name}`"
type="button"
class="rank-row"
@click="selectDrilldown(item.name)"
>
<span class="rank-number">{{ index + 1 }}</span
><span class="rank-name"
>{{ item.name }}<small>{{ item.partyType }}</small></span
><span class="rank-track"
><i
:class="rankClass(index)"
:style="{ width: `${(item.count / maxSupplier) * 100}%` }"
></i></span
><b>{{ item.count }}</b
><span class="tier-badge rank-tier" :class="rankClass(index)">{{
item.tier
}}</span>
</button>
</div>
<p class="panel-note">
层级按异常记录次数和 A 级缺陷判定;点击主体可继续进入对话分析。
</p>
</article>
</section>
<section class="panel panel-wide">
<header class="panel-header">
<div>
<TrendingUp :size="15" />
<h2>月度异常趋势</h2>
</div>
<span>近 12 个月</span>
</header>
<div v-if="!data.trend.length" class="empty-state">
该时间段暂无数据
</div>
<div v-else class="trend-chart">
<button
v-for="item in data.trend"
:key="item.month"
type="button"
class="trend-column"
@click="selectDrilldown(item.month)"
>
<span class="trend-value">{{ item.count }}</span
><i
:class="{ latest: item === data.trend[data.trend.length - 1] }"
:style="{
height: `${Math.max(3, (item.count / maxTrend) * 100)}%`,
}"
></i
><small>{{ item.label }}</small>
</button>
</div>
<div class="insight">
<TrendingUp :size="15" /><span>{{ data.insight }}</span>
</div>
</section>
<section v-if="data.warnings.length" class="warning-list">
<div v-for="warning in data.warnings" :key="warning">
<AlertTriangle :size="14" />{{ warning }}
</div>
</section>
</template>
</div>
</template>
<style scoped>
.dashboard-page {
width: min(1320px, 100%);
margin: 0 auto;
padding: 28px 34px 48px;
overflow: auto;
}
.dashboard-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}
.dashboard-header h1 {
margin: 4px 0 3px;
color: var(--color-text);
font-size: 24px;
font-weight: 750;
}
.dashboard-header h1 small {
color: var(--color-text-faint);
font-size: 11px;
font-weight: 500;
}
.dashboard-subtitle {
margin: 0;
color: var(--color-text-muted);
font-size: 11px;
}
.year-filter {
display: inline-flex;
align-items: center;
gap: 2px;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 3px;
background: var(--color-surface);
}
.year-filter button {
min-width: 46px;
height: 29px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--color-text-muted);
font-size: 11px;
}
.year-filter button.active {
background: var(--color-text);
color: white;
}
.year-filter svg {
margin: 0 5px;
color: var(--color-text-faint);
}
.stats-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
border: 1px solid var(--color-border);
border-radius: 7px;
background: var(--color-surface);
}
.stat-card {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
padding: 16px 14px;
}
.stat-card + .stat-card {
border-left: 1px solid var(--color-border-light);
}
.stat-icon {
display: grid;
width: 35px;
height: 35px;
flex: 0 0 35px;
place-items: center;
border-radius: 6px;
}
.stat-icon--neutral {
background: var(--color-bg-warm);
color: var(--color-text-muted);
}
.stat-icon--red {
background: var(--color-danger-soft);
color: var(--color-danger);
}
.stat-icon--amber {
background: var(--color-warning-soft);
color: var(--color-warning);
}
.stat-icon--green {
background: var(--color-success-soft);
color: var(--color-success);
}
.stat-icon--blue {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.stat-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.stat-copy > span {
overflow: hidden;
color: var(--color-text-muted);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-copy strong {
display: flex;
align-items: baseline;
gap: 4px;
color: var(--color-text);
font-size: 19px;
line-height: 1.1;
}
.stat-copy strong small {
color: var(--color-text-faint);
font-size: 8px;
font-weight: 400;
}
.stat-card em {
margin-left: auto;
border-radius: 4px;
padding: 2px 5px;
font-size: 9px;
font-style: normal;
font-weight: 650;
white-space: nowrap;
}
.change-up {
background: var(--color-danger-soft);
color: var(--color-danger);
}
.change-down {
background: var(--color-success-soft);
color: var(--color-success);
}
.grid-two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
.dashboard-primary-grid {
grid-template-rows: 356px;
margin-top: 0;
margin-bottom: 16px;
}
.dashboard-primary-grid > .panel {
display: flex;
height: 356px;
min-height: 0;
flex-direction: column;
}
.dashboard-primary-grid .stage-list,
.dashboard-primary-grid .category-list {
min-height: 0;
}
.dashboard-primary-grid .stage-list {
flex: 1;
align-content: start;
}
.dashboard-primary-grid .category-list {
flex: 1;
max-height: 180px;
overflow-y: auto;
align-content: start;
scrollbar-color: var(--color-border-strong) transparent;
scrollbar-width: thin;
}
.dashboard-primary-grid .category-list::-webkit-scrollbar {
width: 4px;
}
.dashboard-primary-grid .category-list::-webkit-scrollbar-thumb {
border-radius: 4px;
background: var(--color-border-strong);
}
.panel {
min-width: 0;
border: 1px solid var(--color-border);
border-radius: 7px;
padding: 18px;
background: var(--color-surface);
box-shadow: var(--shadow-sm);
}
.panel-wide {
margin-top: 14px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 15px;
}
.panel-header > div {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.panel-header svg {
color: var(--color-brand);
}
.panel-header h2 {
margin: 0;
color: var(--color-text);
font-size: 13px;
font-weight: 700;
}
.panel-header > span {
color: var(--color-text-faint);
font-size: 9px;
white-space: nowrap;
}
.new-tag {
border-radius: 3px;
padding: 1px 4px;
background: var(--color-accent-subtle);
color: var(--color-accent) !important;
font-size: 8px !important;
font-weight: 700;
}
.stage-list,
.category-list {
display: grid;
gap: 9px;
}
.stage-row,
.category-row {
display: grid;
min-height: 28px;
grid-template-columns: 92px minmax(80px, 1fr) 44px 38px;
align-items: center;
gap: 8px;
border: 0;
background: transparent;
color: var(--color-text-secondary);
text-align: left;
}
.category-row {
grid-template-columns: 112px minmax(80px, 1fr) 44px 40px;
}
.stage-row > span:first-child,
.category-name {
overflow: hidden;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar-track,
.rank-track {
display: block;
height: 10px;
overflow: hidden;
border-radius: 3px;
background: var(--color-bg-warm);
}
.bar-track i,
.rank-track i {
display: block;
height: 100%;
border-radius: inherit;
}
.risk-high {
background: var(--color-danger);
}
.risk-medium {
background: var(--color-warning);
}
.risk-low {
background: var(--color-success);
}
.risk-elevated {
background: #f97316;
}
.risk-moderately-low {
background: var(--color-accent);
}
.stage-row b,
.category-row b {
color: var(--color-text);
font-size: 10px;
text-align: right;
}
.stage-row small {
color: var(--color-text-faint);
font-size: 9px;
text-align: right;
}
.category-row em {
color: var(--color-text-muted);
font-size: 9px;
font-style: normal;
text-align: right;
}
.category-row em.risk-high {
color: var(--color-danger);
}
.category-row em.risk-medium {
color: var(--color-warning);
}
.category-row em.risk-low {
color: var(--color-success);
}
.panel-note {
margin: 14px 0 0;
border-top: 1px dashed var(--color-border-light);
padding-top: 9px;
color: var(--color-text-faint);
font-size: 10px;
line-height: 1.55;
}
.table-scroll {
overflow-x: auto;
}
.matrix-scroll {
max-height: 244px;
overflow-y: auto;
scrollbar-color: var(--color-border-strong) transparent;
scrollbar-width: thin;
}
.matrix-scroll::-webkit-scrollbar {
width: 4px;
}
.matrix-scroll::-webkit-scrollbar-thumb {
border-radius: 4px;
background: var(--color-border-strong);
}
.matrix-scroll {
max-height: 244px;
overflow-y: auto;
scrollbar-color: var(--color-border-strong) transparent;
scrollbar-width: thin;
}
.matrix-scroll::-webkit-scrollbar {
width: 4px;
}
.matrix-scroll::-webkit-scrollbar-thumb {
border-radius: 4px;
background: var(--color-border-strong);
}
.cross-table {
width: 100%;
min-width: 780px;
border-collapse: collapse;
color: var(--color-text-secondary);
font-size: 10px;
}
.cross-table th,
.cross-table td {
border-bottom: 1px solid var(--color-border-light);
padding: 8px 7px;
text-align: center;
white-space: nowrap;
}
.cross-table thead th {
color: var(--color-text-faint);
font-size: 9px;
font-weight: 600;
}
.cross-table tbody th {
text-align: left;
}
.cross-table tbody th button {
display: grid;
border: 0;
background: transparent;
color: #111;
font-size: 12px;
text-align: left;
}
.cross-table tbody th small {
margin-top: 2px;
color: #555;
font-size: 10px;
}
.cross-table td button {
min-width: 38px;
border: 0;
border-radius: 3px;
padding: 5px 4px;
color: #111;
font-size: 11px;
}
.heat-0 {
background: var(--color-bg-warm);
color: var(--color-text-faint) !important;
}
.heat-1 {
background: #f0fdf4;
color: #065f46;
}
.heat-2 {
background: #fef3c7;
color: #92400e;
}
.heat-3 {
background: #fed7aa;
color: #9a3412;
}
.heat-4 {
background: #fecaca;
color: #991b1b !important;
}
.rank-red {
background: var(--color-danger);
}
.rank-amber {
background: var(--color-warning);
}
.rank-blue {
background: var(--color-accent);
}
.rank-tier.rank-red {
background: var(--color-danger-soft);
color: var(--color-danger);
}
.rank-tier.rank-amber {
background: var(--color-warning-soft);
color: var(--color-warning);
}
.rank-tier.rank-blue {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.tier-badge {
display: inline-block;
min-width: 26px;
border-radius: 4px;
padding: 3px 5px;
font-size: 9px;
font-weight: 700;
}
.tier-S {
background: var(--color-success-soft);
color: var(--color-success);
}
.tier-A {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.tier-B {
background: var(--color-warning-soft);
color: var(--color-warning);
}
.tier-C {
background: var(--color-danger-soft);
color: var(--color-danger);
}
.defect-content {
display: flex;
align-items: center;
gap: 25px;
}
.donut {
display: grid;
width: 140px;
height: 140px;
flex: 0 0 140px;
place-items: center;
border-radius: 50%;
}
.donut > div {
display: grid;
width: 91px;
height: 91px;
place-items: center;
align-content: center;
border-radius: 50%;
background: var(--color-surface);
}
.donut strong {
color: var(--color-text);
font-size: 18px;
}
.donut span {
color: var(--color-text-faint);
font-size: 9px;
}
.defect-legend {
display: grid;
min-width: 0;
flex: 1;
gap: 9px;
}
.defect-legend button {
display: grid;
grid-template-columns: 9px minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
border: 0;
background: transparent;
color: #111;
font-size: 12px;
text-align: left;
}
.defect-legend i {
width: 8px;
height: 8px;
border-radius: 50%;
}
.defect-legend b {
color: #111;
font-size: 11px;
font-weight: 500;
}
.rank-list {
display: grid;
gap: 7px;
max-height: 190px;
overflow-y: auto;
padding-right: 4px;
scrollbar-color: var(--color-border-strong) transparent;
scrollbar-width: thin;
}
.rank-list::-webkit-scrollbar {
width: 4px;
}
.rank-list::-webkit-scrollbar-thumb {
border-radius: 4px;
background: var(--color-border-strong);
}
.rank-row {
display: grid;
grid-template-columns: 22px 92px minmax(70px, 1fr) 30px 29px;
align-items: center;
gap: 8px;
border: 0;
background: transparent;
text-align: left;
}
.rank-number {
color: #111;
font-size: 12px;
font-weight: 700;
text-align: center;
}
.rank-name {
display: grid;
color: #111;
font-size: 12px;
}
.rank-name small {
margin-top: 2px;
color: #555;
font-size: 10px;
}
.rank-row > b {
color: #111;
font-size: 12px;
text-align: right;
}
.rank-track {
height: 8px;
}
.trend-chart {
display: flex;
height: 190px;
align-items: flex-end;
gap: 10px;
overflow-x: auto;
border-bottom: 1px solid var(--color-border);
padding: 0 7px;
}
.trend-column {
display: grid;
min-width: 42px;
height: 100%;
grid-template-rows: 21px minmax(30px, 1fr) 24px;
align-items: end;
border: 0;
background: transparent;
color: var(--color-text-faint);
}
.trend-value {
color: #111;
font-size: 11px;
text-align: center;
}
.trend-column i {
display: block;
width: 25px;
justify-self: center;
border-radius: 4px 4px 0 0;
background: var(--color-accent);
}
.trend-column i.latest {
background: var(--color-danger);
}
.trend-column small {
padding-top: 7px;
font-size: 9px;
text-align: center;
}
.insight {
display: flex;
align-items: center;
gap: 7px;
margin-top: 12px;
border-radius: 5px;
padding: 9px 11px;
background: var(--color-bg-warm);
color: var(--color-text-secondary);
font-size: 10px;
}
.insight svg {
flex: 0 0 auto;
color: var(--color-accent);
}
.warning-list {
display: grid;
gap: 6px;
margin-top: 14px;
}
.warning-list div {
display: flex;
align-items: center;
gap: 7px;
border: 1px solid var(--color-warning-border);
border-radius: 5px;
padding: 8px 10px;
background: var(--color-warning-soft);
color: #92400e;
font-size: 10px;
}
.dashboard-state {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--color-text-muted);
font-size: 12px;
}
.dashboard-state--error {
flex-wrap: wrap;
color: var(--color-danger);
}
.dashboard-state button,
.selection-bar button {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 5px 8px;
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 10px;
}
.spin {
animation: spin 1s linear infinite;
}
.selection-bar {
display: flex;
align-items: center;
gap: 7px;
margin-top: 12px;
border: 1px solid var(--color-accent-soft);
border-radius: 5px;
padding: 8px 10px;
background: var(--color-accent-subtle);
color: var(--color-text-secondary);
font-size: 10px;
}
.selection-bar b {
color: var(--color-accent);
}
.selection-bar button:first-of-type {
margin-left: auto;
}
.empty-state {
padding: 55px 0;
color: var(--color-text-faint);
font-size: 11px;
text-align: center;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 1050px) {
.stats-row {
grid-template-columns: repeat(3, 1fr);
}
.stat-card:nth-child(4) {
border-left: 0;
border-top: 1px solid var(--color-border-light);
}
.stat-card:nth-child(5) {
border-top: 1px solid var(--color-border-light);
}
}
@media (max-width: 760px) {
.dashboard-page {
padding: 20px 12px 36px;
}
.dashboard-header {
flex-direction: column;
}
.grid-two {
grid-template-columns: 1fr;
}
.stats-row {
grid-template-columns: repeat(2, 1fr);
}
.stat-card:nth-child(3) {
border-left: 0;
border-top: 1px solid var(--color-border-light);
}
.stat-card:nth-child(5) {
grid-column: 1 / -1;
}
.defect-content {
gap: 14px;
}
.donut {
width: 120px;
height: 120px;
flex-basis: 120px;
}
.donut > div {
width: 78px;
height: 78px;
}
}
@media (max-width: 430px) {
.stats-row {
grid-template-columns: 1fr;
}
.stat-card,
.stat-card:nth-child(4),
.stat-card:nth-child(5) {
border-left: 0;
border-top: 1px solid var(--color-border-light);
}
.stat-card:first-child {
border-top: 0;
}
.category-row {
grid-template-columns: 90px minmax(65px, 1fr) 40px 34px;
}
.defect-content {
align-items: flex-start;
flex-direction: column;
}
.defect-legend {
width: 100%;
}
}
/* Match the prototype dashboard density and visual hierarchy. */
.dashboard-page {
width: min(1160px, 100%);
max-width: none;
padding: 24px 28px 40px;
background: #fafafa;
}
.dashboard-header {
align-items: center;
margin-bottom: 24px;
}
.dashboard-header h1 {
font-size: 20px;
font-weight: 650;
}
.year-filter {
gap: 4px;
border: 0;
padding: 0;
background: transparent;
}
.year-filter button {
min-width: auto;
height: auto;
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 5px 12px;
background: var(--color-surface);
font-size: 12px;
}
.year-filter button.active {
border-color: var(--color-text);
background: var(--color-text);
}
.year-filter svg {
display: none;
}
.stats-row {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
border: 0;
background: transparent;
}
.stat-card {
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 18px;
background: var(--color-surface);
box-shadow: var(--shadow-sm);
}
.stat-card + .stat-card,
.stat-card:nth-child(3),
.stat-card:nth-child(4),
.stat-card:nth-child(5) {
border-left: 1px solid var(--color-border);
}
.grid-two {
gap: 12px;
margin-top: 0;
margin-bottom: 16px;
}
.panel,
.panel-wide {
border-radius: 10px;
padding: 20px;
box-shadow: var(--shadow-sm);
}
.panel-wide {
margin-top: 0;
margin-bottom: 16px;
}
.panel-header {
margin-bottom: 16px;
}
.panel-header h2 {
font-size: 13px;
font-weight: 600;
}
.stage-list,
.category-list {
gap: 6px;
}
.stage-row,
.category-row {
display: grid;
min-height: 28px;
grid-template-columns: 130px minmax(0, 1fr);
gap: 10px;
}
.category-row {
grid-template-columns: 130px minmax(0, 1fr);
}
.stage-row > span:first-child,
.category-name {
overflow: visible;
font-size: 12px;
text-align: right;
white-space: nowrap;
}
.bar-track {
position: relative;
display: flex;
height: 28px;
align-items: center;
overflow: hidden;
border-radius: 6px;
padding: 0 10px;
background: var(--color-bg-warm);
}
.bar-track i {
position: absolute;
inset: 0 auto 0 0;
height: 100%;
border-radius: 6px;
}
.bar-track i.risk-low {
background: #d1fae5;
}
.bar-track i.risk-medium {
background: #fef3c7;
}
.bar-track i.risk-high {
background: #dc2626;
}
.bar-track i.risk-very-high {
background: #fecaca;
}
.bar-track i.risk-elevated {
background: #fee2e2;
}
.bar-track i.risk-moderately-low {
background: #dbeafe;
}
.bar-track.risk-high b {
color: #fff;
}
.bar-track b {
position: relative;
z-index: 1;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 600;
}
.panel-note {
margin-top: 10px;
font-size: 11px;
}
.cross-table {
font-size: 11px;
color: #111;
}
.cross-table th,
.cross-table td {
padding: 6px 8px;
border: 1px solid var(--color-border-light);
}
.cross-table thead th {
background: var(--color-bg-warm);
color: #111;
font-size: 11px;
}
.defect-content {
gap: 24px;
}
.donut {
width: 140px;
height: 140px;
flex-basis: 140px;
}
.donut > div {
width: 91px;
height: 91px;
}
.trend-chart {
height: 180px;
gap: 3px;
padding: 0 6px;
}
.trend-column {
min-width: 0;
flex: 1;
grid-template-rows: 21px minmax(30px, 1fr) 28px;
gap: 3px;
}
.trend-column i {
width: 100%;
max-width: 36px;
border-radius: 4px 4px 0 0;
background: var(--color-accent);
opacity: 0.85;
}
.trend-column i.latest {
background: var(--color-danger);
opacity: 1;
}
.trend-column small {
transform: rotate(-45deg);
padding-top: 3px;
color: #111;
font-size: 10px;
}
.warning-list {
margin-top: 0;
}
@media (max-width: 1050px) {
.stats-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.dashboard-page {
padding: 20px 12px 36px;
}
.dashboard-header {
align-items: flex-start;
}
.stats-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat-card,
.stat-card + .stat-card,
.stat-card:nth-child(3),
.stat-card:nth-child(4),
.stat-card:nth-child(5) {
border-left: 1px solid var(--color-border);
}
.stage-row,
.category-row {
grid-template-columns: 94px minmax(0, 1fr);
}
.stage-row > span:first-child,
.category-name {
font-size: 10px;
}
}
</style>