feat(supply-chain): add product archive dashboard

This commit is contained in:
WuYongJie 2026-08-05 12:43:13 +08:00
parent 5b537b8f9c
commit f6460b1eda
13 changed files with 821 additions and 48 deletions

View File

@ -0,0 +1,28 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { PRODUCT_ARCHIVE_SALES_STATUSES } from '@/lib/supply-chain-product-archive'
import { supplyChainProductArchiveDashboardService } from '@/server/services/supply-chain-product-archive-dashboard.service'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
const querySchema = z.object({
keyword: z.string().trim().max(160).optional(),
categoryL1: z.string().trim().max(80).optional(),
categoryL2: z.string().trim().max(160).optional(),
productStatus: z.enum(PRODUCT_ARCHIVE_SALES_STATUSES).optional(),
supplier: z.string().trim().max(120).optional(),
updateTimeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
})
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries()))
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '看板筛选条件不正确', 400, ctx.requestId)
const data = await supplyChainProductArchiveDashboardService.getDashboard(parsed.data)
return jsonSuccess(data, 200, ctx.requestId)
},
{ requireAuth: true, allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] },
)
}

View File

@ -0,0 +1,53 @@
import { ArrowUpRight, Boxes, BriefcaseBusiness, Database, Package, ShoppingCart, type LucideIcon } from 'lucide-react'
import type { DashboardDomain } from './dashboardConfig'
const ICONS: Record<DashboardDomain['key'], LucideIcon> = {
product: Package,
supply: Boxes,
operation: BriefcaseBusiness,
warehouse: Database,
data: ShoppingCart,
}
const HIGHLIGHTS: Record<DashboardDomain['key'], string[]> = {
product: ['商品状态', '一级类目', '二级类目', '品牌'],
supply: ['供应商', '采购', '结算方式', '产品起订量', '首单生产周期', '稳定生产周期'],
operation: ['运营总负责人', '运营部门负责人', '运营负责人', '运营负责人限价', '铺货ID数量和明细'],
warehouse: ['仓库属性', '出入库仓库', '仓库负责人'],
data: ['库存', '销售与发货', '价格与利润', '售后与退货'],
}
export function DashboardDomainPanel({ domain, large = false }: { domain: DashboardDomain; large?: boolean }) {
const Icon = ICONS[domain.key]
const highlights = HIGHLIGHTS[domain.key]
return (
<section className={`sc-dashboard-domain-panel ${large ? 'sc-dashboard-domain-panel-large' : ''}`} style={{ '--domain-color': domain.color } as React.CSSProperties}>
<div className="sc-dashboard-domain-heading">
<div className="sc-dashboard-domain-heading-main">
<span className="sc-dashboard-domain-icon"><Icon size={16} strokeWidth={1.9} /></span>
<div>
<h2>{domain.label}</h2>
<p>{domain.fieldCount} 个字段</p>
</div>
</div>
<ArrowUpRight size={15} strokeWidth={1.8} className="sc-dashboard-domain-link" />
</div>
<div className="sc-dashboard-domain-body">
<div className="sc-dashboard-domain-summary">
<span>当前数据</span>
<strong>--</strong>
<em>待接入</em>
</div>
<div className="sc-dashboard-domain-grid">
{highlights.map((label) => (
<div className="sc-dashboard-domain-item" key={label}>
<span>{label}</span>
<strong>--</strong>
</div>
))}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,73 @@
'use client'
import { Filter, RotateCcw, Search } from 'lucide-react'
import { useState } from 'react'
export type DashboardFilterValues = {
keyword: string
categoryL1: string
categoryL2: string
status: string
supplier: string
dateRange: string
}
const EMPTY_FILTERS: DashboardFilterValues = {
keyword: '',
categoryL1: '',
categoryL2: '',
status: '',
supplier: '',
dateRange: 'all',
}
export function DashboardFilterBar({ onApply }: { onApply: (filters: DashboardFilterValues) => void }) {
const [draft, setDraft] = useState(EMPTY_FILTERS)
function update(key: keyof DashboardFilterValues, value: string) {
setDraft((current) => ({ ...current, [key]: value }))
}
return (
<form
className="sc-dashboard-filter-bar"
onSubmit={(event) => {
event.preventDefault()
onApply(draft)
}}
>
<div className="sc-dashboard-filter-title">
<Filter size={15} strokeWidth={1.8} />
<span>档案筛选</span>
</div>
<label className="sc-dashboard-filter-search">
<Search size={14} strokeWidth={1.8} />
<input
value={draft.keyword}
onChange={(event) => update('keyword', event.target.value)}
placeholder="条码 / SPU / 商品名称"
aria-label="搜索条码、SPU或商品名称"
/>
</label>
<input className="sc-dashboard-filter-input" value={draft.categoryL1} onChange={(event) => update('categoryL1', event.target.value)} placeholder="一级类目" aria-label="一级类目" />
<input className="sc-dashboard-filter-input" value={draft.categoryL2} onChange={(event) => update('categoryL2', event.target.value)} placeholder="二级类目" aria-label="二级类目" />
<select className="sc-dashboard-filter-select" value={draft.status} onChange={(event) => update('status', event.target.value)} aria-label="商品状态">
<option value="">全部状态</option>
<option value="销售">销售</option>
<option value="停产">停产</option>
<option value="退市">退市</option>
</select>
<input className="sc-dashboard-filter-input" value={draft.supplier} onChange={(event) => update('supplier', event.target.value)} placeholder="供应商" aria-label="供应商" />
<select className="sc-dashboard-filter-select" value={draft.dateRange} onChange={(event) => update('dateRange', event.target.value)} aria-label="更新时间范围">
<option value="all">全部时间</option>
<option value="7">近 7 天</option>
<option value="30">近 30 天</option>
<option value="90">近 90 天</option>
</select>
<button type="submit" className="sc-dashboard-filter-apply">查询</button>
<button type="button" className="sc-dashboard-filter-reset" onClick={() => { setDraft(EMPTY_FILTERS); onApply(EMPTY_FILTERS) }} title="重置筛选">
<RotateCcw size={14} strokeWidth={1.8} />
<span>重置</span>
</button>
</form>
)
}

View File

@ -0,0 +1,49 @@
import { Barcode, Package, Tags, Users, type LucideIcon } from 'lucide-react'
import type { ProductArchiveDashboardData } from '@/lib/supply-chain-product-archive-dashboard'
import { DASHBOARD_KPIS } from './dashboardConfig'
type Kpi = (typeof DASHBOARD_KPIS)[number]
const ICONS: Record<Kpi['icon'], LucideIcon> = {
barcode: Barcode,
product: Package,
status: Tags,
supplier: Users,
}
export function DashboardKpiStrip({ data, loading }: { data: ProductArchiveDashboardData | null; loading: boolean }) {
const values: Record<Kpi['key'], string> = {
barcode: data ? String(data.overview.barcodeCount) : '--',
spu: data ? String(data.overview.spuCount) : '--',
sales: data ? String(data.overview.salesCount) : '--',
stopped: data ? String(data.overview.stoppedCount) : '--',
retired: data ? String(data.overview.retiredCount) : '--',
supplier: data ? String(data.overview.supplierCount) : '--',
}
const notes: Record<Kpi['key'], string> = {
barcode: '条码维度',
spu: 'SPU 去重',
sales: '销售状态',
stopped: '停产状态',
retired: '退市状态',
supplier: data ? `${data.overview.purchaserCount} 位采购负责人` : '采购负责人',
}
return (
<section className="sc-dashboard-kpi-strip" aria-label="档案核心指标">
{DASHBOARD_KPIS.map((kpi) => {
const Icon = ICONS[kpi.icon]
return (
<article key={kpi.key} className="sc-dashboard-kpi-card" style={{ '--kpi-color': kpi.color } as React.CSSProperties}>
<div className="sc-dashboard-kpi-topline" />
<div className="sc-dashboard-kpi-icon"><Icon size={17} strokeWidth={1.9} /></div>
<div className="sc-dashboard-kpi-label">{kpi.label}</div>
<strong className="sc-dashboard-kpi-value">{loading ? '...' : values[kpi.key]}</strong>
<div className="sc-dashboard-kpi-meta">{notes[kpi.key]}</div>
</article>
)
})}
</section>
)
}

View File

@ -0,0 +1,83 @@
import { ArrowUpRight, BarChart3, Layers3, Package, Tags, type LucideIcon } from 'lucide-react'
import type { ProductArchiveDashboardBreakdown, ProductArchiveDashboardData } from '@/lib/supply-chain-product-archive-dashboard'
const STATUS_COLORS: Record<string, string> = { 销售: '#1a9b72', 停产: '#c88924', 退市: '#c65779', 状态未录入: '#9aa3af' }
function maxCount(items: ProductArchiveDashboardBreakdown[]) {
return Math.max(1, ...items.map((item) => item.count))
}
function BreakdownList({ items, color, compact = false }: { items: ProductArchiveDashboardBreakdown[]; color: string; compact?: boolean }) {
const max = maxCount(items)
if (items.length === 0) return <div className="sc-dashboard-inline-empty">暂无数据</div>
return (
<div className={`sc-dashboard-breakdown-list ${compact ? 'sc-dashboard-breakdown-compact' : ''}`}>
{items.map((item) => (
<div className="sc-dashboard-breakdown-row" key={item.label}>
<div className="sc-dashboard-breakdown-label" title={item.label}>{item.label}</div>
<div className="sc-dashboard-breakdown-track"><span style={{ width: `${Math.round((item.count / max) * 100)}%`, backgroundColor: color }} /></div>
<strong>{item.count}</strong>
</div>
))}
</div>
)
}
export function DashboardProductInfoPanel({ data }: { data: ProductArchiveDashboardData | null }) {
const statuses = data?.productInfo.statuses ?? []
const categoriesL1 = data?.productInfo.categoriesL1 ?? []
const categoriesL2 = data?.productInfo.categoriesL2 ?? []
const brands = data?.productInfo.brands ?? []
const recent = data?.productInfo.recent ?? []
return (
<section className="sc-dashboard-product-board">
<div className="sc-dashboard-board-heading">
<div className="sc-dashboard-board-title">
<span className="sc-dashboard-board-icon product"><Package size={17} strokeWidth={1.9} /></span>
<div><h2>商品信息</h2><p>来自产品全量档案的真实商品数据</p></div>
</div>
<div className="sc-dashboard-board-heading-right"><span className="sc-dashboard-live-badge">实时汇总</span><ArrowUpRight size={15} strokeWidth={1.8} /></div>
</div>
<div className="sc-dashboard-product-top-grid">
<div className="sc-dashboard-product-chart">
<div className="sc-dashboard-subheading"><span><BarChart3 size={14} strokeWidth={1.8} />商品状态</span><small>条码数</small></div>
<div className="sc-dashboard-status-list">
{statuses.map((item) => (
<div className="sc-dashboard-status-row" key={item.label}>
<span className="sc-dashboard-status-dot" style={{ backgroundColor: STATUS_COLORS[item.label] ?? '#9aa3af' }} />
<span className="sc-dashboard-status-label">{item.label}</span>
<strong>{item.count}</strong>
</div>
))}
{statuses.length === 0 && <div className="sc-dashboard-inline-empty">暂无数据</div>}
</div>
</div>
<div className="sc-dashboard-product-chart">
<div className="sc-dashboard-subheading"><span><Layers3 size={14} strokeWidth={1.8} />一级类目</span><small>Top 10</small></div>
<BreakdownList items={categoriesL1} color="#2f6fed" compact />
</div>
<div className="sc-dashboard-product-chart">
<div className="sc-dashboard-subheading"><span><Tags size={14} strokeWidth={1.8} />品牌</span><small>Top 10</small></div>
<BreakdownList items={brands} color="#7558c9" compact />
</div>
</div>
<div className="sc-dashboard-product-bottom-grid">
<div className="sc-dashboard-category-table">
<div className="sc-dashboard-subheading"><span>二级类目排行</span><small>条码数</small></div>
{categoriesL2.length === 0 ? <div className="sc-dashboard-inline-empty">暂无数据</div> : categoriesL2.map((item, index) => (
<div className="sc-dashboard-category-row" key={item.label}><span className="sc-dashboard-rank">{index + 1}</span><span title={item.label}>{item.label}</span><strong>{item.count}</strong></div>
))}
</div>
<div className="sc-dashboard-recent-table">
<div className="sc-dashboard-subheading"><span>最近更新商品</span><small>按更新时间</small></div>
<div className="sc-dashboard-recent-head"><span>条码</span><span>商品名称</span><span>状态</span><span>更新时间</span></div>
{recent.length === 0 ? <div className="sc-dashboard-inline-empty">暂无数据</div> : recent.slice(0, 6).map((row) => (
<div className="sc-dashboard-recent-row" key={row.barcode}><span title={row.barcode}>{row.barcode}</span><span title={row.productName}>{row.productName || '--'}</span><span className="sc-dashboard-row-status">{row.productStatus || '未录入'}</span><span>{new Date(row.updatedAt).toLocaleDateString('zh-CN')}</span></div>
))}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,135 @@
export function DashboardStyles() {
return (
<style jsx global>{`
.sc-dashboard-page {
--wo-bg: #f5f6f8;
--wo-surface: #ffffff;
--wo-ink: #20242b;
--wo-muted: #6e7785;
--wo-subtle: #9aa3af;
--wo-line: #dfe3e8;
width: 100%;
min-height: 100%;
box-sizing: border-box;
overflow: auto;
padding: 14px 16px 26px;
background: var(--wo-bg);
color: var(--wo-ink);
}
.sc-dashboard-header,
.sc-dashboard-filter-bar,
.sc-dashboard-kpi-strip,
.sc-dashboard-product-board,
.sc-dashboard-supply-board,
.sc-dashboard-domain-grid-wrap,
.sc-dashboard-data-panel { width: 100%; box-sizing: border-box; margin: 0 0 10px; }
.sc-dashboard-header { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.sc-dashboard-header-title { min-width: 0; }
.sc-dashboard-kicker { display: inline-flex; align-items: center; gap: 6px; color: var(--wo-muted); font-size: 12px; }
.sc-dashboard-header h1 { margin: 6px 0 0; font-size: 20px; line-height: 1.2; font-weight: 700; letter-spacing: 0; }
.sc-dashboard-header p { margin: 4px 0 0; color: var(--wo-muted); font-size: 12px; }
.sc-dashboard-header-actions { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; justify-content: flex-end; }
.sc-dashboard-header-date, .sc-dashboard-refresh, .sc-dashboard-view-archive { display: inline-flex; align-items: center; gap: 6px; height: 29px; padding: 0 9px; border: 1px solid var(--wo-line); border-radius: 4px; background: var(--wo-surface); color: var(--wo-muted); font-size: 11px; }
.sc-dashboard-refresh, .sc-dashboard-view-archive { cursor: pointer; text-decoration: none; }
.sc-dashboard-refresh:hover, .sc-dashboard-view-archive:hover { border-color: #aeb6c1; color: var(--wo-ink); }
.sc-dashboard-filter-bar { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; padding: 8px 9px; border: 1px solid var(--wo-line); border-radius: 4px; background: var(--wo-surface); }
.sc-dashboard-filter-title { display: inline-flex; align-items: center; gap: 5px; min-height: 29px; padding: 0 5px 0 2px; color: var(--wo-ink); font-size: 12px; font-weight: 650; }
.sc-dashboard-filter-search, .sc-dashboard-filter-input, .sc-dashboard-filter-select { height: 29px; border: 1px solid var(--wo-line); border-radius: 3px; background: #fff; color: var(--wo-ink); font-size: 11px; }
.sc-dashboard-filter-search { display: inline-flex; align-items: center; gap: 5px; width: 220px; padding: 0 8px; color: var(--wo-subtle); }
.sc-dashboard-filter-search input { width: 100%; min-width: 0; border: 0; outline: 0; color: var(--wo-ink); font-size: 11px; }
.sc-dashboard-filter-input { width: 112px; padding: 0 7px; outline: 0; }
.sc-dashboard-filter-select { min-width: 100px; padding: 0 6px; outline: 0; }
.sc-dashboard-filter-search:focus-within, .sc-dashboard-filter-input:focus, .sc-dashboard-filter-select:focus { border-color: #98a2b3; box-shadow: 0 0 0 2px rgba(47, 111, 237, .08); }
.sc-dashboard-filter-apply, .sc-dashboard-filter-reset { display: inline-flex; align-items: center; justify-content: center; gap: 5px; height: 29px; padding: 0 10px; border-radius: 3px; font-size: 11px; cursor: pointer; }
.sc-dashboard-filter-apply { border: 1px solid #2f6fed; background: #2f6fed; color: #fff; }
.sc-dashboard-filter-reset { border: 1px solid var(--wo-line); background: #fff; color: var(--wo-muted); }
.sc-dashboard-filter-apply:hover { background: #255ecb; }
.sc-dashboard-filter-reset:hover { border-color: #aeb6c1; color: var(--wo-ink); }
.sc-dashboard-kpi-strip { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 7px; }
.sc-dashboard-kpi-card { position: relative; min-height: 82px; overflow: hidden; padding: 12px 13px 10px; border: 1px solid var(--wo-line); border-radius: 4px; background: var(--wo-surface); }
.sc-dashboard-kpi-topline { height: 3px; position: absolute; inset: 0 0 auto; background: var(--kpi-color); }
.sc-dashboard-kpi-icon { position: absolute; top: 15px; right: 13px; color: var(--kpi-color); opacity: .8; }
.sc-dashboard-kpi-label { color: var(--wo-muted); font-size: 11px; }
.sc-dashboard-kpi-value { display: block; margin-top: 11px; font-size: 23px; line-height: 1; font-weight: 700; letter-spacing: 0; }
.sc-dashboard-kpi-meta { margin-top: 7px; color: var(--wo-subtle); font-size: 10px; }
.sc-dashboard-product-board, .sc-dashboard-supply-board, .sc-dashboard-data-panel { border: 1px solid var(--wo-line); border-radius: 4px; background: var(--wo-surface); }
.sc-dashboard-board-heading, .sc-dashboard-data-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 13px; border-bottom: 1px solid #edf0f3; }
.sc-dashboard-board-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
.sc-dashboard-board-title h2 { margin: 0; font-size: 14px; font-weight: 700; }
.sc-dashboard-board-title p { margin: 3px 0 0; color: var(--wo-subtle); font-size: 10px; }
.sc-dashboard-board-icon { display: inline-flex; align-items: center; justify-content: center; width: 29px; height: 29px; border-radius: 4px; }
.sc-dashboard-board-icon.product { color: #2f6fed; background: #edf3ff; }
.sc-dashboard-board-icon.supply { color: #0f9f7f; background: #e9f8f2; }
.sc-dashboard-board-heading-right { display: inline-flex; align-items: center; gap: 8px; color: var(--wo-subtle); font-size: 10px; }
.sc-dashboard-live-badge, .sc-dashboard-panel-state { padding: 3px 6px; border: 1px solid #dfe3e8; border-radius: 3px; color: var(--wo-muted); font-size: 10px; }
.sc-dashboard-product-top-grid { display: grid; grid-template-columns: 1fr 1.25fr 1fr; min-height: 180px; border-bottom: 1px solid #edf0f3; }
.sc-dashboard-product-chart { min-width: 0; padding: 11px 14px; border-right: 1px solid #edf0f3; }
.sc-dashboard-product-chart:last-child { border-right: 0; }
.sc-dashboard-subheading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; color: var(--wo-ink); font-size: 12px; font-weight: 650; }
.sc-dashboard-subheading > span { display: inline-flex; align-items: center; gap: 5px; }
.sc-dashboard-subheading small { color: var(--wo-subtle); font-size: 10px; font-weight: 400; }
.sc-dashboard-status-list, .sc-dashboard-breakdown-list { display: grid; gap: 9px; }
.sc-dashboard-status-row { display: grid; grid-template-columns: 8px minmax(0, 1fr) 42px; align-items: center; gap: 7px; font-size: 11px; }
.sc-dashboard-status-dot { width: 8px; height: 8px; border-radius: 50%; }
.sc-dashboard-status-label { color: var(--wo-muted); }
.sc-dashboard-status-row strong { text-align: right; font-size: 13px; }
.sc-dashboard-breakdown-row { display: grid; grid-template-columns: minmax(74px, .8fr) minmax(54px, 1.2fr) 38px; align-items: center; gap: 7px; font-size: 10px; }
.sc-dashboard-breakdown-label { overflow: hidden; color: var(--wo-muted); text-overflow: ellipsis; white-space: nowrap; }
.sc-dashboard-breakdown-track, .sc-dashboard-ranking-track { height: 5px; overflow: hidden; border-radius: 999px; background: #eef0f3; }
.sc-dashboard-breakdown-track span, .sc-dashboard-ranking-track span { display: block; height: 100%; border-radius: inherit; }
.sc-dashboard-breakdown-row strong { text-align: right; font-size: 11px; }
.sc-dashboard-product-bottom-grid { display: grid; grid-template-columns: 1fr 1.55fr; gap: 0; min-height: 220px; }
.sc-dashboard-category-table, .sc-dashboard-recent-table { min-width: 0; padding: 11px 14px 13px; }
.sc-dashboard-category-table { border-right: 1px solid #edf0f3; }
.sc-dashboard-category-row { display: grid; grid-template-columns: 24px minmax(0, 1fr) 42px; align-items: center; gap: 7px; min-height: 26px; border-bottom: 1px solid #f3f4f6; color: var(--wo-muted); font-size: 10px; }
.sc-dashboard-category-row span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sc-dashboard-category-row strong { color: var(--wo-ink); text-align: right; font-size: 11px; }
.sc-dashboard-rank { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 3px; background: #f2f4f7; color: var(--wo-muted); font-size: 10px; }
.sc-dashboard-recent-head, .sc-dashboard-recent-row { display: grid; grid-template-columns: 1.1fr 1.65fr .55fr .7fr; gap: 8px; align-items: center; }
.sc-dashboard-recent-head { padding-bottom: 6px; color: var(--wo-subtle); font-size: 10px; }
.sc-dashboard-recent-row { min-height: 26px; border-top: 1px solid #f3f4f6; color: var(--wo-muted); font-size: 10px; }
.sc-dashboard-recent-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sc-dashboard-row-status { color: #1a9b72; }
.sc-dashboard-supply-content { display: grid; grid-template-columns: 1fr 1fr 1.1fr; min-height: 165px; }
.sc-dashboard-ranking-block { min-width: 0; padding: 11px 14px 13px; border-right: 1px solid #edf0f3; }
.sc-dashboard-ranking-row { display: grid; grid-template-columns: 20px minmax(70px, .8fr) minmax(40px, 1.2fr) 34px; align-items: center; gap: 7px; min-height: 25px; color: var(--wo-muted); font-size: 10px; }
.sc-dashboard-ranking-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sc-dashboard-ranking-row strong { color: var(--wo-ink); text-align: right; font-size: 11px; }
.sc-dashboard-supply-empty { display: flex; align-items: center; justify-content: center; gap: 7px; padding: 14px; color: var(--wo-subtle); font-size: 11px; text-align: center; }
.sc-dashboard-domain-grid-wrap { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.sc-dashboard-domain-panel { min-width: 0; border: 1px solid var(--wo-line); border-top: 3px solid var(--domain-color); border-radius: 4px; background: var(--wo-surface); }
.sc-dashboard-domain-heading { display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; border-bottom: 1px solid #edf0f3; }
.sc-dashboard-domain-heading-main { display: flex; align-items: center; gap: 8px; }
.sc-dashboard-domain-icon { display: inline-flex; align-items: center; justify-content: center; width: 27px; height: 27px; border-radius: 4px; color: var(--domain-color); background: color-mix(in srgb, var(--domain-color) 10%, white); }
.sc-dashboard-domain-heading h2 { margin: 0; font-size: 13px; font-weight: 650; }
.sc-dashboard-domain-heading p { margin: 3px 0 0; color: var(--wo-subtle); font-size: 10px; }
.sc-dashboard-domain-link { color: var(--wo-subtle); }
.sc-dashboard-domain-body { padding: 10px 12px 12px; }
.sc-dashboard-domain-summary { display: flex; align-items: baseline; gap: 8px; padding-bottom: 8px; border-bottom: 1px solid #f0f2f4; }
.sc-dashboard-domain-summary span, .sc-dashboard-domain-summary em { color: var(--wo-subtle); font-size: 10px; font-style: normal; }
.sc-dashboard-domain-summary strong { color: var(--wo-subtle); font-size: 19px; }
.sc-dashboard-domain-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 12px; padding-top: 9px; }
.sc-dashboard-domain-item { display: flex; align-items: center; justify-content: space-between; gap: 5px; color: var(--wo-muted); font-size: 10px; }
.sc-dashboard-domain-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sc-dashboard-domain-item strong { color: var(--wo-subtle); font-weight: 600; }
.sc-dashboard-data-panel { border: 1px solid var(--wo-line); border-radius: 4px; background: var(--wo-surface); }
.sc-dashboard-data-heading { display: flex; align-items: center; justify-content: space-between; padding: 10px 13px; border-bottom: 1px solid #edf0f3; }
.sc-dashboard-data-heading h2 { margin: 0; font-size: 14px; font-weight: 650; }
.sc-dashboard-data-heading p { margin: 3px 0 0; color: var(--wo-subtle); font-size: 10px; }
.sc-dashboard-data-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.sc-dashboard-data-segment { min-height: 112px; padding: 11px 13px; border-right: 1px solid #edf0f3; }
.sc-dashboard-data-segment:last-child { border-right: 0; }
.sc-dashboard-data-segment h3 { margin: 0 0 9px; font-size: 12px; font-weight: 650; }
.sc-dashboard-data-items { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 12px; }
.sc-dashboard-data-item { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; color: var(--wo-muted); font-size: 10px; }
.sc-dashboard-data-item strong { color: var(--wo-subtle); font-size: 11px; }
.sc-dashboard-structure-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 13px; border-top: 1px solid #edf0f3; color: var(--wo-muted); font-size: 10px; }
.sc-dashboard-structure-note strong { color: var(--wo-ink); }
.sc-dashboard-error { margin-bottom: 10px; padding: 8px 10px; border: 1px solid #f0c6cf; border-radius: 4px; background: #fff5f6; color: #a53a4e; font-size: 11px; }
.sc-dashboard-inline-empty { display: flex; align-items: center; justify-content: center; min-height: 92px; color: var(--wo-subtle); font-size: 11px; }
@media (max-width: 1180px) { .sc-dashboard-kpi-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); } .sc-dashboard-product-top-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 820px) { .sc-dashboard-page { padding: 12px 12px 24px; } .sc-dashboard-header { align-items: flex-start; flex-direction: column; gap: 8px; } .sc-dashboard-header-actions { justify-content: flex-start; } .sc-dashboard-filter-search { width: 100%; } .sc-dashboard-product-top-grid, .sc-dashboard-product-bottom-grid, .sc-dashboard-supply-content, .sc-dashboard-domain-grid-wrap { grid-template-columns: 1fr; } .sc-dashboard-product-chart, .sc-dashboard-ranking-block { border-right: 0; border-bottom: 1px solid #edf0f3; } .sc-dashboard-product-chart:last-child, .sc-dashboard-ranking-block:last-child { border-bottom: 0; } .sc-dashboard-product-bottom-grid { gap: 0; } .sc-dashboard-category-table { border-right: 0; border-bottom: 1px solid #edf0f3; } .sc-dashboard-data-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .sc-dashboard-data-segment:nth-child(2) { border-right: 0; } .sc-dashboard-data-segment:nth-child(-n+2) { border-bottom: 1px solid #edf0f3; } }
@media (max-width: 560px) { .sc-dashboard-kpi-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; } .sc-dashboard-data-grid { grid-template-columns: 1fr; } .sc-dashboard-data-segment, .sc-dashboard-data-segment:nth-child(2) { border-right: 0; border-bottom: 1px solid #edf0f3; } .sc-dashboard-data-segment:last-child { border-bottom: 0; } .sc-dashboard-filter-input, .sc-dashboard-filter-select { flex: 1 1 130px; } .sc-dashboard-recent-head, .sc-dashboard-recent-row { grid-template-columns: 1.2fr 1.4fr .6fr; } .sc-dashboard-recent-head span:last-child, .sc-dashboard-recent-row span:last-child { display: none; } }
`}</style>
)
}

View File

@ -0,0 +1,32 @@
import { ArrowUpRight, Boxes } from 'lucide-react'
import type { ProductArchiveDashboardBreakdown, ProductArchiveDashboardData } from '@/lib/supply-chain-product-archive-dashboard'
function Ranking({ items, title }: { items: ProductArchiveDashboardBreakdown[]; title: string }) {
const max = Math.max(1, ...items.map((item) => item.count))
return (
<div className="sc-dashboard-ranking-block">
<div className="sc-dashboard-subheading"><span>{title}</span><small>条码数</small></div>
{items.length === 0 ? <div className="sc-dashboard-inline-empty">暂无数据</div> : items.slice(0, 6).map((item, index) => (
<div className="sc-dashboard-ranking-row" key={item.label}>
<span className="sc-dashboard-rank">{index + 1}</span><span className="sc-dashboard-ranking-name" title={item.label}>{item.label}</span><div className="sc-dashboard-ranking-track"><span style={{ width: `${Math.round((item.count / max) * 100)}%` }} /></div><strong>{item.count}</strong>
</div>
))}
</div>
)
}
export function DashboardSupplyChainPanel({ data }: { data: ProductArchiveDashboardData | null }) {
return (
<section className="sc-dashboard-supply-board">
<div className="sc-dashboard-board-heading">
<div className="sc-dashboard-board-title"><span className="sc-dashboard-board-icon supply"><Boxes size={17} strokeWidth={1.9} /></span><div><h2>供应链信息</h2><p>供应商与采购负责人真实分布</p></div></div>
<div className="sc-dashboard-board-heading-right"><span>{data?.overview.supplierCount ?? '--'} 家供应商</span><ArrowUpRight size={15} strokeWidth={1.8} /></div>
</div>
<div className="sc-dashboard-supply-content">
<Ranking items={data?.supplyChain.suppliers ?? []} title="供应商排行" />
<Ranking items={data?.supplyChain.purchasers ?? []} title="采购负责人排行" />
<Ranking items={data?.supplyChain.minimumOrderQuantities ?? []} title="产品起订量分布" />
</div>
</section>
)
}

View File

@ -1,6 +1,16 @@
'use client'
import { Calendar } from 'lucide-react'
import { CalendarDays, ExternalLink, LayoutDashboard, RefreshCw } from 'lucide-react'
import { useMemo, useState } from 'react'
import { DashboardDomainPanel } from './DashboardDomainPanel'
import { DashboardFilterBar, type DashboardFilterValues } from './DashboardFilterBar'
import { DashboardKpiStrip } from './DashboardKpiStrip'
import { DashboardProductInfoPanel } from './DashboardProductInfoPanel'
import { DashboardStyles } from './DashboardStyles'
import { DashboardSupplyChainPanel } from './DashboardSupplyChainPanel'
import { useProductArchiveDashboardData } from './useProductArchiveDashboardData'
import { DASHBOARD_DATA_SECTIONS, DASHBOARD_DOMAINS, TOTAL_ARCHIVE_FIELD_COUNT } from './dashboardConfig'
import type { ProductArchiveDashboardFilters } from '@/lib/supply-chain-product-archive-dashboard'
function getGreeting(): string {
const hour = new Date().getHours()
@ -11,59 +21,79 @@ function getGreeting(): string {
if (hour < 18) return '下午好'
return '晚上好'
}
function getToday(): string {
const d = new Date()
const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()]
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 星期${week}`
const date = new Date()
const week = ['日', '一', '二', '三', '四', '五', '六'][date.getDay()]
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 星期${week}`
}
function dateFromRange(range: string) {
if (range === 'all') return undefined
const date = new Date()
date.setDate(date.getDate() - Number(range))
return date.toISOString().slice(0, 10)
}
export function DashboardView({ username }: { username?: string | null }) {
const [activeFilters, setActiveFilters] = useState<DashboardFilterValues>({
keyword: '', categoryL1: '', categoryL2: '', status: '', supplier: '', dateRange: 'all',
})
const queryFilters = useMemo<ProductArchiveDashboardFilters>(() => ({
keyword: activeFilters.keyword || undefined,
categoryL1: activeFilters.categoryL1 || undefined,
categoryL2: activeFilters.categoryL2 || undefined,
productStatus: (activeFilters.status || undefined) as ProductArchiveDashboardFilters['productStatus'],
supplier: activeFilters.supplier || undefined,
updateTimeFrom: dateFromRange(activeFilters.dateRange),
}), [activeFilters])
const { data, loading, error } = useProductArchiveDashboardData(queryFilters)
const emptyDomains = DASHBOARD_DOMAINS.filter((domain) => ['operation', 'warehouse', 'data'].includes(domain.key))
return (
<div className="sc-dashboard">
<div className="sc-dashboard-hero">
<h1>{getGreeting()},{username || '你好'}</h1>
<p>欢迎回到供应链中心</p>
<div className="sc-dashboard-date">
<Calendar size={15} strokeWidth={1.8} />
<span>{getToday()}</span>
<div className="sc-dashboard-page">
<header className="sc-dashboard-header">
<div className="sc-dashboard-header-title">
<div className="sc-dashboard-kicker"><LayoutDashboard size={14} strokeWidth={1.8} /><span>供应链中心 / 工作台</span></div>
<h1>{getGreeting()},{username || '你好'}</h1>
<p>产品全量档案驾驶舱</p>
</div>
</div>
<style jsx>{`
.sc-dashboard {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100dvh - 40px);
padding: 40px 24px;
}
.sc-dashboard-hero {
text-align: center;
}
.sc-dashboard-hero h1 {
font-size: 30px;
font-weight: 800;
color: #27272a;
letter-spacing: -0.01em;
}
.sc-dashboard-hero p {
margin-top: 10px;
font-size: 14px;
color: #52525b;
}
.sc-dashboard-date {
margin-top: 20px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 8px;
background: #fff;
border: 1px solid #e4e4e7;
font-size: 13px;
color: #52525b;
}
`}</style>
<div className="sc-dashboard-header-actions">
<div className="sc-dashboard-header-date"><CalendarDays size={15} strokeWidth={1.8} /><span>{getToday()}</span></div>
<button type="button" className="sc-dashboard-refresh" onClick={() => window.location.reload()} title="刷新看板"><RefreshCw size={14} strokeWidth={1.8} /><span>刷新</span></button>
<a className="sc-dashboard-view-archive" href="/supply-chain/database/product-archive"><ExternalLink size={14} strokeWidth={1.8} />查看全量档案</a>
</div>
</header>
<DashboardFilterBar onApply={setActiveFilters} />
<DashboardKpiStrip data={data} loading={loading} />
{error && <div className="sc-dashboard-error">{error}</div>}
<DashboardProductInfoPanel data={data} />
<DashboardSupplyChainPanel data={data} />
<section className="sc-dashboard-domain-grid-wrap" aria-label="其他档案板块">
{emptyDomains.filter((domain) => domain.key !== 'data').map((domain) => <DashboardDomainPanel key={domain.key} domain={domain} />)}
</section>
<section className="sc-dashboard-data-panel" aria-label="数据经营">
<div className="sc-dashboard-data-heading">
<div><h2>数据经营</h2><p>库存、销售、价格利润和售后退货</p></div>
<span className="sc-dashboard-panel-state">暂无数据</span>
</div>
<div className="sc-dashboard-data-grid">
{DASHBOARD_DATA_SECTIONS.map((section) => (
<div className="sc-dashboard-data-segment" key={section.key}>
<h3>{section.label}</h3>
<div className="sc-dashboard-data-items">
{section.fields.map((field) => <div className="sc-dashboard-data-item" key={field}><span>{field}</span><strong>--</strong></div>)}
</div>
</div>
))}
</div>
<div className="sc-dashboard-structure-note"><span>当前展示口径:档案表当前快照</span><strong>{TOTAL_ARCHIVE_FIELD_COUNT} 个字段 / 5 个分组</strong></div>
</section>
<DashboardStyles />
</div>
)
}

View File

@ -0,0 +1,50 @@
import { ARCHIVE_COLUMNS } from '../database/product-archive/columns'
export type DashboardDomainKey = 'product' | 'supply' | 'operation' | 'warehouse' | 'data'
export type DashboardDomain = {
key: DashboardDomainKey
label: string
fieldCount: number
color: string
fields: string[]
}
const DOMAIN_META: Record<DashboardDomainKey, Omit<DashboardDomain, 'key' | 'label' | 'fieldCount' | 'fields'>> = {
product: { color: '#2f6fed' },
supply: { color: '#0f9f7f' },
operation: { color: '#c88924' },
warehouse: { color: '#7558c9' },
data: { color: '#c65779' },
}
const DOMAIN_KEYS: DashboardDomainKey[] = ['product', 'supply', 'operation', 'warehouse', 'data']
export const DASHBOARD_DOMAINS: DashboardDomain[] = ARCHIVE_COLUMNS.map((group, index) => ({
key: DOMAIN_KEYS[index] ?? 'data',
label: group.group,
fieldCount: group.columns.length,
fields: group.columns.map((column) => column.label),
...DOMAIN_META[DOMAIN_KEYS[index] ?? 'data'],
}))
export const DASHBOARD_KPIS = [
{ key: 'barcode', label: '条码档案', color: '#2f6fed', icon: 'barcode' as const },
{ key: 'spu', label: 'SPU 产品', color: '#0f9f7f', icon: 'product' as const },
{ key: 'sales', label: '销售中', color: '#1a9b72', icon: 'status' as const },
{ key: 'stopped', label: '停产', color: '#c88924', icon: 'status' as const },
{ key: 'retired', label: '退市', color: '#c65779', icon: 'status' as const },
{ key: 'supplier', label: '供应商', color: '#7558c9', icon: 'supplier' as const },
] as const
export const DASHBOARD_DATA_SECTIONS = [
{ key: 'inventory', label: '库存', fields: ['库存货值', '库存可使用天数', '简尚库存', '清货库存', '工厂库存', '云仓库存', '在途数量'] },
{ key: 'sales', label: '销售与发货', fields: ['采购日销', '3日 / 7日日销', '15日 / 30日日销', '发货金额', '发货件数'] },
{ key: 'profit', label: '价格与利润', fields: ['采购价', '成本价', '毛利额', '毛利率', '费比', '营业利润额'] },
{ key: 'afterSales', label: '售后与退货', fields: ['售后次数', '售后率', '售后成本', '退货件数', '退货率', '配件补发次数'] },
] as const
export const TOTAL_ARCHIVE_FIELD_COUNT = DASHBOARD_DOMAINS.reduce(
(total, domain) => total + domain.fieldCount,
0,
)

View File

@ -0,0 +1,37 @@
'use client'
import { useEffect, useState } from 'react'
import type { ApiResponse } from '@/server/core/types'
import type { ProductArchiveDashboardData, ProductArchiveDashboardFilters } from '@/lib/supply-chain-product-archive-dashboard'
export function useProductArchiveDashboardData(filters: ProductArchiveDashboardFilters) {
const [data, setData] = useState<ProductArchiveDashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
const params = new URLSearchParams()
Object.entries(filters).forEach(([key, value]) => { if (value) params.set(key, value) })
async function load() {
setLoading(true)
setError('')
try {
const response = await fetch(`/api/supply-chain/product-archive/dashboard?${params.toString()}`, { signal: controller.signal })
const payload = (await response.json()) as ApiResponse<ProductArchiveDashboardData>
if (!response.ok || !payload.success || !payload.data) throw new Error(payload.error?.message || '看板数据加载失败')
setData(payload.data)
} catch (loadError) {
if (controller.signal.aborted) return
setError(loadError instanceof Error ? loadError.message : '看板数据加载失败')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => controller.abort()
}, [filters])
return { data, loading, error }
}

View File

@ -0,0 +1,55 @@
import type { ProductArchiveSalesStatus } from './supply-chain-product-archive'
export type ProductArchiveDashboardFilters = {
keyword?: string
categoryL1?: string
categoryL2?: string
productStatus?: ProductArchiveSalesStatus
supplier?: string
updateTimeFrom?: string
}
export type ProductArchiveDashboardBreakdown = {
label: string
count: number
}
export type ProductArchiveDashboardRecentRow = {
barcode: string
spu: string
productName: string
categoryL1: string
categoryL2: string
brand: string
productStatus: string
supplier: string
purchaser: string
updatedAt: string
}
export type ProductArchiveDashboardData = {
overview: {
barcodeCount: number
spuCount: number
salesCount: number
stoppedCount: number
retiredCount: number
statusMissingCount: number
brandCount: number
supplierCount: number
purchaserCount: number
}
productInfo: {
statuses: ProductArchiveDashboardBreakdown[]
categoriesL1: ProductArchiveDashboardBreakdown[]
categoriesL2: ProductArchiveDashboardBreakdown[]
brands: ProductArchiveDashboardBreakdown[]
recent: ProductArchiveDashboardRecentRow[]
}
supplyChain: {
suppliers: ProductArchiveDashboardBreakdown[]
purchasers: ProductArchiveDashboardBreakdown[]
minimumOrderQuantities: ProductArchiveDashboardBreakdown[]
}
generatedAt: string
}

View File

@ -0,0 +1,137 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import type { ProductArchiveDashboardFilters } from '@/lib/supply-chain-product-archive-dashboard'
const recentSelect = {
barcode: true,
spu: true,
productName: true,
categoryL1: true,
categoryL2: true,
brand: true,
productStatus: true,
supplierName: true,
purchaseOwner: true,
updatedAt: true,
} satisfies Prisma.SupplyChainProductArchiveSelect
function parseDateBoundary(value: string) {
return new Date(`${value}T00:00:00.000`)
}
export function buildProductArchiveDashboardWhere(
filters: ProductArchiveDashboardFilters = {},
): Prisma.SupplyChainProductArchiveWhereInput {
const clauses: Prisma.SupplyChainProductArchiveWhereInput[] = []
const keyword = filters.keyword?.trim()
if (keyword) {
clauses.push({
OR: [
{ barcode: { contains: keyword, mode: 'insensitive' } },
{ spu: { contains: keyword, mode: 'insensitive' } },
{ sku: { contains: keyword, mode: 'insensitive' } },
{ productName: { contains: keyword, mode: 'insensitive' } },
],
})
}
if (filters.categoryL1?.trim()) clauses.push({ categoryL1: { equals: filters.categoryL1.trim() } })
if (filters.categoryL2?.trim()) clauses.push({ categoryL2: { contains: filters.categoryL2.trim(), mode: 'insensitive' } })
if (filters.productStatus) clauses.push({ productStatus: filters.productStatus })
if (filters.supplier?.trim()) clauses.push({ supplierName: { contains: filters.supplier.trim(), mode: 'insensitive' } })
if (filters.updateTimeFrom) clauses.push({ updatedAt: { gte: parseDateBoundary(filters.updateTimeFrom) } })
return clauses.length ? { AND: clauses } : {}
}
export class SupplyChainProductArchiveDashboardRepository {
async getDashboard(filters: ProductArchiveDashboardFilters = {}) {
const where = buildProductArchiveDashboardWhere(filters)
const nonEmpty = (field: 'barcode' | 'spu' | 'brand' | 'supplierName' | 'purchaseOwner') => ({
AND: [where, { [field]: { not: '' } }],
}) as Prisma.SupplyChainProductArchiveWhereInput
const [
total,
barcodes,
spus,
brands,
suppliers,
purchasers,
statusRows,
categoryL1Rows,
categoryL2Rows,
brandRows,
supplierRows,
purchaserRows,
minimumOrderQuantityRows,
recent,
] = await Promise.all([
prisma.supplyChainProductArchive.count({ where }),
prisma.supplyChainProductArchive.findMany({ where: nonEmpty('barcode'), distinct: ['barcode'], select: { barcode: true } }),
prisma.supplyChainProductArchive.findMany({ where: nonEmpty('spu'), distinct: ['spu'], select: { spu: true } }),
prisma.supplyChainProductArchive.findMany({ where: nonEmpty('brand'), distinct: ['brand'], select: { brand: true } }),
prisma.supplyChainProductArchive.findMany({ where: nonEmpty('supplierName'), distinct: ['supplierName'], select: { supplierName: true } }),
prisma.supplyChainProductArchive.findMany({ where: nonEmpty('purchaseOwner'), distinct: ['purchaseOwner'], select: { purchaseOwner: true } }),
prisma.supplyChainProductArchive.groupBy({ where, by: ['productStatus'], _count: { _all: true } }),
prisma.supplyChainProductArchive.groupBy({ where: { AND: [where, { categoryL1: { not: '' } }] }, by: ['categoryL1'], _count: { _all: true }, orderBy: { _count: { categoryL1: 'desc' } }, take: 10 }),
prisma.supplyChainProductArchive.groupBy({ where: { AND: [where, { categoryL2: { not: '' } }] }, by: ['categoryL2'], _count: { _all: true }, orderBy: { _count: { categoryL2: 'desc' } }, take: 10 }),
prisma.supplyChainProductArchive.groupBy({ where: nonEmpty('brand'), by: ['brand'], _count: { _all: true }, orderBy: { _count: { brand: 'desc' } }, take: 10 }),
prisma.supplyChainProductArchive.groupBy({ where: nonEmpty('supplierName'), by: ['supplierName'], _count: { _all: true }, orderBy: { _count: { supplierName: 'desc' } }, take: 10 }),
prisma.supplyChainProductArchive.groupBy({ where: nonEmpty('purchaseOwner'), by: ['purchaseOwner'], _count: { _all: true }, orderBy: { _count: { purchaseOwner: 'desc' } }, take: 10 }),
prisma.supplyChainProductArchive.groupBy({ where: { AND: [where, { minimumOrderQuantity: { not: '' } }] }, by: ['minimumOrderQuantity'], _count: { _all: true }, orderBy: { _count: { minimumOrderQuantity: 'desc' } }, take: 10 }),
prisma.supplyChainProductArchive.findMany({ where, select: recentSelect, orderBy: [{ updatedAt: 'desc' }, { barcode: 'asc' }], take: 8 }),
])
const countByStatus = new Map(statusRows.map((row) => [row.productStatus, row._count._all]))
const toBreakdown = <T extends string>(rows: Array<{ _count: { _all: number }} & Record<T, string>>, key: T) =>
rows.map((row) => ({ label: row[key], count: row._count._all }))
return {
overview: {
barcodeCount: barcodes.length,
spuCount: spus.length,
salesCount: countByStatus.get('销售') ?? 0,
stoppedCount: countByStatus.get('停产') ?? 0,
retiredCount: countByStatus.get('退市') ?? 0,
statusMissingCount: countByStatus.get('') ?? 0,
brandCount: brands.length,
supplierCount: suppliers.length,
purchaserCount: purchasers.length,
},
productInfo: {
statuses: [
{ label: '销售', count: countByStatus.get('销售') ?? 0 },
{ label: '停产', count: countByStatus.get('停产') ?? 0 },
{ label: '退市', count: countByStatus.get('退市') ?? 0 },
{ label: '状态未录入', count: countByStatus.get('') ?? 0 },
],
categoriesL1: toBreakdown(categoryL1Rows, 'categoryL1'),
categoriesL2: toBreakdown(categoryL2Rows, 'categoryL2'),
brands: toBreakdown(brandRows, 'brand'),
recent: recent.map((row) => ({
barcode: row.barcode,
spu: row.spu,
productName: row.productName,
categoryL1: row.categoryL1,
categoryL2: row.categoryL2,
brand: row.brand,
productStatus: row.productStatus,
supplier: row.supplierName,
purchaser: row.purchaseOwner,
updatedAt: row.updatedAt.toISOString(),
})),
},
supplyChain: {
suppliers: toBreakdown(supplierRows, 'supplierName'),
purchasers: toBreakdown(purchaserRows, 'purchaseOwner'),
minimumOrderQuantities: toBreakdown(minimumOrderQuantityRows, 'minimumOrderQuantity'),
},
total,
generatedAt: new Date().toISOString(),
}
}
}
export const supplyChainProductArchiveDashboardRepository =
new SupplyChainProductArchiveDashboardRepository()

View File

@ -0,0 +1,11 @@
import type { ProductArchiveDashboardFilters } from '@/lib/supply-chain-product-archive-dashboard'
import { supplyChainProductArchiveDashboardRepository } from '@/server/repositories/supply-chain-product-archive-dashboard.repository'
export class SupplyChainProductArchiveDashboardService {
async getDashboard(filters: ProductArchiveDashboardFilters = {}) {
return supplyChainProductArchiveDashboardRepository.getDashboard(filters)
}
}
export const supplyChainProductArchiveDashboardService =
new SupplyChainProductArchiveDashboardService()