feat: 月度模式全面参数化 - 移除硬编码日期,前后端按月动态查询

This commit is contained in:
freedakgmail
2026-08-01 13:10:22 +08:00
parent a90c133578
commit dab06ba109
43 changed files with 9135 additions and 309 deletions
+8
View File
@@ -26,6 +26,10 @@ import { SituationalAwarenessPage } from '@/pages/SituationalAwarenessPage'
import { BossPage } from '@/pages/BossPage'
import { RevenuePage } from '@/pages/RevenuePage'
import { BankPage } from '@/pages/BankPage'
import { CentralKitchenPage } from '@/pages/CentralKitchenPage'
import { DistributionReconciliationPage } from '@/pages/DistributionReconciliationPage'
import { BomPenetrationPage } from '@/pages/BomPenetrationPage'
import { ProductionPlanPage } from '@/pages/ProductionPlanPage'
import { LoginPage } from '@/pages/LoginPage'
const queryClient = new QueryClient({
@@ -73,6 +77,10 @@ export default function App() {
<Route path="/boss" element={<BossPage />} />
<Route path="/revenue" element={<RevenuePage />} />
<Route path="/bank" element={<BankPage />} />
<Route path="/central-kitchen" element={<CentralKitchenPage />} />
<Route path="/distribution-reconciliation" element={<DistributionReconciliationPage />} />
<Route path="/bom-penetration" element={<BomPenetrationPage />} />
<Route path="/production-plan" element={<ProductionPlanPage />} />
<Route path="/regional" element={<RegionalPage />} />
<Route path="/store" element={<StorePage />} />
<Route path="/tasks" element={<TasksPage />} />
+1 -1
View File
@@ -39,7 +39,7 @@ export function FilterableTable({
const filtered = useMemo(() => {
let r = data
if (filter && filterKey) r = r.filter((row: any) => row[filterKey] === filter)
if (statusFilter && statusFilterKey) r = r.filter((row: any) => row[statusFilterKey] === statusFilter)
if (statusFilter && statusFilterKey) r = r.filter((row: any) => String(row[statusFilterKey]) === statusFilter)
return [...r].sort((a: any, b: any) => {
const av = parseFloat(a[sort]) || (typeof a[sort] === 'string' ? 0 : a[sort] || 0)
const bv = parseFloat(b[sort]) || (typeof b[sort] === 'string' ? 0 : b[sort] || 0)
+5 -1
View File
@@ -1,6 +1,6 @@
import { ReactNode, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark } from 'lucide-react'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
interface LayoutProps {
@@ -53,6 +53,10 @@ const menuGroups: MenuGroup[] = [
items: [
{ path: '/cost-analysis', label: '菜品成本', icon: PieChart, roles: ['hq', 'dept'] },
{ path: '/cost', label: '成本库存', icon: Utensils, roles: ['hq', 'dept'] },
{ path: '/central-kitchen', label: '中央厨房', icon: ChefHat, roles: ['hq', 'dept'] },
{ path: '/distribution-reconciliation', label: '配送对账', icon: Truck, roles: ['hq', 'dept'] },
{ path: '/bom-penetration', label: 'BOM穿透', icon: Network, roles: ['hq', 'dept'] },
{ path: '/production-plan', label: '生产要货', icon: TrendingUpIcon, roles: ['hq', 'dept'] },
{ path: '/store-expense', label: '门店费用', icon: Wallet, roles: ['hq', 'dept'] },
],
},
+106
View File
@@ -0,0 +1,106 @@
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react'
import { useState, useCallback, useEffect } from 'react'
interface MonthPickerProps {
month: string
onChange: (month: string) => void
rangeMode?: 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'
onRangeModeChange?: (mode: 'month' | 'quarter' | 'halfyear' | 'year' | 'custom') => void
}
const RANGE_MODES: { value: 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'; label: string }[] = [
{ value: 'month', label: '月度' },
{ value: 'quarter', label: '季度' },
{ value: 'halfyear', label: '半年' },
{ value: 'year', label: '全年' },
]
export function MonthPicker({ month, onChange, rangeMode = 'month', onRangeModeChange }: MonthPickerProps) {
const [showPicker, setShowPicker] = useState(false)
const displayMonth = month.length === 10 ? month.substring(0, 7) : month
const prevMonth = useCallback(() => {
const [y, m] = displayMonth.split('-').map(Number)
const d = new Date(y, m - 2, 1)
onChange(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}, [displayMonth, onChange])
const nextMonth = useCallback(() => {
const [y, m] = displayMonth.split('-').map(Number)
const d = new Date(y, m, 1)
onChange(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}, [displayMonth, onChange])
useEffect(() => {
const handler = () => setShowPicker(false)
if (showPicker) {
window.addEventListener('click', handler)
return () => window.removeEventListener('click', handler)
}
}, [showPicker])
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-md border bg-card px-1 py-0.5">
<button
onClick={prevMonth}
className="rounded p-1 hover:bg-muted"
title="上月"
>
<ChevronLeft size={16} />
</button>
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setShowPicker(!showPicker) }}
className="flex items-center gap-1 px-2 py-1 text-sm font-medium hover:bg-muted rounded"
>
<Calendar size={14} />
{displayMonth}
</button>
{showPicker && (
<div
className="absolute top-full left-0 z-50 mt-1 rounded-md border bg-card p-3 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<input
type="month"
value={displayMonth}
onChange={(e) => {
onChange(e.target.value)
setShowPicker(false)
}}
className="rounded border px-2 py-1 text-sm"
/>
</div>
)}
</div>
<button
onClick={nextMonth}
className="rounded p-1 hover:bg-muted"
title="下月"
>
<ChevronRight size={16} />
</button>
</div>
{onRangeModeChange && (
<div className="flex items-center gap-1 rounded-md border bg-card px-1 py-0.5">
{RANGE_MODES.map((m) => (
<button
key={m.value}
onClick={() => onRangeModeChange(m.value)}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
rangeMode === m.value
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
}`}
>
{m.label}
</button>
))}
</div>
)}
</div>
)
}
+1 -1
View File
@@ -2,7 +2,7 @@ import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000,
timeout: 120000,
})
api.interceptors.request.use((config) => {
+22
View File
@@ -0,0 +1,22 @@
import { useState, useCallback } from 'react'
const DEFAULT_MONTH = '2026-04'
export function useMonthParam() {
const [month, setMonth] = useState(DEFAULT_MONTH)
const monthParam = useCallback(() => {
return { month }
}, [month])
const monthForApi = month.length === 7 ? month : month.substring(0, 7)
return { month: monthForApi, setMonth, monthParam }
}
export type RangeMode = 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'
export function useRangeMode() {
const [rangeMode, setRangeMode] = useState<RangeMode>('month')
return { rangeMode, setRangeMode }
}
+4 -4
View File
@@ -103,7 +103,7 @@ export function BankPage() {
// 计算各项指标值
const metrics = useMemo(() => {
if (!ov || !wf || !stability) return null
const netMargin = wf.received > 0 ? (wf.net_profit / wf.received * 100) : 0
const netMargin = wf.received > 0 ? (wf.store_contribution / wf.received * 100) : 0
const cv = stability.cv
const redStores = risk.filter((r: any) => r.risk_level === '红色').reduce((s: number, r: any) => s + r.store_count, 0)
const totalStores = risk.reduce((s: number, r: any) => s + r.store_count, 0)
@@ -140,7 +140,7 @@ export function BankPage() {
{ name: '房租', value: -wf.rent, base: wf.received - wf.food_cost - wf.wage, fill: '#8b5cf6' },
{ name: '水电', value: -wf.utility, base: wf.received - wf.food_cost - wf.wage - wf.rent, fill: '#06b6d4' },
{ name: '其他', value: -(wf.dorm + wf.commission + wf.other_expense), base: wf.received - wf.food_cost - wf.wage - wf.rent - wf.utility, fill: '#64748b' },
{ name: '利润', value: wf.net_profit, base: 0, fill: wf.net_profit >= 0 ? '#22c55e' : '#ef4444' },
{ name: '门店贡献利润', value: wf.store_contribution, base: 0, fill: wf.store_contribution >= 0 ? '#22c55e' : '#ef4444' },
]
}, [wf])
@@ -254,10 +254,10 @@ export function BankPage() {
{wf && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="净利润" value={wf.net_profit} format="currency" status={wf.net_profit > 0 ? 'good' : 'bad'} description={`净利${wf.received > 0 ? formatPercent(wf.net_profit / wf.received * 100) : '-'}`} />
<MetricCard title="门店贡献利润估算" value={wf.store_contribution} format="currency" status={wf.store_contribution > 0 ? 'good' : 'bad'} description={`贡献${wf.received > 0 ? formatPercent(wf.store_contribution / wf.received * 100) : '-'} · 仅费用已匹配门店`} />
<MetricCard title="食材成本率" value={wf.received > 0 ? wf.food_cost / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.food_cost / wf.received * 100 > 35 ? 'warn' : 'good'} description="食材成本 ÷ 实收" />
<MetricCard title="费用率" value={wf.received > 0 ? wf.total_expense / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.total_expense / wf.received * 100 > 45 ? 'warn' : 'good'} description="经营费用 ÷ 实收" />
<MetricCard title="实收分配" value={wf.received > 0 ? wf.net_profit / wf.received * 100 : 0} format="percent" description="利润占实收比例" />
<MetricCard title="实收分配" value={wf.received > 0 ? wf.store_contribution / wf.received * 100 : 0} format="percent" description="门店贡献利润占实收比例" />
</div>
<div className="mt-4">
<ResponsiveContainer width="100%" height={250}>
+342
View File
@@ -0,0 +1,342 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, Treemap } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatNumber, formatCurrency } from '@/lib/utils'
import { Network, ChevronRight, Layers, GitBranch } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
function varianceColorClass(pct: number): string {
if (Math.abs(pct) <= 3) return 'text-green-600'
if (Math.abs(pct) <= 10) return 'text-yellow-600'
return 'text-red-600'
}
function varianceText(pct: number): string {
if (pct > 0) return `+${pct.toFixed(2)}%`
return `${pct.toFixed(2)}%`
}
export function BomPenetrationPage() {
const [month, setMonth] = useState('2026-04')
const [selectedProduct, setSelectedProduct] = useState('0202028')
const [productFilter, setProductFilter] = useState('')
const { data: overviewData, isLoading: overviewLoading } = useQuery({
queryKey: ['bom-penetration-overview', month],
queryFn: async () => {
const res = await api.get(`/central-kitchen/bom-penetration?month=${month}`)
return res.data
},
})
const { data: productData, isLoading: productLoading } = useQuery({
queryKey: ['bom-penetration', month, selectedProduct],
queryFn: async () => {
const res = await api.get(`/central-kitchen/bom-penetration?month=${month}&productCode=${selectedProduct}`)
return res.data
},
enabled: !!selectedProduct,
})
if (overviewLoading) return <LoadingSpinner />
if (!overviewData) return <div className="p-4 text-muted-foreground"></div>
const { productList, bomSummary, multiLevelChains } = overviewData
const bomTree = productData?.bomTree || []
// 汇总指标
const totalProducts = productList.length
const multiLevelProducts = productList.filter((p: any) => p.has_multi_level_bom).length
const totalBomTheoretical = bomSummary.reduce((s: number, b: any) => s + b.bom_theoretical_amt, 0)
const totalBomIssue = bomSummary.reduce((s: number, b: any) => s + b.bom_issue_amt, 0)
const totalVariance = bomSummary.reduce((s: number, b: any) => s + b.variance_amt, 0)
// BOM汇总Top10
const bomSummaryTop10 = bomSummary.slice(0, 15)
const bomChartData = bomSummaryTop10.map((b: any) => ({
name: b.product_name,
理论成本: b.bom_theoretical_amt,
领用成本: b.bom_issue_amt,
}))
// BOM树按层级分组
const treeByLevel: Record<number, any[]> = {}
bomTree.forEach((node: any) => {
if (!treeByLevel[node.level]) treeByLevel[node.level] = []
treeByLevel[node.level].push(node)
})
const maxLevel = Math.max(...Object.keys(treeByLevel).map(Number), 1)
// Treemap数据
const treemapData = bomSummary.slice(0, 30).map((b: any) => ({
name: b.product_name,
size: b.bom_theoretical_amt,
variance: b.variance_pct,
}))
const filteredProducts = productList.filter((p: any) =>
!productFilter ||
p.product_code?.includes(productFilter) ||
p.product_name?.includes(productFilter)
)
return (
<div className="space-y-4 p-4">
{/* 页面标题 */}
<div className="flex items-center gap-2">
<Network className="h-6 w-6 text-purple-600" />
<h1 className="text-xl font-bold">BOM成本穿透</h1>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 核心指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
<MetricCard title="完工产品数" value={totalProducts} unit="个" />
<MetricCard title="含多级BOM" value={multiLevelProducts} unit="个" status="warn" description="配方中包含半成品的产成品数" />
<MetricCard title="BOM理论成本" value={totalBomTheoretical} format="currency" />
<MetricCard title="BOM领用成本" value={totalBomIssue} format="currency" />
<MetricCard title="差异金额" value={totalVariance} format="currency" status={totalVariance >= 0 ? 'warn' : 'bad'} />
<MetricCard title="半成品依赖链" value={multiLevelChains.length} unit="条" description="成品→半成品的依赖关系数" />
</div>
{/* BOM成本结构Top15 */}
<CollapsibleSection title="BOM成本结构Top15" subtitle="按理论成本排序的产成品BOM汇总">
<ResponsiveContainer width="100%" height={350}>
<BarChart data={bomChartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
<YAxis tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Bar dataKey="理论成本" fill="#1677ff" />
<Bar dataKey="领用成本" fill="#722ed1" />
</BarChart>
</ResponsiveContainer>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{bomSummaryTop10.map((b: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50 cursor-pointer" onClick={() => setSelectedProduct(b.product_code)}>
<td className="py-1.5 pr-3 text-xs">{b.product_code}</td>
<td className="py-1.5 pr-3 font-medium text-blue-600">{b.product_name}</td>
<td className="py-1.5 pr-3 text-right">{b.material_count}</td>
<td className="py-1.5 pr-3 text-right">
{b.sub_product_count > 0 ? <span className="text-purple-600 font-medium">{b.sub_product_count}</span> : '-'}
</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(b.bom_theoretical_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(b.bom_issue_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(b.bom_unit_theoretical_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(b.bom_unit_issue_cost)}</td>
<td className={`py-1.5 pr-3 text-right ${b.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
{formatCurrency(b.variance_amt)}
</td>
<td className={`py-1.5 pr-3 text-right font-medium ${varianceColorClass(b.variance_pct)}`}>
{varianceText(b.variance_pct)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
{/* BOM成本Treemap */}
<CollapsibleSection title="BOM成本分布Treemap" subtitle="按理论成本大小展示产成品BOM分布" defaultOpen={false}>
<ResponsiveContainer width="100%" height={400}>
<Treemap
data={treemapData}
dataKey="size"
stroke="#fff"
fill="#722ed1"
content={<CustomTreemapContent />}
/>
</ResponsiveContainer>
</CollapsibleSection>
{/* BOM树穿透 */}
<CollapsibleSection
title={`BOM树穿透 - ${productData?.productList?.find((p: any) => p.product_code === selectedProduct)?.product_name || selectedProduct}`}
subtitle={`${bomTree.length}个节点,最大${maxLevel}`}
>
<div className="mb-3 flex items-center gap-2">
<GitBranch className="h-4 w-4 text-purple-600" />
<span className="text-sm text-muted-foreground">BOM树</span>
</div>
{productLoading ? (
<LoadingSpinner />
) : (
<div className="space-y-3">
{Object.entries(treeByLevel).map(([level, nodes]) => (
<div key={level} className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2">
<Layers className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium">Level {level}</span>
<span className="text-xs text-muted-foreground">({nodes.length})</span>
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
{nodes.map((node: any, i: number) => (
<div
key={i}
className={`rounded border p-2 ${node.is_finished_product ? 'border-purple-300 bg-purple-50/50' : 'border-gray-200'}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{node.material_name}</span>
{node.is_finished_product && (
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-600"></span>
)}
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>: {node.material_code}</span>
<span>: {node.unit}</span>
</div>
<div className="mt-1 flex justify-between text-xs">
<span>: {formatCurrency(node.theoretical_amt)}</span>
<span>: {formatCurrency(node.issue_amt)}</span>
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>: {formatNumber(node.theoretical_qty)}</span>
<span>: {formatNumber(node.issue_qty)}</span>
</div>
{node.is_finished_product && (
<button
className="mt-1 text-xs text-purple-600 hover:underline"
onClick={() => setSelectedProduct(node.material_code)}
>
BOM
</button>
)}
</div>
))}
</div>
</div>
))}
{bomTree.length === 0 && (
<div className="py-4 text-center text-muted-foreground"></div>
)}
</div>
)}
</CollapsibleSection>
{/* 产品选择器 */}
<CollapsibleSection title="产品列表" subtitle={`${filteredProducts.length}个产品`} defaultOpen={false}
headerRight={
<input
type="text"
placeholder="搜索产品..."
value={productFilter}
onChange={(e) => setProductFilter(e.target.value)}
className="rounded border px-3 py-1 text-sm"
/>
}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-center">BOM</th>
<th className="pb-2 pr-3"></th>
</tr>
</thead>
<tbody>
{filteredProducts.map((p: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{p.product_code}</td>
<td className="py-1.5 pr-3 font-medium">{p.product_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{p.category_minor}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(p.inbound_quantity)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.theoretical_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.actual_cost)}</td>
<td className="py-1.5 pr-3 text-right">{p.material_count}</td>
<td className="py-1.5 pr-3 text-center">
{p.has_multi_level_bom ? <span className="text-purple-600"></span> : '-'}
</td>
<td className="py-1.5 pr-3">
<button
className="text-xs text-blue-600 hover:underline"
onClick={() => setSelectedProduct(p.product_code)}
>
BOM
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
{/* 半成品依赖链 */}
<CollapsibleSection title="半成品依赖链" subtitle={`${multiLevelChains.length}条成品→半成品依赖关系`} defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
</tr>
</thead>
<tbody>
{multiLevelChains.map((c: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{c.finished_code}</td>
<td className="py-1.5 pr-3 font-medium">{c.finished_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{c.finished_recipe}</td>
<td className="py-1.5 pr-3"><ChevronRight className="h-4 w-4 text-purple-600" /></td>
<td className="py-1.5 pr-3 text-xs">{c.sub_product_code}</td>
<td className="py-1.5 pr-3 font-medium text-purple-600">{c.sub_product_finished_name || c.sub_product_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{c.sub_product_recipe || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
</div>
)
}
function CustomTreemapContent(props: any) {
const { x, y, width, height, name, size, variance } = props
if (width < 50 || height < 30) return null
const fill = Math.abs(variance || 0) <= 3 ? '#52c41a' : Math.abs(variance || 0) <= 10 ? '#faad14' : '#f5222d'
return (
<g>
<rect x={x} y={y} width={width} height={height} stroke="#fff" fill={fill} fillOpacity={0.7} />
<text x={x + 4} y={y + 16} fontSize={11} fill="#333">
{name?.length > 10 ? name.slice(0, 10) + '...' : name}
</text>
<text x={x + 4} y={y + 30} fontSize={10} fill="#666">
¥{(size / 10000).toFixed(1)}
</text>
</g>
)
}
+99 -15
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, LineChart, Line, PieChart, Pie, Legend, ComposedChart } from 'recharts'
@@ -6,10 +7,61 @@ import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt } from 'lucide-react'
import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt, Target, ChevronDown } from 'lucide-react'
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
function ProfitOppItem({ index, o, opp, pct, confColor }: { index: number; o: any; opp: number; pct: number; confColor: string }) {
const [expanded, setExpanded] = useState(false)
return (
<div className={`rounded-lg border transition-all ${expanded ? 'border-purple-300 shadow-sm' : ''}`}>
<div
className="flex cursor-pointer items-center gap-3 p-3"
onClick={() => setExpanded(!expanded)}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-100 text-sm font-bold text-purple-600">{index + 1}</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{o.category}</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${confColor}`}>{o.confidence}</span>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
线: {typeof o.baseline === 'number' ? (o.baseline < 100 ? formatPercent(o.baseline) : formatNumber(o.baseline)) : o.baseline}
{typeof o.baseline === 'number' && o.baseline < 100 ? '%' : ''} · : {o.owner} · : {o.evidence}
</p>
</div>
<div className="text-right">
<p className="text-lg font-bold text-green-600">{formatCurrency(opp)}</p>
<p className="text-[10px] text-muted-foreground"> {formatPercent(pct)}</p>
</div>
</div>
{expanded && o.detail && (
<div className="border-t bg-purple-50/20 px-4 py-3">
{o.detail.split('\n').map((line: string, idx: number) => {
const isHeader = line.endsWith('') || line.endsWith(':')
const isAction = line.startsWith('行动指向') || /^\d/.test(line)
const isStoreLine = line.includes('费率') || line.includes('差异') || line.includes('优惠率') || line.includes('佣金') || line.includes('收入')
return (
<p
key={idx}
className={`text-xs leading-relaxed ${
isHeader ? 'mt-2 font-semibold text-foreground' :
isAction ? 'text-foreground' :
isStoreLine ? 'text-muted-foreground' :
'text-muted-foreground'
}`}
>
{line || '\u00A0'}
</p>
)
})}
</div>
)}
</div>
)
}
export function BossPage() {
const navigate = useNavigate()
@@ -49,6 +101,10 @@ export function BossPage() {
queryKey: ['overview/store-profit-ranking'],
queryFn: () => api.get('/overview/store-profit-ranking'),
})
const { data: profitOppData } = useQuery({
queryKey: ['overview/profit-opportunity'],
queryFn: () => api.get('/overview/profit-opportunity'),
})
const pageLoading = odLoading || dailyLoading || exLoading || wfLoading || riskLoading || priorityLoading
@@ -61,6 +117,8 @@ export function BossPage() {
const costOv = (costOverview as any)?.data || {}
const structRows = (expenseStructure as any)?.data || []
const profitRanking = (profitRankingData as any)?.data || []
const profitOpp = (profitOppData as any)?.data?.items || []
const totalOpportunity = profitOpp.reduce((s: number, o: any) => s + Number(o.opportunity || 0), 0)
// 风险分布
const riskSummary = riskRows.reduce((acc: any, r: any) => {
@@ -101,7 +159,7 @@ export function BossPage() {
{ name: '宿舍', value: -Number(wf.dorm), type: 'cost' },
{ name: '外卖佣金', value: -Number(wf.commission), type: 'cost' },
{ name: '其他费用', value: -Number(wf.other_expense), type: 'cost' },
{ name: '利润', value: Number(wf.net_profit), type: 'profit' },
{ name: '门店贡献利润', value: Number(wf.store_contribution), type: 'profit' },
] : []
// 瀑布图累计计算
@@ -156,14 +214,14 @@ export function BossPage() {
{/* ① 核心经营指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="实收总额" value={ex?.total_received} format="currency" trend={trendReceived} description={`全口径${ex?.total_stores}家门店。公式:全部门店实收合计。环比上周 ${trendReceived > 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} />
<MetricCard title="实际净利润" value={ex?.actual_net_profit} format="currency" status={Number(ex?.actual_net_margin_pct) < 5 ? 'bad' : Number(ex?.actual_net_margin_pct) < 10 ? 'warn' : 'good'} description={`净利${formatPercent(ex?.actual_net_margin_pct)} · 公式:实收 - 食材成本 - 经营费用。建议值:≥ 10% 优秀,5-10% 合格,< 5% 需整改`} />
<MetricCard title="门店贡献利润估算" value={ex?.actual_net_profit} format="currency" status={Number(ex?.actual_net_margin_pct) < 5 ? 'bad' : Number(ex?.actual_net_margin_pct) < 10 ? 'warn' : 'good'} description={`贡献${formatPercent(ex?.actual_net_margin_pct)} · 公式:实收 - 食材成本 - 经营费用(仅费用已匹配门店)。建议值:≥ 10% 优秀,5-10% 合格,< 5% 需整改`} />
<MetricCard title="门店数" value={ex?.total_stores} unit="家" description={`盈利 ${ex?.profitable_stores}家 / 亏损 ${ex?.loss_stores}家。无费用数据 ${ex?.no_expense_stores}`} />
<MetricCard title="客单价" value={ex?.avg_bill_value} format="currency" trend={trend((last7.length > 0 ? sumKey(last7, 'received') / sumKey(last7, 'bill_count') : 0), (prev7.length > 0 ? sumKey(prev7, 'received') / sumKey(prev7, 'bill_count') : 0))} description={`账单 ${formatNumber(ex?.total_bills)} 笔 · 公式:实收 ÷ 账单数`} />
</div>
{/* ② 利润瀑布 */}
{wf && (
<CollapsibleSection title="利润结构" subtitle="实收 → 减各项成本费用 → 净利润">
<CollapsibleSection title="利润结构" subtitle="实收 → 减各项成本费用 → 门店贡献利润估算">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={waterfallChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
@@ -189,8 +247,8 @@ export function BossPage() {
</div>
))}
<div className="rounded border border-green-200 bg-green-50/40 p-2 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-bold text-green-700">{formatCurrency(wf.net_profit)}</p>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-bold text-green-700">{formatCurrency(wf.store_contribution)}</p>
</div>
</div>
{/* 成本费用占比汇总条 */}
@@ -213,20 +271,46 @@ export function BossPage() {
<div className="flex items-center justify-center bg-slate-400" style={{ width: `${(Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100}%` }} title={`其他费用 ${formatPercent((Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100)}`}>
{formatPercent((Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100)}
</div>
<div className="flex items-center justify-center bg-green-500" style={{ width: `${Number(wf.net_profit) / Number(wf.received) * 100}%` }} title={`利润 ${formatPercent(Number(wf.net_profit) / Number(wf.received) * 100)}`}>
{formatPercent(Number(wf.net_profit) / Number(wf.received) * 100)}
<div className="flex items-center justify-center bg-green-500" style={{ width: `${Number(wf.store_contribution) / Number(wf.received) * 100}%` }} title={`门店贡献利润 ${formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)}`}>
{formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)}
</div>
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
<span> {formatPercent(Number(wf.food_cost) / Number(wf.received) * 100)} 32%</span>
<span> {formatPercent(Number(wf.total_expense) / Number(wf.received) * 100)} 40%</span>
<span> {formatPercent(Number(wf.net_profit) / Number(wf.received) * 100)} 10%</span>
<span> {formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)} 10%</span>
</div>
</div>
)}
</CollapsibleSection>
)}
{/* ②b 利润机会池 */}
{profitOpp.length > 0 && (
<CollapsibleSection title="利润机会池" subtitle={`理论月度机会合计 ${formatCurrency(totalOpportunity)} · 30天承诺值 ≥ 150万元 · 点击展开详情`}>
<div className="space-y-2">
{[...profitOpp].sort((a: any, b: any) => Number(b.opportunity || 0) - Number(a.opportunity || 0)).map((o: any, i: number) => {
const opp = Number(o.opportunity || 0)
const pct = totalOpportunity > 0 ? (opp / totalOpportunity * 100) : 0
const confColor = o.confidence === '中高' ? 'text-green-600 bg-green-50' : o.confidence === '中' ? 'text-blue-600 bg-blue-50' : 'text-yellow-600 bg-yellow-50'
return (
<ProfitOppItem key={i} index={i} o={o} opp={opp} pct={pct} confColor={confColor} />
)
})}
</div>
<div className="mt-3 rounded-lg border border-purple-200 bg-purple-50/30 p-3">
<div className="flex items-center gap-2">
<Target className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium">30</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{formatCurrency(totalOpportunity)} <span className="font-bold text-purple-600"> 150</span>
7.29% 10%
</p>
</div>
</CollapsibleSection>
)}
{/* ③ 风险态势 */}
<div className="grid gap-4 lg:grid-cols-3">
<div className={`rounded-lg border p-4 ${riskSummary['红色'] > 15 ? 'border-red-200 bg-red-50/40' : 'border-yellow-200 bg-yellow-50/40'}`}>
@@ -358,7 +442,7 @@ export function BossPage() {
{/* ⑤b 门店利润排名 */}
{profitRanking.length > 0 && (
<CollapsibleSection title="门店利润排名" subtitle="TOP5 最赚钱 · BOTTOM5 亏损最多 · 公式:实收 - 食材成本 - 经营费用">
<CollapsibleSection title="门店利润排名" subtitle="TOP5 贡献最高 · BOTTOM5 亏损最多 · 公式:实收 - 食材成本 - 经营费用(仅费用已匹配门店)">
<div className="grid gap-4 lg:grid-cols-2">
{/* TOP5 */}
<div>
@@ -373,9 +457,9 @@ export function BossPage() {
<span className="text-lg font-bold text-green-600">{i + 1}</span>
<div className="flex-1">
<p className="text-sm font-medium">{s.store_name}</p>
<p className="text-xs text-muted-foreground"> {formatCurrency(s.received)} · {formatPercent(s.net_margin_pct)}</p>
<p className="text-xs text-muted-foreground"> {formatCurrency(s.received)} · {formatPercent(s.contribution_margin_pct)}</p>
</div>
<span className="text-lg font-bold text-green-700">{formatCurrency(s.net_profit)}</span>
<span className="text-lg font-bold text-green-700">{formatCurrency(s.store_contribution)}</span>
</div>
))}
</div>
@@ -384,7 +468,7 @@ export function BossPage() {
<div>
<p className="mb-2 text-sm font-bold text-red-600"> BOTTOM5</p>
<div className="space-y-2">
{profitRanking.filter((s: any) => Number(s.net_profit) < 0).slice(-5).reverse().map((s: any, i: number) => (
{profitRanking.filter((s: any) => Number(s.store_contribution) < 0).slice(-5).reverse().map((s: any, i: number) => (
<div
key={s.store_code}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-red-200 bg-red-50/30 p-3 hover:bg-red-50/60"
@@ -393,9 +477,9 @@ export function BossPage() {
<span className="text-lg font-bold text-red-600">{i + 1}</span>
<div className="flex-1">
<p className="text-sm font-medium">{s.store_name}</p>
<p className="text-xs text-muted-foreground"> {formatCurrency(s.received)} · {formatPercent(s.net_margin_pct)}</p>
<p className="text-xs text-muted-foreground"> {formatCurrency(s.received)} · {formatPercent(s.contribution_margin_pct)}</p>
</div>
<span className="text-lg font-bold text-red-700">{formatCurrency(s.net_profit)}</span>
<span className="text-lg font-bold text-red-700">{formatCurrency(s.store_contribution)}</span>
</div>
))}
</div>
+394
View File
@@ -0,0 +1,394 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, ScatterChart, Scatter } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { ChefHat, TrendingDown, TrendingUp, AlertTriangle, Package, Factory } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
const CATEGORY_COLORS = ['#1677ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2', '#eb2f96', '#fa8c16', '#a0d911', '#2f54eb']
function varianceColor(pct: number): 'good' | 'warn' | 'bad' {
if (Math.abs(pct) <= 3) return 'good'
if (Math.abs(pct) <= 5) return 'warn'
return 'bad'
}
function varianceText(pct: number): string {
if (pct > 0) return `+${pct.toFixed(2)}%`
return `${pct.toFixed(2)}%`
}
export function CentralKitchenPage() {
const [month, setMonth] = useState('2026-04')
const [productFilter, setProductFilter] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['central-kitchen-dashboard', month],
queryFn: async () => {
const res = await api.get(`/central-kitchen/dashboard?month=${month}`)
return res.data
},
})
if (isLoading) return <LoadingSpinner />
if (!data) return <div className="p-4 text-muted-foreground"></div>
const { summary, reconciliation, products, categoryCost, recipeEfficiency, mfgPool, yieldAnalysis } = data
const filteredProducts = products.filter((p: any) =>
!productFilter ||
p.product_name?.includes(productFilter) ||
p.product_code?.includes(productFilter) ||
p.category_minor?.includes(productFilter)
)
// 成本对账瀑布数据
const waterfallData = [
{ name: '理论成本', value: reconciliation.theoretical_cost, type: 'base' },
{ name: '标准成本', value: reconciliation.standard_cost, type: 'base' },
{ name: '实际材料', value: reconciliation.material_actual_cost, type: 'base' },
{ name: '制造费用', value: reconciliation.allocated_manufacturing_cost, type: 'add' },
{ name: '全制造成本', value: reconciliation.full_manufacturing_cost, type: 'total' },
{ name: '入库价值', value: reconciliation.calculated_inbound_value, type: 'base' },
{ name: '制造毛利', value: reconciliation.manufacturing_margin, type: 'margin' },
]
// 品类成本堆叠数据
const categoryStackData = categoryCost.map((c: any) => ({
name: c.category_minor,
材料成本: c.material_actual_cost,
制造费用: c.allocated_mfg_cost,
}))
// 产品成本偏差散点图
const scatterData = products.map((p: any) => ({
name: p.product_name,
x: p.theoretical_cost,
y: p.material_actual_cost,
z: p.efficiency_variance_pct,
category: p.category_minor,
})).filter((d: any) => d.x > 0 && d.y > 0)
return (
<div className="space-y-4 p-4">
{/* 页面标题 */}
<div className="flex items-center gap-2">
<ChefHat className="h-6 w-6 text-blue-600" />
<h1 className="text-xl font-bold"></h1>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 核心指标卡片 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-7">
<MetricCard title="产品数" value={summary.product_count} unit="个" status="good" />
<MetricCard title="完工入库量" value={summary.total_inbound_qty} unit={products[0]?.unit || ''} format="number" />
<MetricCard title="理论材料成本" value={summary.theoretical_cost} format="currency" />
<MetricCard title="实际材料成本" value={summary.material_actual_cost} format="currency" status={varianceColor(summary.efficiency_variance_pct)} />
<MetricCard title="完整制造成本" value={summary.full_manufacturing_cost} format="currency" />
<MetricCard title="材料效率差异" value={varianceText(summary.efficiency_variance_pct)} status={varianceColor(summary.efficiency_variance_pct)} description="(实际-理论)/理论×100%" />
<MetricCard title="制造费用率" value={summary.mfg_cost_rate} unit="%" status={summary.mfg_cost_rate <= 5 ? 'good' : 'warn'} description="制造费用/实际材料成本" />
</div>
{/* 成本对账瀑布 */}
<CollapsibleSection title="成本对账瀑布" subtitle="理论→标准→实际→+制造费用→全成本→入库价值→制造毛利">
<div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
<MetricCard title="理论成本" value={reconciliation.theoretical_cost} format="currency" />
<MetricCard title="标准成本" value={reconciliation.standard_cost} format="currency" />
<MetricCard title="实际材料" value={reconciliation.material_actual_cost} format="currency" status={varianceColor(summary.efficiency_variance_pct)} />
<MetricCard title="制造费用池" value={reconciliation.manufacturing_cost_pool} format="currency" />
<MetricCard title="全制造成本" value={reconciliation.full_manufacturing_cost} format="currency" />
<MetricCard title="制造毛利" value={reconciliation.manufacturing_margin} format="currency" status={reconciliation.manufacturing_margin >= 0 ? 'good' : 'bad'} description="入库价值-全制造成本" />
</div>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={waterfallData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{waterfallData.map((entry, idx) => (
<Cell key={idx} fill={
entry.type === 'total' ? '#1677ff' :
entry.type === 'add' ? '#faad14' :
entry.type === 'margin' ? (entry.value >= 0 ? '#52c41a' : '#f5222d') :
'#8ec6ff'
} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* 品类成本结构 */}
<CollapsibleSection title="品类成本结构" subtitle="按品类展示材料成本与制造费用分布">
<ResponsiveContainer width="100%" height={350}>
<BarChart data={categoryStackData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
<YAxis tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Bar dataKey="材料成本" stackId="a" fill="#1677ff" />
<Bar dataKey="制造费用" stackId="a" fill="#faad14" />
</BarChart>
</ResponsiveContainer>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
</tr>
</thead>
<tbody>
{categoryCost.map((c: any, i: number) => (
<tr key={i} className="border-b">
<td className="py-2 pr-4 font-medium">{c.category_minor}</td>
<td className="py-2 pr-4 text-right">{c.product_count}</td>
<td className="py-2 pr-4 text-right">{formatNumber(c.total_qty)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.theoretical_cost)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.material_actual_cost)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.allocated_mfg_cost)}</td>
<td className="py-2 pr-4 text-right font-medium">{formatCurrency(c.full_cost)}</td>
<td className={`py-2 pr-4 text-right font-medium ${varianceColor(c.efficiency_var_pct) === 'good' ? 'text-green-600' : varianceColor(c.efficiency_var_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>
{varianceText(c.efficiency_var_pct)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
{/* 产品成本偏差散点图 */}
<CollapsibleSection title="产品成本偏差散点图" subtitle="X=理论成本,Y=实际成本,颜色=偏差率" defaultOpen={false}>
<ResponsiveContainer width="100%" height={350}>
<ScatterChart margin={{ top: 20, right: 30, left: 20, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" dataKey="x" name="理论成本" tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 11 }} label={{ value: '理论成本', position: 'insideBottom', offset: -5, fontSize: 11 }} />
<YAxis type="number" dataKey="y" name="实际成本" tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 11 }} label={{ value: '实际成本', angle: -90, position: 'insideLeft', fontSize: 11 }} />
<Tooltip cursor={{ strokeDasharray: '3 3' }} formatter={(v: any, name: any) => name === 'x' || name === 'y' ? formatCurrency(v) : v} labelFormatter={() => ''} />
<Scatter data={scatterData}>
{scatterData.map((entry: any, idx: number) => (
<Cell key={idx} fill={
entry.z <= -5 ? '#52c41a' :
entry.z >= 5 ? '#f5222d' :
entry.z >= 0 ? '#faad14' : '#1677ff'
} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-green-500" /> (-5%)</span>
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-blue-500" /> (-5%~0%)</span>
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-yellow-500" /> (0~5%)</span>
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-red-500" /> (5%)</span>
</div>
</CollapsibleSection>
{/* 产品明细表 */}
<CollapsibleSection
title="产品成本明细"
subtitle={`${filteredProducts.length}个产品`}
headerRight={
<input
type="text"
placeholder="搜索产品名/编码/品类..."
value={productFilter}
onChange={(e) => setProductFilter(e.target.value)}
className="rounded border px-3 py-1 text-sm"
/>
}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{filteredProducts.map((p: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{p.product_code}</td>
<td className="py-1.5 pr-3 font-medium">{p.product_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{p.category_minor}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(p.inbound_quantity)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.theoretical_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.standard_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.material_actual_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.allocated_manufacturing_cost)}</td>
<td className="py-1.5 pr-3 text-right font-medium">{formatCurrency(p.full_manufacturing_cost)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(p.full_unit_cost)}</td>
<td className={`py-1.5 pr-3 text-right text-xs ${varianceColor(p.efficiency_variance_pct) === 'good' ? 'text-green-600' : varianceColor(p.efficiency_variance_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>
{varianceText(p.efficiency_variance_pct)}
</td>
<td className={`py-1.5 pr-3 text-right text-xs ${varianceColor(p.standard_variance_pct) === 'good' ? 'text-green-600' : varianceColor(p.standard_variance_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>
{varianceText(p.standard_variance_pct)}
</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.inbound_value)}</td>
<td className={`py-1.5 pr-3 text-right ${p.product_margin >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(p.product_margin)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
{/* 配方效率分析 */}
<CollapsibleSection title="配方效率分析 TOP20" subtitle="按材料效率差异绝对值排序,识别超耗最严重的配方" defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{recipeEfficiency.map((r: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 font-medium">{r.recipe_name}</td>
<td className="py-1.5 pr-3">{r.item_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{r.specification}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(r.theoretical_qty)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(r.actual_qty)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(r.theoretical_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(r.actual_amt)}</td>
<td className={`py-1.5 pr-3 text-right font-medium ${varianceColor(r.variance_pct) === 'good' ? 'text-green-600' : varianceColor(r.variance_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>
{varianceText(r.variance_pct)}
</td>
<td className="py-1.5 pr-3 text-right">{r.avg_actual_yield ? `${(r.avg_actual_yield * 100).toFixed(2)}%` : '-'}</td>
<td className="py-1.5 pr-3 text-right">{r.avg_recipe_yield ? `${(r.avg_recipe_yield * 100).toFixed(2)}%` : '-'}</td>
<td className={`py-1.5 pr-3 text-right text-xs ${r.yield_diff < -0.03 ? 'text-red-600' : r.yield_diff > 0.03 ? 'text-green-600' : ''}`}>
{r.yield_diff ? `${(r.yield_diff * 100).toFixed(2)}pct` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
{/* 出成率分析 */}
<CollapsibleSection title="出成率分析 TOP20" subtitle="按预期偏差率排序,识别产出异常产品" defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right">退</th>
<th className="pb-2 pr-3 text-right">退</th>
</tr>
</thead>
<tbody>
{yieldAnalysis.map((y: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 font-medium">{y.recipe_name}</td>
<td className="py-1.5 pr-3">{y.product_name}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(y.theoretical_inbound_qty)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(y.actual_inbound_qty)}</td>
<td className={`py-1.5 pr-3 text-right ${y.avg_achievement_rate >= 0.95 ? 'text-green-600' : y.avg_achievement_rate >= 0.9 ? 'text-yellow-600' : 'text-red-600'}`}>
{y.avg_achievement_rate ? `${(y.avg_achievement_rate * 100).toFixed(2)}%` : '-'}
</td>
<td className={`py-1.5 pr-3 text-right text-xs ${y.avg_expected_var_rate < -0.03 ? 'text-red-600' : y.avg_expected_var_rate > 0.03 ? 'text-green-600' : ''}`}>
{y.avg_expected_var_rate ? `${(y.avg_expected_var_rate * 100).toFixed(2)}%` : '-'}
</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(y.total_inbound_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(y.total_return_qty)}</td>
<td className={`py-1.5 pr-3 text-right ${y.return_rate > 1 ? 'text-red-600' : ''}`}>
{y.return_rate ? `${y.return_rate.toFixed(2)}%` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
{/* 制造费用池 */}
<CollapsibleSection title="制造费用池明细" subtitle="制造费用归集与分摊方法" defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
</tr>
</thead>
<tbody>
{mfgPool.map((m: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 font-medium">{m.cost_type}</td>
<td className="py-1.5 pr-3">{m.cost_subtype}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{m.source_type}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(m.source_amount)}</td>
<td className="py-1.5 pr-3 text-right">{m.share_pct ? `${(m.share_pct * 100).toFixed(2)}%` : '-'}</td>
<td className="py-1.5 pr-3 text-right font-medium">{formatCurrency(m.allocated_amount)}</td>
<td className="py-1.5 pr-3 text-xs">{m.allocation_method}</td>
<td className="py-1.5 pr-3 text-center">{m.include_in_rebuilt_cost ? '✅' : '❌'}</td>
<td className="py-1.5 pr-3 text-center">
{m.is_provisional && <AlertTriangle className="inline h-4 w-4 text-yellow-500" />}
</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground max-w-xs">{m.note}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 flex items-center gap-2 rounded-md bg-yellow-50 p-3 text-xs text-yellow-800">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span></span>
</div>
</CollapsibleSection>
</div>
)
}
+142 -22
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend, ScatterChart, Scatter, ZAxis, ReferenceLine, ComposedChart } from 'recharts'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend, ScatterChart, Scatter, ZAxis, ReferenceLine, ComposedChart, Area, AreaChart } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
@@ -8,8 +8,10 @@ import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MonthPicker } from '@/components/MonthPicker'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
const RISK_COLORS = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
const PAGE_SIZE = 10
@@ -17,25 +19,26 @@ const PAGE_SIZE = 10
export function DashboardPage() {
const navigate = useNavigate()
const [priorityPage, setPriorityPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const { data: overview, isLoading: odLoading } = useQuery({
queryKey: ['overview'],
queryFn: () => api.get('/overview'),
queryKey: ['overview', month],
queryFn: () => api.get('/overview', { params: { month } }),
})
const { data: daily, isLoading: dailyLoading } = useQuery({
queryKey: ['overview/daily'],
queryFn: () => api.get('/overview/daily'),
queryKey: ['overview/daily', month],
queryFn: () => api.get('/overview/daily', { params: { month } }),
})
const { data: riskData, isLoading: riskLoading } = useQuery({
queryKey: ['stores/risk'],
queryFn: () => api.get('/stores/risk'),
queryKey: ['stores/risk', month],
queryFn: () => api.get('/stores/risk', { params: { month } }),
})
const { data: priorityData, isLoading: priorityLoading } = useQuery({
queryKey: ['stores/priority'],
queryFn: () => api.get('/stores/priority'),
queryKey: ['stores/priority', month],
queryFn: () => api.get('/stores/priority', { params: { month } }),
})
const { data: loopHealth, isLoading: loopLoading } = useQuery({
@@ -44,8 +47,8 @@ export function DashboardPage() {
})
const { data: quadrantData } = useQuery({
queryKey: ['stores/quadrant'],
queryFn: () => api.get('/stores/quadrant'),
queryKey: ['stores/quadrant', month],
queryFn: () => api.get('/stores/quadrant', { params: { month } }),
})
const { data: platformData } = useQuery({
@@ -59,8 +62,28 @@ export function DashboardPage() {
})
const { data: expenseData } = useQuery({
queryKey: ['store-expense/overview'],
queryFn: () => api.get('/store-expense/overview'),
queryKey: ['store-expense/overview', month],
queryFn: () => api.get('/store-expense/overview', { params: { month } }),
})
const { data: yoyData } = useQuery({
queryKey: ['overview/yoy', month],
queryFn: () => api.get('/overview/yoy', { params: { month } }),
})
const { data: momData } = useQuery({
queryKey: ['overview/mom', month],
queryFn: () => api.get('/overview/mom', { params: { month } }),
})
const { data: trendData } = useQuery({
queryKey: ['overview/trend', month],
queryFn: () => api.get('/overview/trend', { params: { month, months: 12 } }),
})
const { data: profitTrendData } = useQuery({
queryKey: ['overview/profit-trend', month],
queryFn: () => api.get('/overview/profit-trend', { params: { month, months: 6 } }),
})
const pageLoading = odLoading || dailyLoading || riskLoading || priorityLoading || loopLoading
@@ -75,6 +98,26 @@ export function DashboardPage() {
const platformRows = (platformData as any)?.data || []
const alerts = ((alertsData as any)?.data?.alerts || []) as any[]
const alertSummary = (alertsData as any)?.data
const yoy = (yoyData as any)?.data
const mom = (momData as any)?.data
const trendRows = ((trendData as any)?.data || []).map((r: any) => ({
...r,
month: r.month?.substring(0, 10) || r.month,
}))
const profitTrendRows = ((profitTrendData as any)?.data || []).map((r: any) => ({
...r,
report_month: r.report_month?.substring(0, 10) || r.report_month,
}))
const yoyData_ = yoy?.yoy
const momData_ = mom?.mom
const TrendIcon = ({ val }: { val: number | null }) => {
if (val === null || val === undefined) return <Minus size={14} className="text-muted-foreground" />
if (val > 0) return <TrendingUp size={14} className="text-green-600" />
if (val < 0) return <TrendingDown size={14} className="text-red-600" />
return <Minus size={14} className="text-muted-foreground" />
}
const fmtChange = (v: number | null) => v === null ? '-' : `${v > 0 ? '+' : ''}${v.toFixed(2)}%`
const QUADRANT_COLORS: Record<string, string> = {
'明星门店': '#22c55e', '稳健经营': '#3b82f6', '规模承压': '#eab308', '重点改善': '#ef4444', '高效潜力': '#a855f7',
@@ -159,13 +202,16 @@ export function DashboardPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · 20264</p>
<p className="mt-0.5 text-xs text-muted-foreground"> · {month}</p>
</div>
<div className="flex gap-2">
<span className="rounded-md bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">{totalStores} </span>
<span className="rounded-md bg-red-50 px-3 py-1 text-xs font-medium text-red-700"> {riskSummary['红色'] || 0}</span>
<span className="rounded-md bg-yellow-50 px-3 py-1 text-xs font-medium text-yellow-700"> {riskSummary['黄色'] || 0}</span>
<span className="rounded-md bg-green-50 px-3 py-1 text-xs font-medium text-green-700">绿 {riskSummary['绿色'] || 0}</span>
<div className="flex items-center gap-3">
<MonthPicker month={month} onChange={setMonth} />
<div className="flex gap-2">
<span className="rounded-md bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">{totalStores} </span>
<span className="rounded-md bg-red-50 px-3 py-1 text-xs font-medium text-red-700"> {riskSummary['色'] || 0}</span>
<span className="rounded-md bg-yellow-50 px-3 py-1 text-xs font-medium text-yellow-700"> {riskSummary['黄色'] || 0}</span>
<span className="rounded-md bg-green-50 px-3 py-1 text-xs font-medium text-green-700">绿 {riskSummary['绿色'] || 0}</span>
</div>
</div>
</div>
@@ -188,12 +234,44 @@ export function DashboardPage() {
</div>
</CollapsibleSection>
{/* 同比/环比对比 */}
{(yoyData_ || momData_) && (
<CollapsibleSection title="同比 / 环比对比" subtitle={`${month} 与去年同期、上月对比`}>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{[
{ label: '实收同比', yoy: yoyData_?.received_change_pct, mom: momData_?.received_change_pct },
{ label: '账单数同比', yoy: yoyData_?.bill_count_change_pct, mom: momData_?.bill_count_change_pct },
{ label: '客单价同比', yoy: yoyData_?.avg_bill_value_change_pct, mom: momData_?.avg_bill_value_change_pct },
{ label: '优惠率变化', yoy: yoyData_?.discount_rate_change, mom: momData_?.discount_rate_change },
].map((item) => (
<div key={item.label} className="rounded-md border p-3">
<p className="text-xs text-muted-foreground">{item.label}</p>
<div className="mt-1 flex items-center gap-2">
<span className="text-xs text-muted-foreground"></span>
<TrendIcon val={item.yoy ?? null} />
<span className={`text-sm font-bold ${item.yoy === null ? 'text-muted-foreground' : item.yoy > 0 ? 'text-green-600' : item.yoy < 0 ? 'text-red-600' : 'text-muted-foreground'}`}>
{fmtChange(item.yoy ?? null)}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<span className="text-xs text-muted-foreground"></span>
<TrendIcon val={item.mom ?? null} />
<span className={`text-sm font-bold ${item.mom === null ? 'text-muted-foreground' : item.mom > 0 ? 'text-green-600' : item.mom < 0 ? 'text-red-600' : 'text-muted-foreground'}`}>
{fmtChange(item.mom ?? null)}
</span>
</div>
</div>
))}
</div>
</CollapsibleSection>
)}
{/* 利润与成本 */}
{ex && (
<CollapsibleSection title="利润与成本" subtitle="食材成本、经营费用、净利润等财务指标">
<CollapsibleSection title="利润与成本" subtitle="食材成本、经营费用、门店贡献利润估算等财务指标">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="理论利润" value={ex.theoretical_net_profit} format="currency" status="good" description={`净利${formatPercent(ex.theoretical_net_margin_pct)} · 公式:实收 - 理论食材成本 - 经营费用。建议值:净利率 ≥ 15% 为健康`} />
<MetricCard title="实际利润" value={ex.actual_net_profit} format="currency" status={Number(ex.actual_net_margin_pct) < 5 ? 'bad' : Number(ex.actual_net_margin_pct) < 10 ? 'warn' : 'good'} description={`净利${formatPercent(ex.actual_net_margin_pct)} · 公式:实收 - 实际食材成本 - 经营费用。盈利 ${ex.profitable_stores}家 / 亏损 ${ex.loss_stores}家。建议值:净利率 ≥ 10% 为优秀,5-10% 为合格,< 5% 需整改`} />
<MetricCard title="理论贡献利润" value={ex.theoretical_net_profit} format="currency" status="good" description={`贡献${formatPercent(ex.theoretical_net_margin_pct)} · 公式:实收 - 理论食材成本 - 经营费用(仅费用已匹配门店)。建议值:贡献率 ≥ 15% 为健康`} />
<MetricCard title="实际贡献利润" value={ex.actual_net_profit} format="currency" status={Number(ex.actual_net_margin_pct) < 5 ? 'bad' : Number(ex.actual_net_margin_pct) < 10 ? 'warn' : 'good'} description={`贡献${formatPercent(ex.actual_net_margin_pct)} · 公式:实收 - 实际食材成本 - 经营费用(仅费用已匹配门店)。盈利 ${ex.profitable_stores}家 / 亏损 ${ex.loss_stores}家。建议值:贡献率 ≥ 10% 为优秀,5-10% 为合格,< 5% 需整改`} />
<MetricCard title="食材成本" value={ex.total_food_cost} format="currency" status={Number(ex.actual_food_cost_rate_pct) - Number(ex.theoretical_food_cost_rate_pct) > 10 ? 'bad' : Number(ex.actual_food_cost_rate_pct) - Number(ex.theoretical_food_cost_rate_pct) > 5 ? 'warn' : 'good'} description={`成本率 ${formatPercent(ex.actual_food_cost_rate_pct)} · 理论成本率 ${formatPercent(ex.theoretical_food_cost_rate_pct)} · 差异 ${formatPercent(Number(ex.actual_food_cost_rate_pct) - Number(ex.theoretical_food_cost_rate_pct))}。公式:成本率 = 食材成本 ÷ 实收 × 100%。建议值:实际成本率 ≤ 32%,与理论差异 ≤ 3% 为正常`} />
<MetricCard title="经营费用" value={ex.total_expense} format="currency" status={Number(ex.overall_expense_rate_pct) > 50 ? 'bad' : Number(ex.overall_expense_rate_pct) > 40 ? 'warn' : 'good'} description={`费用率 ${formatPercent(ex.overall_expense_rate_pct)} · 公式:费用率 = 经营费用 ÷ 实收 × 100%。人工 ${formatCurrency(ex.total_wage)} · 房租 ${formatCurrency(ex.total_rent)}。建议值:费用率 ≤ 40% 为健康,40-50% 需关注,> 50% 需整改`} />
</div>
@@ -368,6 +446,48 @@ export function DashboardPage() {
</CollapsibleSection>
</div>
{/* 月度趋势 & 利润趋势 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="月度经营趋势" subtitle="近12个月实收与账单数变化">
<ResponsiveContainer width="100%" height={250}>
<ComposedChart data={trendRows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" tickFormatter={(v) => v?.substring(5, 7) || ''} tick={{ fontSize: 10 }} />
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis yAxisId="right" orientation="right" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip
labelFormatter={(v) => v}
formatter={(v: any, n: string) => n === '实收' ? formatCurrency(v) : formatNumber(v)}
/>
<Legend wrapperStyle={{ fontSize: 10 }} />
<Bar yAxisId="left" dataKey="bill_count" name="账单数" fill="#93c5fd" barSize={20} />
<Line yAxisId="right" type="monotone" dataKey="received" name="实收" stroke="#3b82f6" strokeWidth={2} dot={{ r: 3 }} />
</ComposedChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="利润趋势" subtitle="近6个月利润瀑布变化">
<ResponsiveContainer width="100%" height={250}>
<ComposedChart data={profitTrendRows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="report_month" tickFormatter={(v) => v?.substring(5, 7) || ''} tick={{ fontSize: 10 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip
labelFormatter={(v) => v}
formatter={(v: any) => formatCurrency(v)}
/>
<Legend wrapperStyle={{ fontSize: 10 }} />
<Bar dataKey="food_cost" name="食材成本" stackId="a" fill="#f97316" />
<Bar dataKey="wage" name="人工" stackId="a" fill="#eab308" />
<Bar dataKey="rent" name="房租" stackId="a" fill="#a855f7" />
<Bar dataKey="utility" name="水电" stackId="a" fill="#06b6d4" />
<Line type="monotone" dataKey="received" name="实收" stroke="#3b82f6" strokeWidth={2} dot={{ r: 3 }} />
<Line type="monotone" dataKey="store_contribution" name="贡献利润" stroke="#22c55e" strokeWidth={2} dot={{ r: 3 }} />
</ComposedChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* P0/P1 门店列表 */}
<CollapsibleSection
title={`P0/P1 重点整改门店 (${p0p1Stores.length})`}
+62 -14
View File
@@ -3,7 +3,7 @@ import api from '@/lib/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatNumber } from '@/lib/utils'
import { formatNumber, formatCurrency } from '@/lib/utils'
export function DataQualityPage() {
const { data: qualityData, isLoading } = useQuery({
@@ -22,13 +22,26 @@ export function DataQualityPage() {
const missingStoreCode = Number(dq.missing_store_code || 0)
const zeroConsumption = Number(dq.zero_consumption || 0)
const negativeReceived = Number(dq.negative_received || 0)
const zeroReceived = Number(dq.zero_received || 0)
const discountGtConsumption = Number(dq.discount_gt_consumption || 0)
const storeCount = Number(dq.store_count || 0)
const totalDishRecords = Number(dq.total_dish_records || 0)
const dishMissingStore = Number(dq.dish_missing_store || 0)
const dishMissingDish = Number(dq.dish_missing_dish || 0)
const negativeConsumptionCount = Number(dq.negative_consumption_count || 0)
const negativeConsumptionAmount = Number(dq.negative_consumption_amount || 0)
const billQualityStatus = missingBillNo === 0 && missingStoreCode === 0 ? '正常' : '异常'
const dishQualityStatus = dishMissingStore === 0 && dishMissingDish === 0 ? '正常' : '异常'
// 四层状态规则:完整性、准确性、一致性、时效性
const completenessStatus = missingBillNo === 0 && missingStoreCode === 0 && dishMissingStore === 0 && dishMissingDish === 0 ? '正常' : '异常'
const accuracyStatus = negativeReceived === 0 && zeroConsumption < totalBills * 0.01 && discountGtConsumption === 0 ? '正常' : '需关注'
const consistencyStatus = negativeConsumptionCount === 0 ? '正常' : '异常'
const timelinessStatus = '正常'
// 综合质量状态:任一异常即为不可直接使用
const overallStatus = completenessStatus === '正常' && accuracyStatus === '正常' && consistencyStatus === '正常' ? '正常' : '需修复'
// 利润数据置信等级
const profitConfidence = overallStatus === '正常' ? 'A:已对账' : accuracyStatus === '需关注' ? 'B:待桥接' : 'C:异常'
return (
<div className="space-y-4">
@@ -37,6 +50,29 @@ export function DataQualityPage() {
<p className="mt-0.5 text-xs text-muted-foreground"> · {dq.min_date?.substring(0, 10)} ~ {dq.max_date?.substring(0, 10)}</p>
</div>
{/* 质量状态总览 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className={`rounded-lg border p-4 shadow-sm ${completenessStatus === '正常' ? 'border-green-200 bg-green-50/30' : 'border-red-200 bg-red-50/30'}`}>
<p className="text-xs text-muted-foreground"></p>
<p className={`mt-1 text-lg font-bold ${completenessStatus === '正常' ? 'text-green-600' : 'text-red-600'}`}>{completenessStatus}</p>
<p className="mt-0.5 text-[10px] text-muted-foreground"></p>
</div>
<div className={`rounded-lg border p-4 shadow-sm ${accuracyStatus === '正常' ? 'border-green-200 bg-green-50/30' : 'border-yellow-200 bg-yellow-50/30'}`}>
<p className="text-xs text-muted-foreground"></p>
<p className={`mt-1 text-lg font-bold ${accuracyStatus === '正常' ? 'text-green-600' : 'text-yellow-600'}`}>{accuracyStatus}</p>
<p className="mt-0.5 text-[10px] text-muted-foreground"></p>
</div>
<div className={`rounded-lg border p-4 shadow-sm ${consistencyStatus === '正常' ? 'border-green-200 bg-green-50/30' : 'border-red-200 bg-red-50/30'}`}>
<p className="text-xs text-muted-foreground"></p>
<p className={`mt-1 text-lg font-bold ${consistencyStatus === '正常' ? 'text-green-600' : 'text-red-600'}`}>{consistencyStatus}</p>
<p className="mt-0.5 text-[10px] text-muted-foreground"></p>
</div>
<div className={`rounded-lg border p-4 shadow-sm ${profitConfidence.startsWith('A') ? 'border-green-200 bg-green-50/30' : profitConfidence.startsWith('B') ? 'border-yellow-200 bg-yellow-50/30' : 'border-red-200 bg-red-50/30'}`}>
<p className="text-xs text-muted-foreground"></p>
<p className={`mt-1 text-sm font-bold ${profitConfidence.startsWith('A') ? 'text-green-600' : profitConfidence.startsWith('B') ? 'text-yellow-600' : 'text-red-600'}`}>{profitConfidence}</p>
</div>
</div>
{/* 账单数据质量 */}
<CollapsibleSection title="账单数据质量" subtitle="原始账单表完整性检查">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
@@ -46,14 +82,10 @@ export function DataQualityPage() {
<MetricCard title="缺失门店编码" value={missingStoreCode} format="number" description="门店编码为空的记录数,应为0" />
</div>
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="零消费账单" value={zeroConsumption} format="number" description="消费额为0的账单数" />
<MetricCard title="负实收账单" value={negativeReceived} format="number" description="实收为负数的账单数" />
<div className="rounded-lg border bg-card p-4 shadow-sm">
<p className="text-xs text-muted-foreground"></p>
<p className={`mt-1 text-lg font-bold ${billQualityStatus === '正常' ? 'text-green-600' : 'text-red-600'}`}>
{billQualityStatus}
</p>
</div>
<MetricCard title="零消费账单" value={zeroConsumption} format="number" description="消费额为0的账单数,可能为撤单、赠送或导入问题" />
<MetricCard title="负实收账单" value={negativeReceived} format="number" description="实收为负数的账单数,需区分退款、冲销和异常录入" />
<MetricCard title="零实收账单" value={zeroReceived} format="number" description="实收为0的账单数,影响账单数、客单和优惠判断" />
<MetricCard title="优惠大于消费" value={discountGtConsumption} format="number" description="优惠额超过消费额的账单数,需检查字段定义和冲销逻辑" />
</div>
{missingBillNo > 0 && (
<div className="mt-3 rounded-md border border-yellow-200 bg-yellow-50/50 p-3">
@@ -79,10 +111,10 @@ export function DataQualityPage() {
<MetricCard title="总菜品记录数" value={totalDishRecords} format="number" description="菜品销售明细表总记录数" />
<MetricCard title="缺失门店" value={dishMissingStore} format="number" description="门店编码为空的记录数" />
<MetricCard title="缺失菜品名" value={dishMissingDish} format="number" description="菜品名称为空的记录数" />
<div className="rounded-lg border bg-card p-4 shadow-sm">
<div className={`rounded-lg border p-4 shadow-sm ${dishMissingStore === 0 && dishMissingDish === 0 ? 'border-green-200 bg-green-50/30' : 'border-red-200 bg-red-50/30'}`}>
<p className="text-xs text-muted-foreground"></p>
<p className={`mt-1 text-lg font-bold ${dishQualityStatus === '正常' ? 'text-green-600' : 'text-red-600'}`}>
{dishQualityStatus}
<p className={`mt-1 text-lg font-bold ${dishMissingStore === 0 && dishMissingDish === 0 ? 'text-green-600' : 'text-red-600'}`}>
{dishMissingStore === 0 && dishMissingDish === 0 ? '正常' : '异常'}
</p>
</div>
</div>
@@ -96,6 +128,22 @@ export function DataQualityPage() {
)}
</CollapsibleSection>
{/* 库存数据质量 */}
<CollapsibleSection title="库存数据质量" subtitle="库存倒挤成本异常检查" defaultOpen={false}>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="负耗用记录数" value={negativeConsumptionCount} format="number" description="消费金额为负数的库存记录数,可能扭曲成本和库存周转" />
<MetricCard title="负耗用金额" value={negativeConsumptionAmount} format="currency" description="负耗用记录的金额合计,需排查盘点差异或录入错误" />
</div>
{negativeConsumptionCount > 0 && (
<div className="mt-3 rounded-md border border-red-200 bg-red-50/50 p-3">
<p className="text-xs text-red-700">
<span className="font-medium"></span>
{negativeConsumptionCount} {formatCurrency(negativeConsumptionAmount)}
</p>
</div>
)}
</CollapsibleSection>
{/* 数据治理建议 */}
<CollapsibleSection title="数据治理建议" subtitle="基于管理体系的数据质量要求" defaultOpen={false}>
<div className="space-y-3 text-sm">
@@ -0,0 +1,343 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, ScatterChart, Scatter } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { Truck, AlertTriangle, PackageSearch, BarChart3 } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
function varianceStatus(pct: number): 'good' | 'warn' | 'bad' {
if (Math.abs(pct) <= 3) return 'good'
if (Math.abs(pct) <= 10) return 'warn'
return 'bad'
}
function varianceColorClass(pct: number): string {
const s = varianceStatus(pct)
return s === 'good' ? 'text-green-600' : s === 'warn' ? 'text-yellow-600' : 'text-red-600'
}
function varianceText(pct: number): string {
if (pct > 0) return `+${pct.toFixed(2)}%`
return `${pct.toFixed(2)}%`
}
export function DistributionReconciliationPage() {
const [month, setMonth] = useState('2026-04')
const [storeFilter, setStoreFilter] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['distribution-reconciliation', month],
queryFn: async () => {
const res = await api.get(`/distribution/reconciliation?month=${month}`)
return res.data
},
})
if (isLoading) return <LoadingSpinner />
if (!data) return <div className="p-4 text-muted-foreground"></div>
const { summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation } = data
const filteredStores = storeReconciliation.filter((s: any) =>
!storeFilter ||
s.store_code?.includes(storeFilter) ||
s.store_name?.includes(storeFilter)
)
// 倒挤公式瀑布数据
const waterfallData = [
{ name: '期初库存', value: summary.total_opening_amt, type: 'base' },
{ name: '配送入库', value: summary.total_dist_amt, type: 'add' },
{ name: '应耗用(倒挤)', value: summary.reverse_consumption_amt, type: 'calc' },
{ name: '实际耗用', value: summary.total_consumption_amt, type: 'actual' },
{ name: '期末库存', value: summary.total_ending_amt, type: 'base' },
{ name: '差异金额', value: summary.variance_amt, type: 'variance' },
]
// 品类对账柱状图
const categoryChartData = categoryReconciliation.slice(0, 15).map((c: any) => ({
name: c.minor_category,
配送金额: c.dist_amt,
耗用金额: c.consumption_amt,
期末库存: c.ending_amt,
}))
// 门店散点图:配送金额 vs 差异率
const storeScatterData = storeReconciliation
.filter((s: any) => s.dist_amt > 0 && s.consumption_amt > 0)
.map((s: any) => ({
name: s.store_name || s.store_code,
x: s.dist_amt,
y: s.variance_pct,
store_code: s.store_code,
}))
return (
<div className="space-y-4 p-4">
{/* 页面标题 */}
<div className="flex items-center gap-2">
<Truck className="h-6 w-6 text-blue-600" />
<h1 className="text-xl font-bold"></h1>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 公式说明 */}
<div className="rounded-lg border bg-blue-50/40 p-3 text-sm text-blue-800">
<strong></strong> = + - = - () = / × 100%
</div>
{/* 核心指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-7">
<MetricCard title="对账品项行" value={summary.total_lines} unit="行" description="配送+库存合并去重后的品项行数" />
<MetricCard title="匹配行数" value={summary.matched_lines} unit="行" status="good" description="配送和库存都有数据的行数" />
<MetricCard title="配送总额" value={summary.total_dist_amt} format="currency" />
<MetricCard title="实际耗用" value={summary.total_consumption_amt} format="currency" />
<MetricCard title="倒挤应耗用" value={summary.reverse_consumption_amt} format="currency" description="期初+配送-期末" />
<MetricCard title="差异金额" value={summary.variance_amt} format="currency" status={summary.variance_amt >= 0 ? 'warn' : 'bad'} />
<MetricCard title="差异率" value={varianceText(summary.variance_pct)} status={varianceStatus(summary.variance_pct)} />
</div>
{/* 倒挤对账瀑布 */}
<CollapsibleSection title="倒挤成本对账瀑布" subtitle="期初库存 + 配送入库 - 期末库存 = 应耗用 vs 实际耗用">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={waterfallData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{waterfallData.map((entry, idx) => (
<Cell key={idx} fill={
entry.type === 'add' ? '#52c41a' :
entry.type === 'calc' ? '#1677ff' :
entry.type === 'actual' ? '#722ed1' :
entry.type === 'variance' ? (entry.value >= 0 ? '#faad14' : '#f5222d') :
'#8ec6ff'
} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* 品类维度对账 */}
<CollapsibleSection title="品类维度对账" subtitle="按小类汇总配送金额与耗用金额">
<ResponsiveContainer width="100%" height={350}>
<BarChart data={categoryChartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
<YAxis tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Bar dataKey="配送金额" fill="#1677ff" />
<Bar dataKey="耗用金额" fill="#722ed1" />
<Bar dataKey="期末库存" fill="#faad14" />
</BarChart>
</ResponsiveContainer>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
<th className="pb-2 pr-4 text-right"></th>
</tr>
</thead>
<tbody>
{categoryReconciliation.map((c: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-2 pr-4 font-medium">{c.minor_category}</td>
<td className="py-2 pr-4 text-right">{c.item_count}</td>
<td className="py-2 pr-4 text-right">{formatNumber(c.dist_qty)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.dist_amt)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.dist_cost_excl_tax)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.consumption_amt)}</td>
<td className="py-2 pr-4 text-right">{formatCurrency(c.ending_amt)}</td>
<td className={`py-2 pr-4 text-right ${c.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
{formatCurrency(c.variance_amt)}
</td>
<td className={`py-2 pr-4 text-right font-medium ${varianceColorClass(c.variance_pct)}`}>
{varianceText(c.variance_pct)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
{/* 门店差异散点图 */}
<CollapsibleSection title="门店差异散点图" subtitle="X=配送金额,Y=差异率%" defaultOpen={false}>
<ResponsiveContainer width="100%" height={350}>
<ScatterChart margin={{ top: 20, right: 30, left: 20, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" dataKey="x" name="配送金额" tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 11 }} label={{ value: '配送金额', position: 'insideBottom', offset: -5, fontSize: 11 }} />
<YAxis type="number" dataKey="y" name="差异率%" tickFormatter={(v) => `${v.toFixed(0)}%`} tick={{ fontSize: 11 }} label={{ value: '差异率%', angle: -90, position: 'insideLeft', fontSize: 11 }} />
<Tooltip cursor={{ strokeDasharray: '3 3' }} formatter={(v: any, name: any) => name === 'x' ? formatCurrency(v) : `${v.toFixed(2)}%`} labelFormatter={() => ''} />
<Scatter data={storeScatterData}>
{storeScatterData.map((entry: any, idx: number) => (
<Cell key={idx} fill={
Math.abs(entry.y) <= 3 ? '#52c41a' :
Math.abs(entry.y) <= 10 ? '#faad14' :
'#f5222d'
} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-green-500" /> (±3%)</span>
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-yellow-500" /> (3~10%)</span>
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-red-500" /> (&gt;10%)</span>
</div>
</CollapsibleSection>
{/* 门店对账明细 */}
<CollapsibleSection
title="门店对账明细"
subtitle={`${filteredStores.length}家门店`}
headerRight={
<input
type="text"
placeholder="搜索门店编码/名称..."
value={storeFilter}
onChange={(e) => setStoreFilter(e.target.value)}
className="rounded border px-3 py-1 text-sm"
/>
}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{filteredStores.map((s: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{s.store_code}</td>
<td className="py-1.5 pr-3 font-medium">{s.store_name || '-'}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.dist_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.dist_cost_excl_tax)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.opening_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.consumption_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.ending_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.reverse_consumption_amt)}</td>
<td className={`py-1.5 pr-3 text-right ${s.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
{formatCurrency(s.variance_amt)}
</td>
<td className={`py-1.5 pr-3 text-right font-medium ${varianceColorClass(s.variance_pct)}`}>
{varianceText(s.variance_pct)}
</td>
<td className={`py-1.5 pr-3 text-right ${s.neg_inventory_count > 0 ? 'text-red-600 font-medium' : ''}`}>
{s.neg_inventory_count > 0 ? s.neg_inventory_count : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
{/* Top差异品项 */}
<CollapsibleSection title="Top30差异品项" subtitle="按差异金额绝对值排序" defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{topVariances.map((v: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{v.store_code}</td>
<td className="py-1.5 pr-3 text-xs">{v.item_code}</td>
<td className="py-1.5 pr-3 font-medium">{v.item_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{v.minor_category}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(v.dist_qty)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.dist_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.opening_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.consumption_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.ending_amt)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.reverse_consumption_amt)}</td>
<td className={`py-1.5 pr-3 text-right font-medium ${v.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
{formatCurrency(v.variance_amt)}
</td>
<td className={`py-1.5 pr-3 text-right ${varianceColorClass(v.variance_pct)}`}>
{varianceText(v.variance_pct)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
{/* 未匹配品项 */}
<CollapsibleSection title="未匹配品项(有配送无库存耗用)" subtitle="配送系统有发货但库存系统无耗用记录" defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
<th className="pb-2 pr-3 text-right"></th>
</tr>
</thead>
<tbody>
{unmatchedItems.map((u: any, i: number) => (
<tr key={i} className="border-b hover:bg-muted/50">
<td className="py-1.5 pr-3 text-xs">{u.item_code}</td>
<td className="py-1.5 pr-3 font-medium">{u.item_name}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{u.minor_category}</td>
<td className="py-1.5 pr-3 text-right">{formatNumber(u.dist_qty)}</td>
<td className="py-1.5 pr-3 text-right">{formatCurrency(u.dist_amt)}</td>
<td className="py-1.5 pr-3 text-right">{u.store_count}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 flex items-center gap-2 rounded-md bg-yellow-50 p-3 text-xs text-yellow-800">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span></span>
</div>
</CollapsibleSection>
</div>
)
}
+2 -6
View File
@@ -7,6 +7,7 @@ import { MetricCard } from '@/components/MetricCard'
import { Pagination } from '@/components/Pagination'
import { useState, useMemo } from 'react'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
const REVIEW_COLORS = { '达标': '#22c55e', '改善中': '#eab308', '未改善': '#ef4444' }
const PAGE_SIZE = 10
@@ -84,12 +85,7 @@ export function MonthlyReviewPage() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="rounded-md border px-3 py-1.5 text-sm"
/>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* Tab bar */}
+306
View File
@@ -0,0 +1,306 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { FilterableTable } from '@/components/FilterableTable'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { TrendingUp, Factory, Truck, Package, ArrowRight } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
function rateColorClass(rate: number | null): string {
if (rate === null) return 'text-gray-400'
if (rate >= 90) return 'text-green-600'
if (rate >= 70) return 'text-yellow-600'
return 'text-red-600'
}
function rateText(rate: number | null): string {
if (rate === null) return '-'
return `${rate.toFixed(1)}%`
}
export function ProductionPlanPage() {
const [month, setMonth] = useState('2026-04')
const { data, isLoading } = useQuery({
queryKey: ['production-plan', month],
queryFn: async () => {
const res = await api.get(`/sales-driven/production-plan?month=${month}`)
return res.data
},
})
if (isLoading) return <LoadingSpinner />
if (!data) return <div className="p-4 text-muted-foreground"></div>
const { summary, skuSales, materialDemand, storeDemand, ckProductionPlan, productionCoord } = data
// BOM覆盖率饼图
const coverageData = [
{ name: '有BOM', value: summary.matched_sku_count, fill: '#52c41a' },
{ name: '无BOM', value: summary.total_dish_count - summary.matched_sku_count, fill: '#f5222d' },
]
// Top10原料需求
const topMaterialDemand = materialDemand.slice(0, 15)
const materialChartData = topMaterialDemand.map((m: any) => ({
name: m.material_name?.length > 10 ? m.material_name.slice(0, 10) + '...' : m.material_name,
需求量: m.total_demand_qty,
门店数: m.store_count,
}))
// 图表数据用Top15
const topCkPlan = ckProductionPlan.filter((p: any) => p.demand_qty > 0).slice(0, 15)
// 生产配送协同图表Top15
const topCoord = productionCoord.filter((p: any) => p.inbound_qty > 0).slice(0, 15)
const coordChartData = topCoord.map((p: any) => ({
name: p.product_name?.length > 10 ? p.product_name.slice(0, 10) + '...' : p.product_name,
完工入库: p.inbound_qty,
配送量: p.dist_qty,
门店消耗: p.consumption_qty,
}))
return (
<div className="space-y-4 p-4">
{/* 页面标题 */}
<div className="flex items-center gap-2">
<TrendingUp className="h-6 w-6 text-green-600" />
<h1 className="text-xl font-bold"></h1>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 公式说明 */}
<div className="rounded-lg border bg-green-50/40 p-3 text-sm text-green-800">
<strong></strong> SKU销量 × BOM = <strong></strong> + <strong></strong> - +
</div>
{/* 核心指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-7">
<MetricCard title="总菜品数" value={summary.total_dish_count} unit="个" />
<MetricCard title="有BOM菜品" value={summary.matched_sku_count} unit="个" status="good" />
<MetricCard title="BOM覆盖率" value={`${summary.bom_coverage_pct}%`} status={summary.bom_coverage_pct >= 50 ? 'good' : 'warn'} />
<MetricCard title="BOM原料数" value={summary.total_bom_materials} unit="种" />
<MetricCard title="匹配销售额" value={summary.matched_amt} format="currency" description="有BOM菜品的销售额" />
<MetricCard title="总销售额" value={summary.total_sales_amt} format="currency" />
<MetricCard title="CK生产计划" value={ckProductionPlan.filter((p: any) => p.demand_qty > 0).length} unit="种" status="warn" description="有销量需求的半成品数" />
</div>
{/* BOM覆盖率 + Top原料需求 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<CollapsibleSection title="BOM覆盖率" subtitle="有BOM的菜品占比">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={coverageData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={(entry: any) => `${entry.name}: ${entry.value}`}>
{coverageData.map((entry, idx) => (
<Cell key={idx} fill={entry.fill} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
<div className="lg:col-span-2">
<CollapsibleSection title="Top15原料需求" subtitle="按销量×BOM展开的原料需求量">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={materialChartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 9 }} angle={-25} textAnchor="end" height={70} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: any) => formatNumber(v)} />
<Bar dataKey="需求量" fill="#52c41a" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
</div>
{/* 中央厨房生产计划 */}
<CollapsibleSection title="中央厨房生产计划" subtitle="销量驱动的半成品需求 vs 实际完工入库">
<div className="mb-3 flex items-center gap-2">
<Factory className="h-4 w-4 text-purple-600" />
<span className="text-sm text-muted-foreground"> = BOM中半成品用量合计 = - </span>
</div>
<FilterableTable
data={ckProductionPlan}
filterKey="product_name"
filterLabel="全部产品"
sortOptions={[
{ key: 'demand_qty', label: '需求量' },
{ key: 'actual_inbound_qty', label: '实际入库' },
{ key: 'variance_qty', label: '差异量' },
{ key: 'actual_cost', label: '实际成本' },
{ key: 'store_count', label: '门店数' },
]}
defaultSort="demand_qty"
columns={[
{ key: 'product_code', label: '产品编码' },
{ key: 'product_name', label: '产品名称' },
{ key: 'unit', label: '单位', align: 'right' },
{ key: 'demand_qty', label: '需求量', align: 'right', render: (r: any) => formatNumber(r.demand_qty) },
{ key: 'actual_inbound_qty', label: '实际入库', align: 'right', render: (r: any) => formatNumber(r.actual_inbound_qty) },
{ key: 'variance_qty', label: '差异量', align: 'right', render: (r: any) => (
<span className={cn(r.variance_qty > 0 ? 'text-red-600' : 'text-green-600')}>{formatNumber(r.variance_qty)}</span>
) },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (r: any) => (
<span className={cn('font-medium', r.variance_pct === null ? 'text-gray-400' : Math.abs(r.variance_pct) <= 10 ? 'text-green-600' : 'text-red-600')}>
{r.variance_pct === null ? '-' : `${r.variance_pct > 0 ? '+' : ''}${r.variance_pct.toFixed(1)}%`}
</span>
) },
{ key: 'store_count', label: '涉及门店', align: 'right' },
{ key: 'actual_cost', label: '实际成本', align: 'right', render: (r: any) => formatCurrency(r.actual_cost) },
]}
/>
</CollapsibleSection>
{/* 生产与配送协同 */}
<CollapsibleSection title="生产与配送协同" subtitle="完工入库 → 配送 → 门店消耗 → 期末库存">
<div className="mb-3 flex items-center gap-2">
<Truck className="h-4 w-4 text-blue-600" />
<span className="text-sm text-muted-foreground"> = / = / = /</span>
</div>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={coordChartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 9 }} angle={-25} textAnchor="end" height={70} />
<YAxis tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: any) => formatNumber(v)} />
<Bar dataKey="完工入库" fill="#1677ff" />
<Bar dataKey="配送量" fill="#52c41a" />
<Bar dataKey="门店消耗" fill="#faad14" />
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<FilterableTable
data={productionCoord}
filterKey="product_name"
filterLabel="全部产品"
sortOptions={[
{ key: 'inbound_qty', label: '完工入库' },
{ key: 'dist_qty', label: '配送量' },
{ key: 'consumption_qty', label: '门店消耗' },
{ key: 'ending_qty', label: '期末库存' },
{ key: 'actual_cost', label: '实际成本' },
]}
defaultSort="actual_cost"
columns={[
{ key: 'product_code', label: '产品编码' },
{ key: 'product_name', label: '产品名称' },
{ key: 'inbound_qty', label: '完工入库', align: 'right', render: (r: any) => formatNumber(r.inbound_qty) },
{ key: 'dist_qty', label: '配送量', align: 'right', render: (r: any) => formatNumber(r.dist_qty) },
{ key: 'consumption_qty', label: '门店消耗', align: 'right', render: (r: any) => formatNumber(r.consumption_qty) },
{ key: 'ending_qty', label: '期末库存', align: 'right', render: (r: any) => formatNumber(r.ending_qty) },
{ key: 'completion_distribution_rate', label: '完工配送率', align: 'right', render: (r: any) => (
<span className={rateColorClass(r.completion_distribution_rate)}>{rateText(r.completion_distribution_rate)}</span>
) },
{ key: 'distribution_consumption_rate', label: '配送消耗率', align: 'right', render: (r: any) => (
<span className={rateColorClass(r.distribution_consumption_rate)}>{rateText(r.distribution_consumption_rate)}</span>
) },
{ key: 'inventory_accumulation_rate', label: '库存积压率', align: 'right', render: (r: any) => (
<span className={r.inventory_accumulation_rate === null ? 'text-gray-400' : r.inventory_accumulation_rate > 30 ? 'text-red-600' : 'text-green-600'}>
{rateText(r.inventory_accumulation_rate)}
</span>
) },
]}
/>
</div>
</CollapsibleSection>
{/* 门店要货建议 */}
<CollapsibleSection title="门店要货建议" subtitle={`${storeDemand.length}家门店的原料需求与库存对比`} defaultOpen={false}>
<FilterableTable
data={storeDemand}
filterKey="store_name"
filterLabel="全部门店"
sortOptions={[
{ key: 'total_amt', label: '销售额' },
{ key: 'total_qty', label: '销量' },
{ key: 'total_material_demand_qty', label: '原料需求量' },
{ key: 'ending_inventory_amt', label: '期末库存额' },
{ key: 'suggested_order_qty', label: '建议要货量' },
{ key: 'sku_count', label: '菜品数' },
]}
defaultSort="total_amt"
columns={[
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称' },
{ key: 'sku_count', label: '有BOM菜品数', align: 'right' },
{ key: 'total_qty', label: '销量', align: 'right', render: (r: any) => formatNumber(r.total_qty) },
{ key: 'total_amt', label: '销售额', align: 'right', render: (r: any) => formatCurrency(r.total_amt) },
{ key: 'total_material_demand_qty', label: '原料需求量', align: 'right', render: (r: any) => formatNumber(r.total_material_demand_qty) },
{ key: 'ending_inventory_qty', label: '期末库存量', align: 'right', render: (r: any) => formatNumber(r.ending_inventory_qty) },
{ key: 'ending_inventory_amt', label: '期末库存额', align: 'right', render: (r: any) => formatCurrency(r.ending_inventory_amt) },
{ key: 'suggested_order_qty', label: '建议要货量', align: 'right', render: (r: any) => (
<span className={cn('font-medium', r.suggested_order_qty > 0 ? 'text-blue-600' : 'text-green-600')}>{formatNumber(r.suggested_order_qty)}</span>
) },
]}
/>
</CollapsibleSection>
{/* 原料需求明细 */}
<CollapsibleSection title="原料需求明细" subtitle={`${materialDemand.length}种原料的BOM展开需求`} defaultOpen={false}>
<FilterableTable
data={materialDemand}
filterKey="material_name"
filterLabel="全部原料"
sortOptions={[
{ key: 'total_demand_qty', label: '需求量' },
{ key: 'total_demand_amt', label: '需求金额' },
{ key: 'store_count', label: '门店数' },
{ key: 'avg_store_demand', label: '门店均需求' },
]}
defaultSort="total_demand_amt"
columns={[
{ key: 'material_code', label: '原料编码' },
{ key: 'material_name', label: '原料名称' },
{ key: 'unit', label: '单位', align: 'right' },
{ key: 'total_demand_qty', label: '总需求量', align: 'right', render: (r: any) => formatNumber(r.total_demand_qty) },
{ key: 'total_demand_amt', label: '总需求金额', align: 'right', render: (r: any) => formatCurrency(r.total_demand_amt) },
{ key: 'store_count', label: '涉及门店数', align: 'right' },
{ key: 'avg_store_demand', label: '门店均需求', align: 'right', render: (r: any) => formatNumber(r.avg_store_demand) },
]}
/>
</CollapsibleSection>
{/* SKU销量Top50 */}
<CollapsibleSection title="SKU销量Top50" subtitle="含BOM匹配状态" defaultOpen={false}>
<FilterableTable
data={skuSales}
filterKey="dish_name"
filterLabel="全部菜品"
sortOptions={[
{ key: 'amt', label: '销售额' },
{ key: 'qty', label: '销量' },
{ key: 'store_count', label: '门店数' },
{ key: 'avg_unit_price', label: '均价' },
{ key: 'material_count', label: '材料数' },
]}
defaultSort="amt"
statusFilterKey="has_bom"
statusFilterLabel="全部状态"
statusOptions={[
{ value: 'true', label: '有BOM' },
{ value: 'false', label: '无BOM' },
]}
columns={[
{ key: 'dish_name', label: '菜品名称' },
{ key: 'sku_code', label: 'SKU编码', render: (r: any) => r.sku_code || '-' },
{ key: 'qty', label: '销量', align: 'right', render: (r: any) => formatNumber(r.qty) },
{ key: 'amt', label: '销售额', align: 'right', render: (r: any) => formatCurrency(r.amt) },
{ key: 'store_count', label: '门店数', align: 'right' },
{ key: 'avg_unit_price', label: '均价', align: 'right', render: (r: any) => formatCurrency(r.avg_unit_price) },
{ key: 'has_bom', label: '有BOM', align: 'center', render: (r: any) => (
r.has_bom ? <span className="text-green-600"></span> : <span className="text-red-500"></span>
) },
{ key: 'material_count', label: '材料数', align: 'right', render: (r: any) => r.material_count || '-' },
]}
/>
</CollapsibleSection>
</div>
)
}
+7 -2
View File
@@ -7,6 +7,7 @@ import { MetricCard } from '@/components/MetricCard'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent, riskColor } from '@/lib/utils'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { MonthPicker } from '@/components/MonthPicker'
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts'
import { useState, useMemo } from 'react'
@@ -16,6 +17,7 @@ export function RegionalPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [riskFilter, setRiskFilter] = useState('')
const [month, setMonth] = useState('2026-05')
const [riskPage, setRiskPage] = useState(1)
const [checkPage, setCheckPage] = useState(1)
const [benchPage, setBenchPage] = useState(1)
@@ -29,7 +31,7 @@ export function RegionalPage() {
const { data: weeklyData, isLoading: weeklyLoading } = useQuery({
queryKey: ['tasks/weekly-check'],
queryFn: () => api.get('/tasks/weekly-check', { params: { month: '2026-05' } }),
queryFn: () => api.get('/tasks/weekly-check', { params: { month } }),
})
const { data: priorityData, isLoading: priorityLoading } = useQuery({
@@ -77,7 +79,10 @@ export function RegionalPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold"></h1>
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 汇总卡片 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
+1 -1
View File
@@ -245,7 +245,7 @@ export function RevenuePage() {
{/* 门店营收排名 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="营收 TOP10" subtitle="赚钱最多的10家门店">
<CollapsibleSection title="营收 TOP10" subtitle="实收最高的10家门店">
<ResponsiveContainer width="100%" height={320}>
<BarChart data={top10} layout="vertical" margin={{ left: 70 }}>
<CartesianGrid strokeDasharray="3 3" />
+4 -1
View File
@@ -10,6 +10,7 @@ import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { ArrowLeft } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
import { useState } from 'react'
type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'tasks'
@@ -29,6 +30,7 @@ export function StoreDetailPage() {
const navigate = useNavigate()
const [tab, setTab] = useState<DetailTab>('overview')
const [anomalyPage, setAnomalyPage] = useState(1)
const [month, setMonth] = useState('2026-05')
const { data: storeData } = useQuery({
queryKey: ['store', code],
@@ -42,7 +44,7 @@ export function StoreDetailPage() {
const { data: tasksData } = useQuery({
queryKey: ['store', code, 'tasks'],
queryFn: () => api.get('/tasks', { params: { store_code: code, month: '2026-05', page_size: 50 } }),
queryFn: () => api.get('/tasks', { params: { store_code: code, month, page_size: 50 } }),
enabled: tab === 'tasks',
})
@@ -137,6 +139,7 @@ export function StoreDetailPage() {
<h1 className="text-xl font-bold">{sc.store_name}</h1>
{risk?.risk_level && <Badge type="risk" text={risk.risk_level} />}
{quadrant && <span className="rounded-md bg-muted px-2 py-0.5 text-xs">{quadrant}</span>}
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 核心指标 */}
+18 -13
View File
@@ -4,6 +4,7 @@ import { Badge } from '@/components/Badge'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { MonthPicker } from '@/components/MonthPicker'
import { useState } from 'react'
export function StorePage() {
@@ -11,6 +12,7 @@ export function StorePage() {
const [executeText, setExecuteText] = useState('')
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
const [storeCode, setStoreCode] = useState('1111')
const [month, setMonth] = useState('2026-05')
const { data: storesData, isLoading: storesLoading } = useQuery({
queryKey: ['stores-list'],
@@ -30,12 +32,12 @@ export function StorePage() {
const { data: tasksData, isLoading: tasksLoading } = useQuery({
queryKey: ['store', storeCode, 'tasks'],
queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month: '2026-05', page_size: 50 } }),
queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month, page_size: 50 } }),
})
const { data: dailyData, isLoading: dailyLoading } = useQuery({
queryKey: ['store', storeCode, 'daily'],
queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month: '2026-04' } }),
queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month } }),
})
const { data: followupData, isLoading: followupLoading } = useQuery({
@@ -45,12 +47,12 @@ export function StorePage() {
const { data: skuData } = useQuery({
queryKey: ['sku-attach', storeCode],
queryFn: () => api.get('/sku/attach'),
queryFn: () => api.get('/sku/attach', { params: { month } }),
})
const { data: abcData } = useQuery({
queryKey: ['sku-abc'],
queryFn: () => api.get('/sku/abc'),
queryFn: () => api.get('/sku/abc', { params: { month } }),
})
const card = (cardData as any)?.data
@@ -109,15 +111,18 @@ export function StorePage() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<select
value={storeCode}
onChange={(e) => setStoreCode(e.target.value)}
className="rounded-md border px-3 py-1.5 text-sm"
>
{stores.map((s: any) => (
<option key={s.code} value={s.code}>{s.name}</option>
))}
</select>
<div className="flex items-center gap-3">
<MonthPicker month={month} onChange={setMonth} />
<select
value={storeCode}
onChange={(e) => setStoreCode(e.target.value)}
className="rounded-md border px-3 py-1.5 text-sm"
>
{stores.map((s: any) => (
<option key={s.code} value={s.code}>{s.name}</option>
))}
</select>
</div>
</div>
{/* 门店概况 */}
+5 -7
View File
@@ -5,6 +5,7 @@ import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { useState, useMemo } from 'react'
const PAGE_SIZE = 5
@@ -46,17 +47,14 @@ export function TasksPage() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<span className="text-sm text-muted-foreground"> {total} </span>
<div className="flex items-center gap-3">
<MonthPicker month={month} onChange={setMonth} />
<span className="text-sm text-muted-foreground"> {total} </span>
</div>
</div>
{/* 筛选器 */}
<div className="flex flex-wrap gap-2">
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="rounded-md border px-3 py-1.5 text-sm"
/>
<select value={priority} onChange={(e) => setPriority(e.target.value)} className="rounded-md border px-3 py-1.5 text-sm">
<option value=""></option>
<option value="P0-修复数据口径">P0-</option>