feat(supply-chain): add product archive dashboard
This commit is contained in:
parent
5b537b8f9c
commit
f6460b1eda
@ -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'] },
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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 }
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user