
【實戰】Day 27:ERP 訂單儀表板 — Recharts 數據可視化
把真實訂單數據搬上螢幕:一支 FastAPI Summary API + Next.js Proxy + Recharts 四圖表,打造鞋墊廠專屬 ERP 儀表板。品牌營收、廠區稼動率、毛利率警示、訂單狀態一覽無遺。
WRITTEN BY

- Name
- Harry Chang
Day 26 把 AI 問答接上前端;Day 27 把訂單數據搬上螢幕。比起空泛的 "Hello World" 圖表,這次的每一條柱狀圖都對應真實的工廠與品牌,讓管理者一眼看到問題在哪。
- 今日目標
- 架構全貌
- 四圖表設計
- Step 1:Backend Summary Endpoint
- Step 2:Next.js API Proxy
- Step 3:Recharts 四圖表實作
- Step 4:KPI 警示卡
- Dashboard警示卡
- Step 5:useEffect 資料載入
- Step 6:Sidebar 導覽
- 測試
- 今日 Checklist
- 小結
今日目標
| 任務 | 說明 |
|---|---|
| Backend Summary API | GET /api/v1/dashboard/summary — 一次回傳四組圖表資料 |
| Next.js Proxy | GET /api/dashboard |
| ERP 儀表板頁面 | /dashboard/erp,KPI 卡 + 四圖表 |
| Sidebar 導覽 | 新增「ERP 儀表板」項目 |
架構全貌
瀏覽器 (React + Recharts)
│ GET /api/dashboard
▼
Next.js Route Handler (/app/api/dashboard/route.ts)
│ GET http://localhost:8000/api/v1/dashboard/summary
▼
FastAPI /routers/dashboard.py
│ order_tools:讀取 orders.json + factories.json
▼
回傳 JSON:{ kpi, brand_revenue, factory_utilization, gross_margin, order_status }
與 Day 26 的串流架構不同,儀表板資料是一次性 GET,不需要 SSE,proxy 直接轉發 JSON 即可。

四圖表設計
| 圖表 | Recharts 元件 | 核心指標 | 警示條件 |
|---|---|---|---|
| 品牌營收分布 | BarChart | 各品牌累計金額 | — |
| 廠區稼動率 | BarChart (horizontal) | 利用率 % | > 85% 顯示紅色虛線 |
| 品牌毛利率 | BarChart + ReferenceLine | 毛利率 % | < 15% 柱子變紅 |
| 訂單狀態分布 | PieChart | 已完成 / 部分出貨 / 待出貨 | — |
Step 1:Backend Summary Endpoint
新增 backend/app/routers/dashboard.py:
from fastapi import APIRouter
from app.data_loader import get_orders, get_factories
from app.tools.order_tools import BRAND_COST_FACTOR
router = APIRouter(prefix="/api/v1", tags=["dashboard"])
BRANDS = ["Nike", "Adidas", "New Balance", "Puma", "Asics", "Under Armour"]
@router.get("/dashboard/summary")
def dashboard_summary():
orders = get_orders()
factories = get_factories()
# 1. 品牌營收
brand_revenue: dict[str, float] = {}
for o in orders:
b = o["brand_name"]
brand_revenue[b] = round(brand_revenue.get(b, 0) + o["total_amount"], 2)
brand_chart = [
{"brand": b, "revenue": brand_revenue.get(b, 0)}
for b in BRANDS if b in brand_revenue
]
# 2. 廠區稼動率
factory_chart = [{
"factory": f["factory_name"],
"factory_code": f["factory_code"],
"utilization": round(f["current_utilization"] * 100, 1),
"status": f["status"],
} for f in factories]
# 3. 品牌毛利率
margin_chart = []
for b in BRANDS:
b_orders = [o for o in orders if o["brand_name"].lower() == b.lower()]
if not b_orders:
continue
total_rev = sum(o["total_amount"] for o in b_orders)
cost_factor = BRAND_COST_FACTOR.get(b.lower(), 0.35)
total_cost = sum(o["unit_price"] * cost_factor * o["order_qty"] for o in b_orders)
margin = round((total_rev - total_cost) / total_rev * 100, 1) if total_rev else 0
margin_chart.append({"brand": b, "margin": margin, "below_redline": margin < 15.0})
# 4. 訂單狀態
status_map = {"completed": "已完成", "partial": "部分出貨", "pending": "待出貨"}
status_count: dict[str, int] = {}
for o in orders:
s = o["status"]
status_count[s] = status_count.get(s, 0) + 1
status_chart = [
{"status": status_map.get(k, k), "count": v, "key": k}
for k, v in status_count.items()
]
# 5. KPI
total_rev = sum(o["total_amount"] for o in orders)
ship_rate = round(
sum(o["shipped_qty"] for o in orders) / sum(o["order_qty"] for o in orders) * 100, 1
)
return {
"kpi": {
"total_revenue": round(total_rev, 2),
"total_orders": len(orders),
"overall_ship_rate": ship_rate,
"low_margin_brands": [m["brand"] for m in margin_chart if m["below_redline"]],
"high_load_factories": [f["factory"] for f in factory_chart if f["utilization"] > 85],
},
"brand_revenue": brand_chart,
"factory_utilization": factory_chart,
"gross_margin": margin_chart,
"order_status": status_chart,
}
在 main.py 掛上這個 router:
from app.routers import orders, agent, debate, dashboard
app.include_router(dashboard.router)
Step 2:Next.js API Proxy
src/app/api/dashboard/route.ts(GET 轉發,不需要 body):
import { NextRequest } from 'next/server';
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8000';
export async function GET(_request: NextRequest) {
const upstream = await fetch(`${BACKEND_URL}/api/v1/dashboard/summary`, {
cache: 'no-store' // 儀表板每次都要最新資料,不能快取
});
if (!upstream.ok) {
return new Response(await upstream.text(), { status: upstream.status });
}
return Response.json(await upstream.json());
}
cache: 'no-store' 避免 Next.js 快取靜態回應,確保每次打開頁面都拿到最新訂單數據。
Step 3:Recharts 四圖表實作
品牌營收分布 — <Cell> 獨立配色
品牌營收是最基本的 BarChart,但重點在讓每個品牌有自己的顏色。預設 <Bar fill='...' /> 會讓所有柱子同色,必須用 <Cell> 覆蓋:
const BRAND_COLORS: Record<string, string> = {
'Nike': '#3b82f6', // blue
'Adidas': '#8b5cf6', // violet
'New Balance': '#06b6d4', // cyan
'Puma': '#f59e0b', // amber
'Asics': '#22c55e', // green
'Under Armour': '#ec4899', // pink
};
function BrandRevenueChart({ data }: { data: BrandRevenue[] }) {
return (
<ResponsiveContainer width='100%' height={220}>
<BarChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray='3 3' />
<XAxis dataKey='brand' tick={{ fontSize: 11 }} />
<YAxis tickFormatter={v => `$${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: number) => [`$${v.toLocaleString()}`, '營收']} />
<Bar dataKey='revenue' radius={[4, 4, 0, 0]}>
{data.map(entry => (
<Cell key={entry.brand} fill={BRAND_COLORS[entry.brand] ?? '#6366f1'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
關鍵:<Bar> 裡放 {data.map(entry => <Cell fill={...} />)},Recharts 會讓每根柱子使用對應 Cell 的 fill,覆蓋掉 <Bar> 層級的顏色設定。tickFormatter 把 211150 格式化為 $211k,讓 Y 軸標籤不擠在一起。

廠區稼動率 — 水平 BarChart
layout='vertical' 讓 Recharts 把 X 軸改為數值、Y 軸改為類別:
<BarChart data={data} layout='vertical'>
<XAxis type='number' domain={[0, 100]} tickFormatter={v => `${v}%`} />
<YAxis type='category' dataKey='factory' width={48} />
<ReferenceLine x={85} stroke='#ef4444' strokeDasharray='4 4' />
<Bar dataKey='utilization' radius={[0, 4, 4, 0]}>
{data.map(entry => (
<Cell key={entry.factory_code} fill={FACTORY_COLOR[entry.factory_code]} />
))}
</Bar>
</BarChart>
越南廠 91% 的橫條會超過虛線右側,一目了然。

品牌毛利率 — ReferenceLine + 條件顏色
這個圖表有兩個重點:15% 紅線與條件顏色。
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip, ReferenceLine, Cell, ResponsiveContainer
} from 'recharts';
function GrossMarginChart({ data }: { data: GrossMargin[] }) {
return (
<ResponsiveContainer width='100%' height={220}>
<BarChart data={data}>
<CartesianGrid strokeDasharray='3 3' />
<XAxis dataKey='brand' tick={{ fontSize: 11 }} />
<YAxis tickFormatter={v => `${v}%`} domain={[0, 80]} />
<Tooltip formatter={(v: number) => [`${v}%`, '毛利率']} />
{/* 15% 紅色警示線 */}
<ReferenceLine
y={15}
stroke='#ef4444'
strokeDasharray='4 4'
label={{ value: '15%', position: 'right', fill: '#ef4444', fontSize: 10 }}
/>
{/* 每根柱子獨立配色 */}
<Bar dataKey='margin' radius={[4, 4, 0, 0]}>
{data.map(entry => (
<Cell
key={entry.brand}
fill={entry.below_redline ? '#ef4444' : '#22c55e'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
<Cell> 讓每根柱子套用不同顏色 — 低於紅線的品牌顯示紅色,正常的顯示綠色。

訂單狀態 — PieChart
<PieChart>
<Pie
data={data}
dataKey='count'
nameKey='status'
cx='50%' cy='50%'
outerRadius={75}
label={({ status, count }) => `${status} (${count})`}
>
{data.map(entry => (
<Cell key={entry.key} fill={STATUS_COLORS[entry.status] ?? '#94a3b8'} />
))}
</Pie>
<Legend iconSize={10} />
<Tooltip formatter={(v: number) => [v, '張訂單']} />
</PieChart>
顏色對應:已完成 → 綠、部分出貨 → 橘、待出貨 → 灰。

Step 4:KPI 警示卡
儀表板右上角的警示卡會動態列出問題:
function KpiCards({ kpi }: { kpi: KPI }) {
return (
<Card className={kpi.low_margin_brands.length > 0 ? 'border-red-300' : ''}>
<CardTitle>警示 {kpi.low_margin_brands.length + kpi.high_load_factories.length} 項</CardTitle>
<CardContent>
{kpi.low_margin_brands.map(b => (
<p key={b} className='text-xs text-red-500'>🚨 {b} 毛利偏低</p>
))}
{kpi.high_load_factories.map(f => (
<p key={f} className='text-xs text-amber-500'>⚠️ {f} 產能過高</p>
))}
</CardContent>
</Card>
);
}
本次數據中,Puma 毛利率 12%(低於 15%)、越南廠稼動率 91%(高於 85%),警示卡顯示 2 項異常,並且卡片邊框變紅。
Step 5:useEffect 資料載入
整個 ErpDashboard 元件以 useEffect 在客戶端載入資料:
export default function ErpDashboard() {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetch('/api/dashboard')
.then(r => {
if (!r.ok) throw new Error(`Backend ${r.status}`);
return r.json();
})
.then(setData)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <LoadingState />;
if (error || !data) return <ErrorState message={error} />;
return (
<div className='space-y-4'>
<KpiCards kpi={data.kpi} />
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<BrandRevenueChart data={data.brand_revenue} />
<FactoryUtilizationChart data={data.factory_utilization} />
<GrossMarginChart data={data.gross_margin} />
<OrderStatusChart data={data.order_status} />
</div>
</div>
);
}
'use client' 宣告讓元件在瀏覽器端執行,Recharts 需要 DOM 才能渲染,不能用 Server Component。
Step 6:Sidebar 導覽
在 src/config/nav-config.ts 加入:
{
title: 'ERP 儀表板',
url: '/dashboard/erp',
icon: 'dashboard',
shortcut: ['e', 'r'],
isActive: false,
items: []
},
測試
確認兩個服務都在跑:
# Terminal 1
cd backend && uvicorn app.main:app --reload
# Terminal 2
cd frontend/next-shadcn-dashboard-starter-main && npx next dev
開啟 http://localhost:3000/dashboard/erp,應該看到:
- 總營收 $1,167,350
- 出貨率 64.6%(橘色,低於 80% 門檻)
- 警示 2 項:Puma 毛利偏低 🚨 + 越南廠產能過高 ⚠️
- 越南廠稼動率橫條超過 85% 紅線
- Puma 毛利率柱子顯示紅色
今日 Checklist
-
backend/app/routers/dashboard.py:Summary API,五組資料一次回傳 -
backend/app/main.py:掛載 dashboard router -
src/app/api/dashboard/route.ts:GET Proxy -
src/features/erp/components/erp-dashboard.tsx:KPI 卡 + 四圖表 -
src/app/dashboard/erp/page.tsx:頁面 wrapper -
src/config/nav-config.ts:新增導覽項
小結
今天有幾個值得記住的工程細節:
<Cell>條件顏色:Recharts 的<Bar>預設所有柱子同色。要讓每根柱子有獨立顏色,必須在<Bar>裡放{data.map(entry => <Cell fill={...} />)},用Cell覆蓋單根柱子的樣式。水平 BarChart:設定
layout='vertical'後,XAxis的type要改成'number'、YAxis改成'category',否則軸標籤對調會渲染錯誤。ReferenceLine:垂直基準線用x={值},水平基準線用y={值}。在水平 BarChart 裡要用x=不是y=,容易搞混。cache: 'no-store':Next.js App Router 的fetch()預設會做靜態快取。儀表板資料要加cache: 'no-store'強制每次重新請求,否則資料更新後頁面仍顯示舊值。
明天 Day 28:Human-in-the-Loop(HITL)— 當 AI 要做重大決策(如接受大額急單),系統暫停並通知人工確認,由管理者按下「批准 / 拒絕」才繼續執行。
