【實戰】Day 27:ERP 訂單儀表板 — Recharts 數據可視化

【實戰】Day 27:ERP 訂單儀表板 — Recharts 數據可視化

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


Day 26 把 AI 問答接上前端;Day 27 把訂單數據搬上螢幕。比起空泛的 "Hello World" 圖表,這次的每一條柱狀圖都對應真實的工廠與品牌,讓管理者一眼看到問題在哪。

今日目標

任務說明
Backend Summary APIGET /api/v1/dashboard/summary — 一次回傳四組圖表資料
Next.js ProxyGET /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 即可。

Dashboard

四圖表設計

圖表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 會讓每根柱子使用對應 Cellfill,覆蓋掉 <Bar> 層級的顏色設定。tickFormatter211150 格式化為 $211k,讓 Y 軸標籤不擠在一起。

Dashboard營收

廠區稼動率 — 水平 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% 的橫條會超過虛線右側,一目了然。

Dashboard稼動率

品牌毛利率 — 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> 讓每根柱子套用不同顏色 — 低於紅線的品牌顯示紅色,正常的顯示綠色。

Dashboard毛利

訂單狀態 — 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>

顏色對應:已完成 → 綠、部分出貨 → 橘、待出貨 → 灰。

Dashboard訂單狀態

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 項異常,並且卡片邊框變紅。

Dashboard警示卡

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:新增導覽項

小結

今天有幾個值得記住的工程細節:

  1. <Cell> 條件顏色:Recharts 的 <Bar> 預設所有柱子同色。要讓每根柱子有獨立顏色,必須在 <Bar> 裡放 {data.map(entry => <Cell fill={...} />)},用 Cell 覆蓋單根柱子的樣式。

  2. 水平 BarChart:設定 layout='vertical' 後,XAxistype 要改成 'number'YAxis 改成 'category',否則軸標籤對調會渲染錯誤。

  3. ReferenceLine:垂直基準線用 x={值},水平基準線用 y={值}。在水平 BarChart 裡要用 x= 不是 y=,容易搞混。

  4. cache: 'no-store':Next.js App Router 的 fetch() 預設會做靜態快取。儀表板資料要加 cache: 'no-store' 強制每次重新請求,否則資料更新後頁面仍顯示舊值。

明天 Day 28:Human-in-the-Loop(HITL)— 當 AI 要做重大決策(如接受大額急單),系統暫停並通知人工確認,由管理者按下「批准 / 拒絕」才繼續執行。