风险与内控管理优化: 异常账单服务端分页/筛选/收银员搜索/合计, 收银员TOP15按异常账单数排序, 双击跳转明细, MonthPicker靠右, 侧边栏底部间距

This commit is contained in:
freedakgmail
2026-08-01 21:28:15 +08:00
parent ac8e39e2f9
commit d724a565f5
60 changed files with 3154 additions and 2561 deletions
+3 -2
View File
@@ -6,17 +6,18 @@ interface CollapsibleSectionProps {
subtitle?: string
defaultOpen?: boolean
headerRight?: ReactNode
onToggle?: (open: boolean) => void
children: ReactNode
}
export function CollapsibleSection({ title, subtitle, defaultOpen = true, headerRight, children }: CollapsibleSectionProps) {
export function CollapsibleSection({ title, subtitle, defaultOpen = true, headerRight, onToggle, children }: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="rounded-lg border bg-card">
<button
className="flex w-full items-center justify-between p-4 text-left"
onClick={() => setOpen(!open)}
onClick={() => { const next = !open; setOpen(next); onToggle?.(next) }}
>
<div className="flex items-center gap-2">
<span className={cn('text-sm font-bold transition-transform', open ? 'rotate-90' : 'rotate-0')}></span>
+4 -2
View File
@@ -5,11 +5,12 @@ interface DataTableProps {
columns: { key: string; label: string; align?: 'left' | 'right' | 'center'; render?: (row: any) => ReactNode }[]
data: any[]
onRowClick?: (row: any) => void
onRowDoubleClick?: (row: any) => void
className?: string
rowClassName?: (row: any) => string
}
export function DataTable({ columns, data, onRowClick, className, rowClassName }: DataTableProps) {
export function DataTable({ columns, data, onRowClick, onRowDoubleClick, className, rowClassName }: DataTableProps) {
return (
<div className={cn('overflow-x-auto rounded-lg border', className)}>
<table className="w-full text-sm">
@@ -42,9 +43,10 @@ export function DataTable({ columns, data, onRowClick, className, rowClassName }
<tr
key={i}
onClick={() => onRowClick?.(row)}
onDoubleClick={() => onRowDoubleClick?.(row)}
className={cn(
'border-t hover:bg-muted/30',
onRowClick && 'cursor-pointer',
(onRowClick || onRowDoubleClick) && 'cursor-pointer',
rowClassName?.(row)
)}
>
+110 -19
View File
@@ -3,12 +3,13 @@ import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import type { ReactNode } from 'react'
const PAGE_SIZE = 20
const DEFAULT_PAGE_SIZE = 20
interface FilterableTableProps {
data: any[]
columns: { key: string; label: string; align?: 'left' | 'right' | 'center'; render?: (row: any) => ReactNode }[]
onRowClick?: (row: any) => void
onRowDoubleClick?: (row: any) => void
filterKey?: string
filterLabel?: string
sortOptions: { key: string; label: string }[]
@@ -17,67 +18,157 @@ interface FilterableTableProps {
statusFilterKey?: string
statusFilterLabel?: string
statusOptions?: { value: string; label: string }[]
statusFilterValue?: string
onStatusFilterChange?: (value: string) => void
pageSize?: number
serverSide?: boolean
total?: number
page?: number
onPageChange?: (page: number) => void
sort?: string
order?: 'asc' | 'desc'
onSortChange?: (sort: string) => void
onOrderChange?: (order: 'asc' | 'desc') => void
filter?: string
onFilterChange?: (filter: string) => void
filterButtons?: { key: string; label: string }[]
filterOptions?: string[]
searchKeys?: string[]
searchPlaceholder?: string
serverSearchValue?: string
onServerSearchChange?: (value: string) => void
}
export function FilterableTable({
data, columns, onRowClick,
data, columns, onRowClick, onRowDoubleClick,
filterKey, filterLabel,
sortOptions, defaultSort, defaultOrder = 'desc',
statusFilterKey, statusFilterLabel, statusOptions,
statusFilterKey, statusFilterLabel, statusOptions, statusFilterValue, onStatusFilterChange,
pageSize = DEFAULT_PAGE_SIZE,
serverSide = false,
total: serverTotal,
page: serverPage,
onPageChange,
sort: serverSort,
order: serverOrder,
onSortChange,
onOrderChange,
filter: serverFilter,
onFilterChange,
filterButtons,
filterOptions,
searchKeys,
searchPlaceholder,
serverSearchValue,
onServerSearchChange,
}: FilterableTableProps) {
const [page, setPage] = useState(1)
const [filter, setFilter] = useState('')
const [sort, setSort] = useState(defaultSort || sortOptions[0]?.key || '')
const [order, setOrder] = useState<'asc' | 'desc'>(defaultOrder)
const [localPage, setLocalPage] = useState(1)
const [localFilter, setLocalFilter] = useState('')
const [localSort, setLocalSort] = useState(defaultSort || sortOptions[0]?.key || '')
const [localOrder, setLocalOrder] = useState<'asc' | 'desc'>(defaultOrder)
const [statusFilter, setStatusFilter] = useState('')
const currentStatusFilter = serverSide ? (statusFilterValue ?? '') : statusFilter
const [searchText, setSearchText] = useState('')
const page = serverSide ? (serverPage ?? 1) : localPage
const sort = serverSide ? (serverSort ?? localSort) : localSort
const order = serverSide ? (serverOrder ?? localOrder) : localOrder
const filter = serverSide ? (serverFilter ?? '') : localFilter
const setPage = (p: number) => {
if (serverSide) onPageChange?.(p)
else setLocalPage(p)
}
const setSortVal = (s: string) => {
if (serverSide) onSortChange?.(s)
else setLocalSort(s)
setPage(1)
}
const setOrderVal = (o: 'asc' | 'desc') => {
if (serverSide) onOrderChange?.(o)
else setLocalOrder(o)
setPage(1)
}
const setFilterVal = (f: string) => {
if (serverSide) onFilterChange?.(f)
else setLocalFilter(f)
setPage(1)
}
const filterValues = useMemo(() => {
if (filterOptions) return filterOptions
if (!filterKey) return []
return [...new Set(data.map((r: any) => r[filterKey]).filter(Boolean))].sort() as string[]
}, [data, filterKey])
}, [data, filterKey, filterOptions])
const filtered = useMemo(() => {
if (serverSide) return data
let r = data
if (filter && filterKey) r = r.filter((row: any) => row[filterKey] === filter)
if (statusFilter && statusFilterKey) r = r.filter((row: any) => String(row[statusFilterKey]) === statusFilter)
if (currentStatusFilter && statusFilterKey) r = r.filter((row: any) => String(row[statusFilterKey]) === currentStatusFilter)
if (searchText && searchKeys) r = r.filter((row: any) => searchKeys.some(k => String(row[k] ?? '').toLowerCase().includes(searchText.toLowerCase())))
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)
return order === 'desc' ? bv - av : av - bv
})
}, [data, filter, filterKey, sort, order, statusFilter, statusFilterKey])
}, [data, filter, filterKey, sort, order, currentStatusFilter, statusFilterKey, serverSide, searchText, searchKeys])
const total = filtered.length
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const total = serverSide ? (serverTotal ?? 0) : filtered.length
const paged = serverSide ? data : filtered.slice((page - 1) * pageSize, page * pageSize)
return (
<>
<div className="mb-3 flex flex-wrap gap-2">
{filterKey && (
<select value={filter} onChange={(e) => { setFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
{searchKeys && !serverSide && (
<input
type="text"
placeholder={searchPlaceholder || '搜索...'}
value={searchText}
onChange={(e) => { setSearchText(e.target.value); setPage(1) }}
className="rounded border px-3 py-1 text-sm"
/>
)}
{serverSide && onServerSearchChange && (
<input
type="text"
placeholder={searchPlaceholder || '搜索...'}
value={serverSearchValue ?? ''}
onChange={(e) => { onServerSearchChange(e.target.value); setPage(1) }}
className="rounded border px-3 py-1 text-sm"
/>
)}
{filterButtons ? (
filterButtons.map(f => (
<button key={f.key} onClick={() => setFilterVal(f.key)} className={`rounded px-3 py-1 text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
{f.label}
</button>
))
) : filterKey && (
<select value={filter} onChange={(e) => setFilterVal(e.target.value)} className="rounded border px-2 py-1 text-sm">
<option value="">{filterLabel || '全部'}</option>
{filterValues.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
)}
{statusFilterKey && statusOptions && (
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
<select value={currentStatusFilter} onChange={(e) => { if (serverSide && onStatusFilterChange) { onStatusFilterChange(e.target.value) } else { setStatusFilter(e.target.value) }; setPage(1) }} className="rounded border px-2 py-1 text-sm">
<option value="">{statusFilterLabel || '全部状态'}</option>
{statusOptions.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
)}
<span className="mx-1 text-muted-foreground">|</span>
{sortOptions.map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`rounded px-3 py-1 text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
<button key={s.key} onClick={() => setSortVal(s.key)} className={`rounded px-3 py-1 text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
{s.label}
</button>
))}
<span className="mx-1 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="rounded bg-muted px-3 py-1 text-xs">
<button onClick={() => setOrderVal(order === 'asc' ? 'desc' : 'asc')} className="rounded bg-muted px-3 py-1 text-xs">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable columns={columns} data={paged} onRowClick={onRowClick} />
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
<DataTable columns={columns} data={paged} onRowClick={onRowClick} onRowDoubleClick={onRowDoubleClick} />
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
</>
)
}
+1 -1
View File
@@ -112,7 +112,7 @@ export function Layout({ children, user, onLogout }: LayoutProps) {
<div className="flex h-14 items-center border-b px-4">
<span className="text-lg font-bold"></span>
</div>
<nav className="space-y-4 overflow-y-auto p-3" style={{ maxHeight: 'calc(100vh - 3.5rem - 5rem)' }}>
<nav className="space-y-4 overflow-y-auto p-3 pb-20" style={{ maxHeight: 'calc(100vh - 3.5rem - 5rem)' }}>
{visibleGroups.map((group) => (
<div key={group.title}>
<p className="mb-1 px-3 text-xs font-medium text-muted-foreground">{group.title}</p>
@@ -2,9 +2,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent, formatNumber, priorityColor } from '@/lib/utils'
import { useState } from 'react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend } from 'recharts'
@@ -30,7 +29,7 @@ const STATUS_LABELS: Record<string, string> = {
'cancelled': '已取消',
}
export function AdjustmentTab() {
export function AdjustmentTab({ month }: { month: string }) {
const queryClient = useQueryClient()
const [subTab, setSubTab] = useState<'diagnosis' | 'adjustment' | 'verify'>('diagnosis')
const [diagPage, setDiagPage] = useState(1)
@@ -38,15 +37,15 @@ export function AdjustmentTab() {
const [diagPriority, setDiagPriority] = useState('')
const [adjStatus, setAdjStatus] = useState('')
const [diagSort, setDiagSort] = useState('priority')
const [diagOrder, setDiagOrder] = useState('asc')
const [diagOrder, setDiagOrder] = useState<'asc' | 'desc'>('asc')
const [adjSort, setAdjSort] = useState('effective_date')
const [adjOrder, setAdjOrder] = useState('desc')
const [adjOrder, setAdjOrder] = useState<'asc' | 'desc'>('desc')
const [showForm, setShowForm] = useState(false)
const [verifyId, setVerifyId] = useState<number | null>(null)
const [formData, setFormData] = useState<any>({})
const { data: diag, isLoading: ld } = useQuery({ queryKey: ['ca/diagnosis', diagPage, diagPriority, diagSort, diagOrder], queryFn: () => api.get(`/cost-analysis/diagnosis?page=${diagPage}&page_size=${PAGE_SIZE}&sort=${diagSort}&order=${diagOrder}${diagPriority ? `&priority=${diagPriority}` : ''}`) })
const { data: adj, isLoading: la } = useQuery({ queryKey: ['ca/adjustment', adjPage, adjStatus, adjSort, adjOrder], queryFn: () => api.get(`/cost-analysis/adjustment?page=${adjPage}&page_size=${PAGE_SIZE}&sort=${adjSort}&order=${adjOrder}${adjStatus ? `&status=${adjStatus}` : ''}`) })
const { data: diag, isLoading: ld } = useQuery({ queryKey: ['ca/diagnosis', month, diagPage, diagPriority, diagSort, diagOrder], queryFn: () => api.get(`/cost-analysis/diagnosis`, { params: { month, page: diagPage, page_size: PAGE_SIZE, sort: diagSort, order: diagOrder, ...(diagPriority ? { priority: diagPriority } : {}) } }) })
const { data: adj, isLoading: la } = useQuery({ queryKey: ['ca/adjustment', month, adjPage, adjStatus, adjSort, adjOrder], queryFn: () => api.get(`/cost-analysis/adjustment`, { params: { month, page: adjPage, page_size: PAGE_SIZE, sort: adjSort, order: adjOrder, ...(adjStatus ? { status: adjStatus } : {}) } }) })
const { data: verify, isLoading: lv } = useQuery({ queryKey: ['ca/adjustment/verify', verifyId], queryFn: () => api.get(`/cost-analysis/adjustment/${verifyId}/verify`), enabled: verifyId !== null })
const generateDiagnosis = useMutation({
@@ -124,19 +123,6 @@ export function AdjustmentTab() {
<option value="P2">P2</option>
<option value="P3">P3</option>
</select>
<span className="mx-2 text-muted-foreground">|</span>
{[
{ key: 'priority', label: '优先级' },
{ key: 'cost_variance_amount', label: '成本差异' },
{ key: 'sales_amount', label: '销售额' },
{ key: 'actual_margin_pct', label: '实际毛利率' },
].map(s => (
<button key={s.key} onClick={() => { setDiagSort(s.key); setDiagPage(1) }} className={`px-3 py-1 rounded text-xs ${diagSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setDiagOrder(diagOrder === 'asc' ? 'desc' : 'asc'); setDiagPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{diagOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
@@ -146,30 +132,42 @@ export function AdjustmentTab() {
</div>
{ld ? <LoadingSpinner text="加载诊断..." /> : (
<>
<Pagination page={diagPage} pageSize={PAGE_SIZE} total={diagMeta.total} onPageChange={setDiagPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_l1', label: '品类' },
{ key: 'diagnosis_type', label: '诊断类型' },
{ key: 'diagnosis_detail', label: '诊断详情' },
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
{ key: 'actual_margin_pct', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin_pct) },
{ key: 'cost_variance_amount', label: '成本差异', align: 'right', render: (r) => formatCurrency(r.cost_variance_amount) },
{ key: 'suggested_action', label: '建议动作', render: (r) => ACTION_LABELS[r.suggested_action] || r.suggested_action },
{ key: 'priority', label: '优先级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium border ${priorityColor(r.priority)}`}>{r.priority}</span>
)},
{ key: 'action', label: '操作', render: (r) => (
<button onClick={() => handleCreateAdjustment(r)} className="text-xs text-blue-600 hover:underline"></button>
)},
]}
data={diagData}
/>
</div>
</>
<FilterableTable
data={diagData}
serverSide
page={diagPage}
onPageChange={setDiagPage}
sort={diagSort}
onSortChange={setDiagSort}
order={diagOrder}
onOrderChange={setDiagOrder}
pageSize={PAGE_SIZE}
total={diagMeta.total}
sortOptions={[
{ key: 'priority', label: '优先级' },
{ key: 'cost_variance_amount', label: '成本差异' },
{ key: 'sales_amount', label: '销售额' },
{ key: 'actual_margin_pct', label: '实际毛利率' },
]}
defaultSort="priority"
defaultOrder="asc"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_l1', label: '品类' },
{ key: 'diagnosis_type', label: '诊断类型' },
{ key: 'diagnosis_detail', label: '诊断详情' },
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
{ key: 'actual_margin_pct', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin_pct) },
{ key: 'cost_variance_amount', label: '成本差异', align: 'right', render: (r) => formatCurrency(r.cost_variance_amount) },
{ key: 'suggested_action', label: '建议动作', render: (r) => ACTION_LABELS[r.suggested_action] || r.suggested_action },
{ key: 'priority', label: '优先级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium border ${priorityColor(r.priority)}`}>{r.priority}</span>
)},
{ key: 'action', label: '操作', render: (r) => (
<button onClick={() => handleCreateAdjustment(r)} className="text-xs text-blue-600 hover:underline"></button>
)},
]}
/>
)}
{showForm && (
@@ -214,46 +212,44 @@ export function AdjustmentTab() {
<option value="completed"></option>
<option value="cancelled"></option>
</select>
<span className="mx-2 text-muted-foreground">|</span>
{[
{ key: 'effective_date', label: '执行日期' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'adjustment_type', label: '调整类型' },
].map(s => (
<button key={s.key} onClick={() => { setAdjSort(s.key); setAdjPage(1) }} className={`px-3 py-1 rounded text-xs ${adjSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setAdjOrder(adjOrder === 'asc' ? 'desc' : 'asc'); setAdjPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{adjOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
{la ? <LoadingSpinner text="加载调整记录..." /> : (
<>
<Pagination page={adjPage} pageSize={PAGE_SIZE} total={adjMeta.total} onPageChange={setAdjPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'adjustment_type', label: '调整类型', render: (r) => ACTION_LABELS[r.adjustment_type] || r.adjustment_type },
{ key: 'effective_date', label: '执行日期' },
{ key: 'before_theoretical_margin', label: '调整前毛利率', align: 'right', render: (r) => formatPercent(r.before_theoretical_margin) },
{ key: 'after_target_margin', label: '目标毛利率', align: 'right', render: (r) => formatPercent(r.after_target_margin) },
{ key: 'decided_by', label: '决策人' },
{ key: 'status', label: '状态', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{STATUS_LABELS[r.status] || r.status}</span>
)},
{ key: 'actions', label: '操作', render: (r) => (
<div className="flex gap-2">
{r.status === 'planned' && <button onClick={() => updateStatus.mutate({ id: r.id, status: 'executing' })} className="text-xs text-blue-600 hover:underline"></button>}
{r.status === 'executing' && <button onClick={() => updateStatus.mutate({ id: r.id, status: 'completed' })} className="text-xs text-green-600 hover:underline"></button>}
<button onClick={() => { setVerifyId(r.id); setSubTab('verify') }} className="text-xs text-blue-600 hover:underline"></button>
</div>
)},
]}
data={adjData}
/>
</div>
</>
<FilterableTable
data={adjData}
serverSide
page={adjPage}
onPageChange={setAdjPage}
sort={adjSort}
onSortChange={setAdjSort}
order={adjOrder}
onOrderChange={setAdjOrder}
pageSize={PAGE_SIZE}
total={adjMeta.total}
sortOptions={[
{ key: 'effective_date', label: '执行日期' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'adjustment_type', label: '调整类型' },
]}
defaultSort="effective_date"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'adjustment_type', label: '调整类型', render: (r) => ACTION_LABELS[r.adjustment_type] || r.adjustment_type },
{ key: 'effective_date', label: '执行日期' },
{ key: 'before_theoretical_margin', label: '调整前毛利率', align: 'right', render: (r) => formatPercent(r.before_theoretical_margin) },
{ key: 'after_target_margin', label: '目标毛利率', align: 'right', render: (r) => formatPercent(r.after_target_margin) },
{ key: 'decided_by', label: '决策人' },
{ key: 'status', label: '状态', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{STATUS_LABELS[r.status] || r.status}</span>
)},
{ key: 'actions', label: '操作', render: (r) => (
<div className="flex gap-2">
{r.status === 'planned' && <button onClick={() => updateStatus.mutate({ id: r.id, status: 'executing' })} className="text-xs text-blue-600 hover:underline"></button>}
{r.status === 'executing' && <button onClick={() => updateStatus.mutate({ id: r.id, status: 'completed' })} className="text-xs text-green-600 hover:underline"></button>}
<button onClick={() => { setVerifyId(r.id); setSubTab('verify') }} className="text-xs text-blue-600 hover:underline"></button>
</div>
)},
]}
/>
)}
</>
)}
@@ -291,7 +287,14 @@ export function AdjustmentTab() {
</CollapsibleSection>
<CollapsibleSection title="调整详情" defaultOpen={false}>
<DataTable
<FilterableTable
data={[verifyData.adjustment]}
sortOptions={[
{ key: 'dish_name', label: '菜品' },
{ key: 'effective_date', label: '执行日期' },
]}
defaultSort="dish_name"
defaultOrder="asc"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'adjustment_type', label: '调整类型', render: (r) => ACTION_LABELS[r.adjustment_type] || r.adjustment_type },
@@ -301,7 +304,6 @@ export function AdjustmentTab() {
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{STATUS_LABELS[r.status] || r.status}</span>
)},
]}
data={[verifyData.adjustment]}
/>
</CollapsibleSection>
</>
+86 -73
View File
@@ -2,32 +2,31 @@ import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
const PAGE_SIZE = 15
export function BomTab() {
export function BomTab({ month }: { month: string }) {
const [complexPage, setComplexPage] = useState(1)
const [missingPage, setMissingPage] = useState(1)
const [selectedSku, setSelectedSku] = useState('')
const [complexSort, setComplexSort] = useState('material_count')
const [complexOrder, setComplexOrder] = useState('desc')
const [complexOrder, setComplexOrder] = useState<'asc' | 'desc'>('desc')
const [complexFilter, setComplexFilter] = useState('')
const [missSort, setMissSort] = useState('sales_amount')
const [missOrder, setMissOrder] = useState('desc')
const [missOrder, setMissOrder] = useState<'asc' | 'desc'>('desc')
const { data: bomOv, isLoading: l1 } = useQuery({ queryKey: ['ca/bom-overview'], queryFn: () => api.get('/cost-analysis/bom-overview') })
const { data: complexity, isLoading: l2 } = useQuery({ queryKey: ['ca/bom-complexity', complexPage, complexSort, complexOrder, complexFilter], queryFn: () => api.get(`/cost-analysis/bom-complexity?page=${complexPage}&page_size=${PAGE_SIZE}&sort=${complexSort}&order=${complexOrder}&filter=${complexFilter}`) })
const { data: highLoss, isLoading: l3 } = useQuery({ queryKey: ['ca/bom-high-loss'], queryFn: () => api.get('/cost-analysis/bom-high-loss?threshold=20') })
const { data: missing, isLoading: l4 } = useQuery({ queryKey: ['ca/bom-missing', missingPage, missSort, missOrder], queryFn: () => api.get(`/cost-analysis/bom-missing?page=${missingPage}&page_size=${PAGE_SIZE}&sort=${missSort}&order=${missOrder}`) })
const { data: bomOv, isLoading: l1 } = useQuery({ queryKey: ['ca/bom-overview', month], queryFn: () => api.get('/cost-analysis/bom-overview', { params: { month } }) })
const { data: complexity, isLoading: l2 } = useQuery({ queryKey: ['ca/bom-complexity', month, complexPage, complexSort, complexOrder, complexFilter], queryFn: () => api.get(`/cost-analysis/bom-complexity`, { params: { month, page: complexPage, page_size: PAGE_SIZE, sort: complexSort, order: complexOrder, filter: complexFilter } }) })
const { data: highLoss, isLoading: l3 } = useQuery({ queryKey: ['ca/bom-high-loss', month], queryFn: () => api.get('/cost-analysis/bom-high-loss', { params: { month, threshold: 20 } }) })
const { data: missing, isLoading: l4 } = useQuery({ queryKey: ['ca/bom-missing', month, missingPage, missSort, missOrder], queryFn: () => api.get(`/cost-analysis/bom-missing`, { params: { month, page: missingPage, page_size: PAGE_SIZE, sort: missSort, order: missOrder } }) })
const { data: composition, isLoading: l5 } = useQuery({
queryKey: ['ca/bom-composition', selectedSku],
queryFn: () => api.get(`/cost-analysis/bom-composition?sku_code=${selectedSku}`),
queryKey: ['ca/bom-composition', month, selectedSku],
queryFn: () => api.get(`/cost-analysis/bom-composition`, { params: { month, sku_code: selectedSku } }),
enabled: !!selectedSku,
})
@@ -85,53 +84,61 @@ export function BomTab() {
</CollapsibleSection>
<CollapsibleSection title="BOM复杂度明细" subtitle="支持筛选和排序">
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={compData}
serverSide
page={complexPage}
onPageChange={setComplexPage}
sort={complexSort}
onSortChange={setComplexSort}
order={complexOrder}
onOrderChange={setComplexOrder}
filter={complexFilter}
onFilterChange={setComplexFilter}
pageSize={PAGE_SIZE}
total={compMeta.total}
filterButtons={[
{ key: '', label: '全部' },
{ key: 'high_loss', label: '含高损耗' },
{ key: 'unique_heavy', label: '独有原料多' },
].map(f => (
<button key={f.key} onClick={() => { setComplexFilter(f.key); setComplexPage(1) }} className={`px-3 py-1 rounded text-xs ${complexFilter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{[
]}
sortOptions={[
{ key: 'material_count', label: '原料数' },
{ key: 'unique_material_count', label: '独有原料' },
{ key: 'high_loss_count', label: '高损耗' },
].map(s => (
<button key={s.key} onClick={() => { setComplexSort(s.key); setComplexPage(1) }} className={`px-3 py-1 rounded text-xs ${complexSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setComplexOrder(complexOrder === 'asc' ? 'desc' : 'asc'); setComplexPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{complexOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<Pagination page={complexPage} pageSize={PAGE_SIZE} total={compMeta.total} onPageChange={setComplexPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名', render: (r) => (
<span className="text-blue-600 cursor-pointer hover:underline" onClick={() => setSelectedSku(r.sku_code)}>{r.dish_name}</span>
)},
{ key: 'category_l1', label: '品类' },
{ key: 'material_count', label: '原料数', align: 'right', render: (r) => formatNumber(r.material_count) },
{ key: 'semi_finished_count', label: '半成品数', align: 'right', render: (r) => formatNumber(r.semi_finished_count) },
{ key: 'unique_material_count', label: '独有原料', align: 'right', render: (r) => formatNumber(r.unique_material_count) },
{ key: 'high_loss_count', label: '高损耗原料', align: 'right', render: (r) => r.high_loss_count > 0 ? <span className="text-red-600">{r.high_loss_count}</span> : '0' },
{ key: 'complexity_level', label: '复杂度', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${complexityColor(r.complexity_level)}`}>{r.complexity_level}</span>
)},
]}
data={compData}
/>
</div>
]}
defaultSort="material_count"
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名', render: (r) => (
<span className="text-blue-600 cursor-pointer hover:underline" onClick={() => setSelectedSku(r.sku_code)}>{r.dish_name}</span>
)},
{ key: 'category_l1', label: '品类' },
{ key: 'material_count', label: '原料数', align: 'right', render: (r) => formatNumber(r.material_count) },
{ key: 'semi_finished_count', label: '半成品数', align: 'right', render: (r) => formatNumber(r.semi_finished_count) },
{ key: 'unique_material_count', label: '独有原料', align: 'right', render: (r) => formatNumber(r.unique_material_count) },
{ key: 'high_loss_count', label: '高损耗原料', align: 'right', render: (r) => r.high_loss_count > 0 ? <span className="text-red-600">{r.high_loss_count}</span> : '0' },
{ key: 'complexity_level', label: '复杂度', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${complexityColor(r.complexity_level)}`}>{r.complexity_level}</span>
)},
]}
/>
</CollapsibleSection>
{selectedSku && (
<CollapsibleSection title={`菜品物料构成: ${selectedSku}`} subtitle="点击菜品名查看物料详情" defaultOpen={true}>
{l5 ? <LoadingSpinner text="加载物料构成..." /> : (
<DataTable
<FilterableTable
data={compDetail}
sortOptions={[
{ key: 'cost_share', label: '成本占比' },
{ key: 'waste_rate', label: '损耗率' },
{ key: 'gross_qty', label: '标准毛用量' },
]}
defaultSort="cost_share"
defaultOrder="desc"
filterKey="material_type"
filterLabel="全部类型"
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'material_type', label: '类型', align: 'center' },
@@ -141,14 +148,22 @@ export function BomTab() {
{ key: 'waste_rate', label: '损耗率', align: 'right', render: (r) => formatPercent(r.waste_rate) },
{ key: 'cost_share', label: '成本占比', align: 'right', render: (r) => formatPercent(r.cost_share) },
]}
data={compDetail}
/>
)}
</CollapsibleSection>
)}
<CollapsibleSection title="高损耗BOM预警" subtitle="损耗率 ≥ 20%的BOM记录" defaultOpen={false}>
<DataTable
<FilterableTable
data={lossData}
sortOptions={[
{ key: 'waste_rate', label: '损耗率' },
{ key: 'yield_rate', label: '出成率' },
]}
defaultSort="waste_rate"
defaultOrder="desc"
filterKey="alert_level"
filterLabel="全部级别"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'material_name', label: '物料' },
@@ -159,37 +174,35 @@ export function BomTab() {
<span className={`rounded px-2 py-0.5 text-xs font-medium ${alertColor(r.alert_level)}`}>{r.alert_level}</span>
)},
]}
data={lossData}
/>
</CollapsibleSection>
<CollapsibleSection title="缺BOM的SKU清单" subtitle="优先补齐高销量SKU的BOM" defaultOpen={false}>
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={missData}
serverSide
page={missingPage}
onPageChange={setMissingPage}
sort={missSort}
onSortChange={setMissSort}
order={missOrder}
onOrderChange={setMissOrder}
pageSize={PAGE_SIZE}
total={missMeta.total}
sortOptions={[
{ key: 'sales_amount', label: '销售额' },
{ key: 'sales_quantity', label: '销量' },
{ key: 'standard_name', label: '菜品名' },
].map(s => (
<button key={s.key} onClick={() => { setMissSort(s.key); setMissingPage(1) }} className={`px-3 py-1 rounded text-xs ${missSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setMissOrder(missOrder === 'asc' ? 'desc' : 'asc'); setMissingPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{missOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<Pagination page={missingPage} pageSize={PAGE_SIZE} total={missMeta.total} onPageChange={setMissingPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_l1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => r.sales_amount > 0 ? `¥${formatNumber(r.sales_amount)}` : '-' },
{ key: 'sales_quantity', label: '销量', align: 'right', render: (r) => r.sales_quantity > 0 ? formatNumber(r.sales_quantity) : '-' },
]}
data={missData}
/>
</div>
]}
defaultSort="sales_amount"
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_l1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => r.sales_amount > 0 ? `¥${formatNumber(r.sales_amount)}` : '-' },
{ key: 'sales_quantity', label: '销量', align: 'right', render: (r) => r.sales_quantity > 0 ? formatNumber(r.sales_quantity) : '-' },
]}
/>
</CollapsibleSection>
</div>
)
@@ -7,8 +7,8 @@ import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ZAxis, Res
const CATEGORY_COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#f97316', '#eab308', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#a3a3a3', '#f43f5e', '#6366f1', '#14b8a6', '#facc15', '#7c3aed', '#10b981', '#fb923c', '#64748b', '#d946ef']
export function ExploreTab() {
const { data: scatter, isLoading } = useQuery({ queryKey: ['ca/scatter'], queryFn: () => api.get('/cost-analysis/scatter') })
export function ExploreTab({ month }: { month: string }) {
const { data: scatter, isLoading } = useQuery({ queryKey: ['ca/scatter', month], queryFn: () => api.get('/cost-analysis/scatter', { params: { month } }) })
if (isLoading) return <LoadingSpinner text="加载散点图数据..." />
@@ -1,42 +1,31 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { useState } from 'react'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'
const PAGE_SIZE = 15
const PIE_COLORS = ['#22c55e', '#3b82f6', '#eab308', '#f97316', '#ef4444', '#8b5cf6', '#a3a3a3']
export function MaterialTab() {
export function MaterialTab({ month }: { month: string }) {
const [dishCode, setDishCode] = useState('')
const [searchInput, setSearchInput] = useState('')
const [page, setPage] = useState(1)
const [topSort, setTopSort] = useState('total_loss_qty')
const [topOrder, setTopOrder] = useState('asc')
const { data: variance, isLoading: lv } = useQuery({
queryKey: ['ca/material-variance', dishCode],
queryFn: () => api.get(`/cost-analysis/material-variance?dish_code=${dishCode}`),
queryKey: ['ca/material-variance', month, dishCode],
queryFn: () => api.get(`/cost-analysis/material-variance`, { params: { month, dish_code: dishCode } }),
enabled: !!dishCode,
})
const { data: lossDist, isLoading: ld } = useQuery({ queryKey: ['ca/loss-distribution'], queryFn: () => api.get('/cost-analysis/loss-distribution') })
const { data: lossTop, isLoading: lt } = useQuery({ queryKey: ['ca/material-loss-top'], queryFn: () => api.get('/cost-analysis/material-loss-top?limit=50') })
const { data: typeLoss, isLoading: ltl } = useQuery({ queryKey: ['ca/material-type-loss'], queryFn: () => api.get('/cost-analysis/material-type-loss') })
const { data: lossDist, isLoading: ld } = useQuery({ queryKey: ['ca/loss-distribution', month], queryFn: () => api.get('/cost-analysis/loss-distribution', { params: { month } }) })
const { data: lossTop, isLoading: lt } = useQuery({ queryKey: ['ca/material-loss-top', month], queryFn: () => api.get('/cost-analysis/material-loss-top', { params: { month, limit: 50 } }) })
const { data: typeLoss, isLoading: ltl } = useQuery({ queryKey: ['ca/material-type-loss', month], queryFn: () => api.get('/cost-analysis/material-type-loss', { params: { month } }) })
if (ld || lt || ltl) return <LoadingSpinner text="加载原料差异数据..." />
const varianceData = (variance as any)?.data || []
const distData = (lossDist as any)?.data || []
const topData = (lossTop as any)?.data || []
const sortedTopData = [...topData].sort((a: any, b: any) => {
const av = parseFloat(a[topSort]) || 0
const bv = parseFloat(b[topSort]) || 0
return topOrder === 'asc' ? av - bv : bv - av
})
const typeData = (typeLoss as any)?.data || []
const reasonColor = (reason: string) => {
@@ -90,7 +79,17 @@ export function MaterialTab() {
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<DataTable
<FilterableTable
data={varianceData}
sortOptions={[
{ key: 'loss_amount', label: '金额差异' },
{ key: 'loss_rate', label: '损耗率' },
{ key: 'loss_qty', label: '数量差异' },
]}
defaultSort="loss_amount"
defaultOrder="asc"
filterKey="reason"
filterLabel="全部原因"
columns={[
{ key: 'material_name', label: '原料' },
{ key: 'material_unit', label: '单位', align: 'center' },
@@ -106,7 +105,6 @@ export function MaterialTab() {
<span className={`rounded px-2 py-0.5 text-xs font-medium ${reasonColor(r.reason)}`}>{r.reason}</span>
)},
]}
data={varianceData}
/>
</div>
</>
@@ -125,51 +123,56 @@ export function MaterialTab() {
</PieChart>
</ResponsiveContainer>
<div className="flex-1">
<DataTable
<FilterableTable
data={distData}
sortOptions={[
{ key: 'cnt', label: '明细数' },
{ key: 'loss_band', label: '损耗区间' },
]}
defaultSort="cnt"
defaultOrder="desc"
columns={[
{ key: 'loss_band', label: '损耗区间' },
{ key: 'cnt', label: '明细数', align: 'right' },
]}
data={distData}
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="超耗物料TOP榜" subtitle="支持排序">
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={topData}
sortOptions={[
{ key: 'total_loss_qty', label: '总损耗量' },
{ key: 'avg_loss_rate', label: '平均损耗率' },
{ key: 'total_loss_amount', label: '损耗金额' },
{ key: 'dish_count', label: '涉及菜品数' },
].map(s => (
<button key={s.key} onClick={() => { setTopSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${topSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setTopOrder(topOrder === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{topOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<Pagination page={page} pageSize={PAGE_SIZE} total={sortedTopData.length} onPageChange={setPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'material_type', label: '类型', align: 'center' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'total_loss_qty', label: '总损耗量', align: 'right', render: (r) => formatNumber(r.total_loss_qty) },
{ key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => <span className="text-red-600">{formatPercent(r.avg_loss_rate)}</span> },
{ key: 'max_loss_rate', label: '最大损耗率', align: 'right', render: (r) => formatPercent(r.max_loss_rate) },
{ key: 'total_loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.total_loss_amount) },
]}
data={sortedTopData.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)}
/>
</div>
]}
defaultSort="total_loss_qty"
defaultOrder="asc"
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'material_type', label: '类型', align: 'center' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'total_loss_qty', label: '总损耗量', align: 'right', render: (r) => formatNumber(r.total_loss_qty) },
{ key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => <span className="text-red-600">{formatPercent(r.avg_loss_rate)}</span> },
{ key: 'max_loss_rate', label: '最大损耗率', align: 'right', render: (r) => formatPercent(r.max_loss_rate) },
{ key: 'total_loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.total_loss_amount) },
]}
/>
</CollapsibleSection>
<CollapsibleSection title="物料类型损耗对比" subtitle="原材料 vs 半成品" defaultOpen={false}>
<DataTable
<FilterableTable
data={typeData}
sortOptions={[
{ key: 'total_loss_amount', label: '损耗金额' },
{ key: 'avg_loss_rate', label: '平均损耗率' },
{ key: 'cnt', label: '明细数' },
]}
defaultSort="total_loss_amount"
defaultOrder="desc"
columns={[
{ key: 'material_type', label: '物料类型' },
{ key: 'cnt', label: '明细数', align: 'right' },
@@ -177,7 +180,6 @@ export function MaterialTab() {
{ key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => formatPercent(r.avg_loss_rate) },
{ key: 'total_loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.total_loss_amount) },
]}
data={typeData}
/>
</CollapsibleSection>
</div>
@@ -2,18 +2,18 @@ import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent } from '@/lib/utils'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const PIE_COLORS = ['#ef4444', '#f97316', '#eab308', '#3b82f6', '#22c55e', '#a3a3a3', '#8b5cf6']
export function OverviewTab() {
const { data: overview, isLoading: l1 } = useQuery({ queryKey: ['ca/overview'], queryFn: () => api.get('/cost-analysis/overview') })
const { data: categories, isLoading: l2 } = useQuery({ queryKey: ['ca/category-comparison'], queryFn: () => api.get('/cost-analysis/category-comparison') })
const { data: deviation, isLoading: l3 } = useQuery({ queryKey: ['ca/margin-deviation'], queryFn: () => api.get('/cost-analysis/margin-deviation') })
const { data: varianceTop, isLoading: l4 } = useQuery({ queryKey: ['ca/variance-top'], queryFn: () => api.get('/cost-analysis/variance-top?limit=50') })
export function OverviewTab({ month }: { month: string }) {
const { data: overview, isLoading: l1 } = useQuery({ queryKey: ['ca/overview', month], queryFn: () => api.get('/cost-analysis/overview', { params: { month } }) })
const { data: categories, isLoading: l2 } = useQuery({ queryKey: ['ca/category-comparison', month], queryFn: () => api.get('/cost-analysis/category-comparison', { params: { month } }) })
const { data: deviation, isLoading: l3 } = useQuery({ queryKey: ['ca/margin-deviation', month], queryFn: () => api.get('/cost-analysis/margin-deviation', { params: { month } }) })
const { data: varianceTop, isLoading: l4 } = useQuery({ queryKey: ['ca/variance-top', month], queryFn: () => api.get('/cost-analysis/variance-top', { params: { month, limit: 50 } }) })
if (l1 || l2 || l3 || l4) return <LoadingSpinner text="加载成本总览..." />
@@ -66,7 +66,18 @@ export function OverviewTab() {
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<DataTable
<FilterableTable
data={cats}
sortOptions={[
{ key: 'total_variance', label: '成本差异' },
{ key: 'avg_actual_margin', label: '实际毛利率' },
{ key: 'avg_theo_margin', label: '理论毛利率' },
{ key: 'total_sales', label: '销售额' },
]}
defaultSort="total_variance"
defaultOrder="desc"
filterKey="category_level1"
filterLabel="全部品类"
columns={[
{ key: 'category_level1', label: '品类' },
{ key: 'dishes', label: '菜品数', align: 'right' },
@@ -78,7 +89,6 @@ export function OverviewTab() {
return <span className={v > 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(v)}</span>
}},
]}
data={cats}
/>
</div>
</CollapsibleSection>
@@ -94,19 +104,35 @@ export function OverviewTab() {
</PieChart>
</ResponsiveContainer>
<div className="flex-1">
<DataTable
<FilterableTable
data={devs}
sortOptions={[
{ key: 'cnt', label: '菜品数' },
{ key: 'deviation_band', label: '偏差区间' },
]}
defaultSort="cnt"
defaultOrder="desc"
columns={[
{ key: 'deviation_band', label: '偏差区间' },
{ key: 'cnt', label: '菜品数', align: 'right' },
]}
data={devs}
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="成本差异TOP榜" subtitle="实际成本超理论最严重的菜品">
<DataTable
<FilterableTable
data={tops}
sortOptions={[
{ key: 'cost_variance', label: '成本差异' },
{ key: 'sales_amount', label: '销售额' },
{ key: 'actual_margin', label: '实际毛利率' },
{ key: 'theo_margin', label: '理论毛利率' },
]}
defaultSort="cost_variance"
filterKey="cost_tier"
filterLabel="全部分层"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_level1', label: '品类' },
@@ -121,7 +147,6 @@ export function OverviewTab() {
<span className={`rounded px-2 py-0.5 text-xs font-medium ${tierColor(r.cost_tier)}`}>{r.cost_tier}</span>
)},
]}
data={tops}
/>
</CollapsibleSection>
</div>
@@ -2,22 +2,21 @@ import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
const PAGE_SIZE = 15
export function PackagingTab() {
export function PackagingTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('total_cost')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const [filter, setFilter] = useState('')
const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/packaging-overview'], queryFn: () => api.get('/cost-analysis/packaging-overview') })
const { data: detail, isLoading: l2 } = useQuery({ queryKey: ['ca/packaging-detail', page, sort, order, filter], queryFn: () => api.get(`/cost-analysis/packaging-detail?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}&filter=${filter}`) })
const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/packaging-overview', month], queryFn: () => api.get('/cost-analysis/packaging-overview', { params: { month } }) })
const { data: detail, isLoading: l2 } = useQuery({ queryKey: ['ca/packaging-detail', month, page, sort, order, filter], queryFn: () => api.get(`/cost-analysis/packaging-detail`, { params: { month, page, page_size: PAGE_SIZE, sort, order, filter } }) })
if (l1 || l2) return <LoadingSpinner text="加载包装耗材数据..." />
@@ -41,48 +40,46 @@ export function PackagingTab() {
</div>
<CollapsibleSection title="包装物料明细" subtitle="餐盒、餐具、打包袋等耗材使用情况">
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={detailData}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
filter={filter}
onFilterChange={setFilter}
pageSize={PAGE_SIZE}
total={meta.total}
filterButtons={[
{ key: '', label: '全部' },
{ key: 'high_loss', label: '高损耗' },
{ key: 'normal', label: '正常' },
].map(f => (
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{[
]}
sortOptions={[
{ key: 'total_cost', label: '总成本' },
{ key: 'loss_qty', label: '损耗量' },
{ key: 'avg_loss_rate', label: '损耗率' },
{ key: 'dish_count', label: '涉及菜品数' },
].map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<Pagination page={page} pageSize={PAGE_SIZE} total={meta.total} onPageChange={setPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'total_qty', label: '总用量', align: 'right', render: (r) => formatNumber(r.total_qty) },
{ key: 'total_cost', label: '总成本', align: 'right', render: (r) => formatCurrency(r.total_cost) },
{ key: 'loss_qty', label: '损耗量', align: 'right', render: (r) => formatNumber(r.loss_qty) },
{ key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => {
const v = Number(r.avg_loss_rate || 0)
return <span className={v < -20 ? 'text-red-600' : v < -5 ? 'text-yellow-600' : 'text-green-600'}>{formatPercent(v)}</span>
}},
{ key: 'status', label: '状态', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{r.status}</span>
)},
]}
data={detailData}
/>
</div>
]}
defaultSort="total_cost"
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'total_qty', label: '总用量', align: 'right', render: (r) => formatNumber(r.total_qty) },
{ key: 'total_cost', label: '总成本', align: 'right', render: (r) => formatCurrency(r.total_cost) },
{ key: 'loss_qty', label: '损耗量', align: 'right', render: (r) => formatNumber(r.loss_qty) },
{ key: 'avg_loss_rate', label: '平均损耗率', align: 'right', render: (r) => {
const v = Number(r.avg_loss_rate || 0)
return <span className={v < -20 ? 'text-red-600' : v < -5 ? 'text-yellow-600' : 'text-green-600'}>{formatPercent(v)}</span>
}},
{ key: 'status', label: '状态', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}>{r.status}</span>
)},
]}
/>
</CollapsibleSection>
</div>
)
@@ -1,9 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { useState } from 'react'
import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ZAxis } from 'recharts'
@@ -17,16 +16,16 @@ const MENU_COLORS: Record<string, string> = {
'数据异常品': '#a3a3a3',
}
export function ProfitabilityTab() {
export function ProfitabilityTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [category, setCategory] = useState('')
const [sort, setSort] = useState('sales_amount')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const [filter, setFilter] = useState('')
const { data: matrix, isLoading: l1 } = useQuery({ queryKey: ['ca/menu-engineering'], queryFn: () => api.get('/cost-analysis/menu-engineering') })
const { data: profit, isLoading: l2 } = useQuery({ queryKey: ['ca/profitability', page, category, sort, order, filter], queryFn: () => api.get(`/cost-analysis/profitability?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}&filter=${filter}${category ? `&category=${category}` : ''}`) })
const { data: pricing, isLoading: l3 } = useQuery({ queryKey: ['ca/pricing'], queryFn: () => api.get('/cost-analysis/pricing?threshold=50') })
const { data: matrix, isLoading: l1 } = useQuery({ queryKey: ['ca/menu-engineering', month], queryFn: () => api.get('/cost-analysis/menu-engineering', { params: { month } }) })
const { data: profit, isLoading: l2 } = useQuery({ queryKey: ['ca/profitability', month, page, category, sort, order, filter], queryFn: () => api.get(`/cost-analysis/profitability`, { params: { month, page, page_size: PAGE_SIZE, sort, order, filter, ...(category ? { category } : {}) } }) })
const { data: pricing, isLoading: l3 } = useQuery({ queryKey: ['ca/pricing', month], queryFn: () => api.get('/cost-analysis/pricing', { params: { month, threshold: 50 } }) })
if (l1 || l2 || l3) return <LoadingSpinner text="加载菜品盈利数据..." />
@@ -85,57 +84,67 @@ export function ProfitabilityTab() {
</CollapsibleSection>
<CollapsibleSection title="菜品盈利明细" subtitle="支持筛选和排序">
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
{ key: '', label: '全部' },
{ key: 'profitable', label: '盈利菜品' },
{ key: 'loss', label: '亏损菜品' },
{ key: 'high_variance', label: '成本超耗' },
].map(f => (
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{[
{ key: 'sales_amount', label: '销售额' },
{ key: 'sales_quantity', label: '销量' },
{ key: 'actual_margin_rate_pct', label: '实际毛利率' },
{ key: 'theoretical_margin_rate_pct', label: '理论毛利率' },
{ key: 'cost_variance_amount', label: '成本差异' },
].map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
<span className="mx-2 text-muted-foreground">|</span>
<div className="mb-3 flex items-center gap-2">
<label className="text-xs text-muted-foreground">:</label>
<select value={category} onChange={(e) => { setCategory(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-xs">
<option value=""></option>
{categories.map((c: any) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<Pagination page={page} pageSize={PAGE_SIZE} total={profitMeta.total} onPageChange={setPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_level1', label: '品类' },
{ key: 'sales_quantity', label: '销量', align: 'right', render: (r) => formatNumber(r.sales_quantity) },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
{ key: 'theo_profit', label: '理论毛利', align: 'right', render: (r) => formatCurrency(r.theo_profit) },
{ key: 'actual_profit', label: '实际毛利', align: 'right', render: (r) => formatCurrency(r.actual_profit) },
{ key: 'theo_margin', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theo_margin) },
{ key: 'actual_margin', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin) },
{ key: 'revenue_contribution', label: '收入贡献度', align: 'right', render: (r) => formatPercent(r.revenue_contribution) },
]}
data={profitData}
/>
</div>
<FilterableTable
data={profitData}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
filter={filter}
onFilterChange={setFilter}
pageSize={PAGE_SIZE}
total={profitMeta.total}
filterButtons={[
{ key: '', label: '全部' },
{ key: 'profitable', label: '盈利菜品' },
{ key: 'loss', label: '亏损菜品' },
{ key: 'high_variance', label: '成本超耗' },
]}
sortOptions={[
{ key: 'sales_amount', label: '销售额' },
{ key: 'sales_quantity', label: '销量' },
{ key: 'actual_margin_rate_pct', label: '实际毛利率' },
{ key: 'theoretical_margin_rate_pct', label: '理论毛利率' },
{ key: 'cost_variance_amount', label: '成本差异' },
]}
defaultSort="sales_amount"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_level1', label: '品类' },
{ key: 'sales_quantity', label: '销量', align: 'right', render: (r) => formatNumber(r.sales_quantity) },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
{ key: 'theo_profit', label: '理论毛利', align: 'right', render: (r) => formatCurrency(r.theo_profit) },
{ key: 'actual_profit', label: '实际毛利', align: 'right', render: (r) => formatCurrency(r.actual_profit) },
{ key: 'theo_margin', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theo_margin) },
{ key: 'actual_margin', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin) },
{ key: 'revenue_contribution', label: '收入贡献度', align: 'right', render: (r) => formatPercent(r.revenue_contribution) },
]}
/>
</CollapsibleSection>
<CollapsibleSection title="低毛利菜品预警" subtitle="理论毛利率 < 50% 且销售额 > 1000" defaultOpen={false}>
<DataTable
<FilterableTable
data={pricingData}
sortOptions={[
{ key: 'theo_margin', label: '理论毛利率' },
{ key: 'actual_margin', label: '实际毛利率' },
{ key: 'sales_amount', label: '销售额' },
{ key: 'price', label: '售价' },
]}
defaultSort="theo_margin"
defaultOrder="asc"
filterKey="category_level1"
filterLabel="全部分类"
columns={[
{ key: 'dish_name', label: '菜品' },
{ key: 'category_level1', label: '品类' },
@@ -146,7 +155,6 @@ export function ProfitabilityTab() {
{ key: 'actual_margin', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin) },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
]}
data={pricingData}
/>
</CollapsibleSection>
</div>
@@ -2,25 +2,24 @@ import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatPercent, formatNumber, formatCurrency } from '@/lib/utils'
import { useState } from 'react'
const PAGE_SIZE = 15
export function QualityTab() {
export function QualityTab({ month }: { month: string }) {
const [dishPage, setDishPage] = useState(1)
const [matPage, setMatPage] = useState(1)
const [dishSort, setDishSort] = useState('sales_amount')
const [dishOrder, setDishOrder] = useState('desc')
const [dishOrder, setDishOrder] = useState<'asc' | 'desc'>('desc')
const [matSort, setMatSort] = useState('dish_count')
const [matOrder, setMatOrder] = useState('desc')
const [matOrder, setMatOrder] = useState<'asc' | 'desc'>('desc')
const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/data-quality'], queryFn: () => api.get('/cost-analysis/data-quality') })
const { data: dishes, isLoading: l2 } = useQuery({ queryKey: ['ca/unmatched-dishes', dishPage, dishSort, dishOrder], queryFn: () => api.get(`/cost-analysis/unmatched-dishes?page=${dishPage}&page_size=${PAGE_SIZE}&sort=${dishSort}&order=${dishOrder}`) })
const { data: materials, isLoading: l3 } = useQuery({ queryKey: ['ca/unmatched-materials', matPage, matSort, matOrder], queryFn: () => api.get(`/cost-analysis/unmatched-materials?page=${matPage}&page_size=${PAGE_SIZE}&sort=${matSort}&order=${matOrder}`) })
const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/data-quality', month], queryFn: () => api.get('/cost-analysis/data-quality', { params: { month } }) })
const { data: dishes, isLoading: l2 } = useQuery({ queryKey: ['ca/unmatched-dishes', month, dishPage, dishSort, dishOrder], queryFn: () => api.get(`/cost-analysis/unmatched-dishes`, { params: { month, page: dishPage, page_size: PAGE_SIZE, sort: dishSort, order: dishOrder } }) })
const { data: materials, isLoading: l3 } = useQuery({ queryKey: ['ca/unmatched-materials', month, matPage, matSort, matOrder], queryFn: () => api.get(`/cost-analysis/unmatched-materials`, { params: { month, page: matPage, page_size: PAGE_SIZE, sort: matSort, order: matOrder } }) })
if (l1 || l2 || l3) return <LoadingSpinner text="加载数据质量..." />
@@ -53,7 +52,16 @@ export function QualityTab() {
</div>
<CollapsibleSection title="BOM数据质量检查" subtitle="每月固定检查项">
<DataTable
<FilterableTable
data={qualityChecks}
sortOptions={[
{ key: 'check', label: '检查项' },
{ key: 'status', label: '状态' },
]}
defaultSort="check"
defaultOrder="asc"
filterKey="status"
filterLabel="全部状态"
columns={[
{ key: 'check', label: '检查项' },
{ key: 'value', label: '值', align: 'right' },
@@ -62,63 +70,60 @@ export function QualityTab() {
<span className={`rounded px-2 py-0.5 text-xs font-medium ${checkColor(r.status)}`}>{r.status}</span>
)},
]}
data={qualityChecks}
/>
</CollapsibleSection>
<CollapsibleSection title="未匹配菜品清单" subtitle="菜品成本报表中未匹配到销售数据的菜品" defaultOpen={false}>
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={dishData}
serverSide
page={dishPage}
onPageChange={setDishPage}
sort={dishSort}
onSortChange={setDishSort}
order={dishOrder}
onOrderChange={setDishOrder}
pageSize={PAGE_SIZE}
total={dishMeta.total}
sortOptions={[
{ key: 'sales_amount', label: '销售额' },
{ key: 'dish_name', label: '菜品名' },
].map(s => (
<button key={s.key} onClick={() => { setDishSort(s.key); setDishPage(1) }} className={`px-3 py-1 rounded text-xs ${dishSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setDishOrder(dishOrder === 'asc' ? 'desc' : 'asc'); setDishPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{dishOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<Pagination page={dishPage} pageSize={PAGE_SIZE} total={dishMeta.total} onPageChange={setDishPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品名' },
{ key: 'dish_code', label: '编码' },
{ key: 'category_level1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
]}
data={dishData}
/>
</div>
]}
defaultSort="sales_amount"
columns={[
{ key: 'dish_name', label: '菜品名' },
{ key: 'dish_code', label: '编码' },
{ key: 'category_level1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
]}
/>
</CollapsibleSection>
<CollapsibleSection title="未匹配物料清单" subtitle="未匹配到库存数据的物料" defaultOpen={false}>
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={matData}
serverSide
page={matPage}
onPageChange={setMatPage}
sort={matSort}
onSortChange={setMatSort}
order={matOrder}
onOrderChange={setMatOrder}
pageSize={PAGE_SIZE}
total={matMeta.total}
sortOptions={[
{ key: 'dish_count', label: '涉及菜品数' },
{ key: 'loss_amount', label: '损耗金额' },
{ key: 'material_name', label: '物料名' },
].map(s => (
<button key={s.key} onClick={() => { setMatSort(s.key); setMatPage(1) }} className={`px-3 py-1 rounded text-xs ${matSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setMatOrder(matOrder === 'asc' ? 'desc' : 'asc'); setMatPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{matOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<Pagination page={matPage} pageSize={PAGE_SIZE} total={matMeta.total} onPageChange={setMatPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'material_name', label: '物料名' },
{ key: 'material_type', label: '类型', align: 'center' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.loss_amount) },
]}
data={matData}
/>
</div>
]}
defaultSort="dish_count"
columns={[
{ key: 'material_name', label: '物料名' },
{ key: 'material_type', label: '类型', align: 'center' },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
{ key: 'loss_amount', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.loss_amount) },
]}
/>
</CollapsibleSection>
</div>
)
@@ -2,13 +2,11 @@ import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { useState } from 'react'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const PAGE_SIZE = 15
const LEVEL_COLORS: Record<string, string> = {
@@ -18,15 +16,14 @@ const LEVEL_COLORS: Record<string, string> = {
'灰色-口径异常': '#a3a3a3',
}
export function StoreTab() {
export function StoreTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('food_cost_variance')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const [filter, setFilter] = useState('')
const [month, setMonth] = useState('2026-04')
const { data: ov, isLoading: l1 } = useQuery({ queryKey: ['ca/store-overview', month], queryFn: () => api.get('/cost-analysis/store-overview', { params: { month } }) })
const { data: ranking, isLoading: l2 } = useQuery({ queryKey: ['ca/store-ranking', month, page, sort, order, filter], queryFn: () => api.get(`/cost-analysis/store-ranking?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}&filter=${filter}&month=${month}`) })
const { data: ranking, isLoading: l2 } = useQuery({ queryKey: ['ca/store-ranking', month, page, sort, order, filter], queryFn: () => api.get(`/cost-analysis/store-ranking`, { params: { month, page, page_size: PAGE_SIZE, sort, order, filter } }) })
if (l1 || l2) return <LoadingSpinner text="加载门店成本数据..." />
@@ -50,9 +47,6 @@ export function StoreTab() {
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
<MonthPicker month={month} onChange={setMonth} />
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="门店总数" value={ovData.total_stores} format="number" />
<MetricCard title="红色-严重超耗" value={ovData.red_count} format="number" />
@@ -74,56 +68,54 @@ export function StoreTab() {
</CollapsibleSection>
<CollapsibleSection title="门店超耗排名" subtitle="支持筛选和排序">
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={rankData}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
filter={filter}
onFilterChange={setFilter}
pageSize={PAGE_SIZE}
total={meta.total}
filterButtons={[
{ key: '', label: '全部' },
{ key: 'red', label: '红色-严重超耗' },
{ key: 'orange', label: '橙色-明显超耗' },
{ key: 'green', label: '绿色-基本正常' },
{ key: 'gray', label: '灰色-口径异常' },
].map(f => (
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{[
]}
sortOptions={[
{ key: 'food_cost_variance', label: '超耗金额' },
{ key: 'variance_to_theoretical_pct', label: '偏差率' },
{ key: 'actual_food_cost_rate_pct', label: '实际成本率' },
{ key: 'negative_item_lines', label: '负库存项' },
].map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<Pagination page={page} pageSize={PAGE_SIZE} total={meta.total} onPageChange={setPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'theo_cost_rate', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theo_cost_rate) },
{ key: 'actual_cost_rate', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_cost_rate) },
{ key: 'variance_pct', label: '偏差', align: 'right', render: (r) => {
const v = Number(r.variance_pct || 0)
return <span className={v > 20 ? 'text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{formatPercent(v)}</span>
}},
{ key: 'variance_amount', label: '超耗金额', align: 'right', render: (r) => {
const v = Number(r.variance_amount || 0)
return <span className={v > 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(v)}</span>
}},
{ key: 'negative_item_lines', label: '负库存项', align: 'center', render: (r) => {
const n = Number(r.negative_item_lines || 0)
return n > 0 ? <span className="text-red-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
{ key: 'variance_level', label: '分级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${levelColor(r.variance_level)}`}>{r.variance_level}</span>
)},
]}
data={rankData}
/>
</div>
]}
defaultSort="food_cost_variance"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'theo_cost_rate', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theo_cost_rate) },
{ key: 'actual_cost_rate', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_cost_rate) },
{ key: 'variance_pct', label: '偏差', align: 'right', render: (r) => {
const v = Number(r.variance_pct || 0)
return <span className={v > 20 ? 'text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{formatPercent(v)}</span>
}},
{ key: 'variance_amount', label: '超耗金额', align: 'right', render: (r) => {
const v = Number(r.variance_amount || 0)
return <span className={v > 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(v)}</span>
}},
{ key: 'negative_item_lines', label: '负库存项', align: 'center', render: (r) => {
const n = Number(r.negative_item_lines || 0)
return n > 0 ? <span className="text-red-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
{ key: 'variance_level', label: '分级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${levelColor(r.variance_level)}`}>{r.variance_level}</span>
)},
]}
/>
</CollapsibleSection>
</div>
)
@@ -1,31 +1,30 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatNumber } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
const PAGE_SIZE = 15
export function SupplyTab() {
export function SupplyTab({ month }: { month: string }) {
const [riskPage, setRiskPage] = useState(1)
const [demandPage, setDemandPage] = useState(1)
const [skuInput, setSkuInput] = useState('')
const [simResult, setSimResult] = useState<any>(null)
const [simLoading, setSimLoading] = useState(false)
const [riskSort, setRiskSort] = useState('sales_amount')
const [riskOrder, setRiskOrder] = useState('asc')
const [riskOrder, setRiskOrder] = useState<'asc' | 'desc'>('asc')
const [riskFilter, setRiskFilter] = useState('')
const [demandSort, setDemandSort] = useState('estimated_demand')
const [demandOrder, setDemandOrder] = useState('desc')
const [demandOrder, setDemandOrder] = useState<'asc' | 'desc'>('desc')
const { data: sharing, isLoading: l1 } = useQuery({ queryKey: ['ca/material-sharing'], queryFn: () => api.get('/cost-analysis/material-sharing?limit=30') })
const { data: risk, isLoading: l2 } = useQuery({ queryKey: ['ca/unique-material-risk', riskPage, riskSort, riskOrder, riskFilter], queryFn: () => api.get(`/cost-analysis/unique-material-risk?page=${riskPage}&page_size=${PAGE_SIZE}&sort=${riskSort}&order=${riskOrder}&filter=${riskFilter}`) })
const { data: demand, isLoading: l3 } = useQuery({ queryKey: ['ca/material-demand'], queryFn: () => api.get('/cost-analysis/material-demand?limit=100') })
const { data: sharing, isLoading: l1 } = useQuery({ queryKey: ['ca/material-sharing', month], queryFn: () => api.get('/cost-analysis/material-sharing', { params: { month, limit: 30 } }) })
const { data: risk, isLoading: l2 } = useQuery({ queryKey: ['ca/unique-material-risk', month, riskPage, riskSort, riskOrder, riskFilter], queryFn: () => api.get(`/cost-analysis/unique-material-risk`, { params: { month, page: riskPage, page_size: PAGE_SIZE, sort: riskSort, order: riskOrder, filter: riskFilter } }) })
const { data: demand, isLoading: l3 } = useQuery({ queryKey: ['ca/material-demand', month], queryFn: () => api.get('/cost-analysis/material-demand', { params: { month, limit: 100 } }) })
if (l1 || l2 || l3) return <LoadingSpinner text="加载供应链数据..." />
@@ -33,12 +32,6 @@ export function SupplyTab() {
const riskData = (risk as any)?.data || []
const riskMeta = (risk as any)?.meta || { total: 0 }
const rawDemandData = (demand as any)?.data || []
const demandData = [...rawDemandData].sort((a: any, b: any) => {
const av = parseFloat(a[demandSort]) || a[demandSort] || 0
const bv = parseFloat(b[demandSort]) || b[demandSort] || 0
if (typeof av === 'string') return demandOrder === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av)
return demandOrder === 'asc' ? av - bv : bv - av
})
const sharingColor = (type: string) => {
if (type === '核心原料') return 'bg-red-100 text-red-700'
@@ -92,7 +85,16 @@ export function SupplyTab() {
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<DataTable
<FilterableTable
data={sharingData}
sortOptions={[
{ key: 'sku_count', label: '被引用SKU数' },
{ key: 'material_name', label: '物料名' },
]}
defaultSort="sku_count"
defaultOrder="desc"
filterKey="sharing_type"
filterLabel="全部分类"
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'major_category', label: '类型', align: 'center' },
@@ -101,48 +103,46 @@ export function SupplyTab() {
<span className={`rounded px-2 py-0.5 text-xs font-medium ${sharingColor(r.sharing_type)}`}>{r.sharing_type}</span>
)},
]}
data={sharingData}
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="独有原料风险" subtitle="低收入SKU占用专用原料">
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={riskData}
serverSide
page={riskPage}
onPageChange={setRiskPage}
sort={riskSort}
onSortChange={setRiskSort}
order={riskOrder}
onOrderChange={setRiskOrder}
filter={riskFilter}
onFilterChange={setRiskFilter}
pageSize={PAGE_SIZE}
total={riskMeta.total}
filterButtons={[
{ key: '', label: '全部' },
{ key: 'high_risk', label: '高风险' },
{ key: 'low_sales', label: '低销售额' },
].map(f => (
<button key={f.key} onClick={() => { setRiskFilter(f.key); setRiskPage(1) }} className={`px-3 py-1 rounded text-xs ${riskFilter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{[
]}
sortOptions={[
{ key: 'sales_amount', label: '销售额' },
{ key: 'unique_material_count', label: '独有原料数' },
].map(s => (
<button key={s.key} onClick={() => { setRiskSort(s.key); setRiskPage(1) }} className={`px-3 py-1 rounded text-xs ${riskSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setRiskOrder(riskOrder === 'asc' ? 'desc' : 'asc'); setRiskPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{riskOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<Pagination page={riskPage} pageSize={PAGE_SIZE} total={riskMeta.total} onPageChange={setRiskPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_l1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
{ key: 'unique_material_count', label: '独有原料数', align: 'right', render: (r) => <span className={r.unique_material_count > 3 ? 'text-red-600' : ''}>{formatNumber(r.unique_material_count)}</span> },
{ key: 'risk_level', label: '风险等级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${riskLevelColor(r.risk_level)}`}>{r.risk_level}</span>
)},
]}
data={riskData}
/>
</div>
]}
defaultSort="sales_amount"
defaultOrder="asc"
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_l1', label: '品类' },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
{ key: 'unique_material_count', label: '独有原料数', align: 'right', render: (r) => <span className={r.unique_material_count > 3 ? 'text-red-600' : ''}>{formatNumber(r.unique_material_count)}</span> },
{ key: 'risk_level', label: '风险等级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${riskLevelColor(r.risk_level)}`}>{r.risk_level}</span>
)},
]}
/>
</CollapsibleSection>
<CollapsibleSection title="SKU精简模拟器" subtitle="输入SKU编码模拟下架影响" defaultOpen={false}>
@@ -167,13 +167,21 @@ export function SupplyTab() {
<MetricCard title="负毛利菜品数" value={simResult.summary?.negative_margin_count} format="number" />
</div>
{simResult.releasable_materials?.length > 0 && (
<DataTable
<FilterableTable
data={simResult.releasable_materials}
sortOptions={[
{ key: 'inventory_value', label: '库存金额' },
{ key: 'material_name', label: '物料名' },
]}
defaultSort="inventory_value"
defaultOrder="desc"
filterKey="major_category"
filterLabel="全部类型"
columns={[
{ key: 'material_name', label: '可释放物料' },
{ key: 'major_category', label: '类型', align: 'center' },
{ key: 'inventory_value', label: '库存金额', align: 'right', render: (r) => formatCurrency(r.inventory_value) },
]}
data={simResult.releasable_materials}
/>
)}
</>
@@ -182,32 +190,23 @@ export function SupplyTab() {
</CollapsibleSection>
<CollapsibleSection title="BOM驱动物料需求预测" subtitle="按历史销量×标准用量估算" defaultOpen={false}>
<div className="mb-3 flex flex-wrap items-center gap-2">
{[
<FilterableTable
data={rawDemandData}
sortOptions={[
{ key: 'estimated_demand', label: '需求量' },
{ key: 'dish_count', label: '涉及菜品数' },
{ key: 'material_name', label: '物料名' },
].map(s => (
<button key={s.key} onClick={() => { setDemandSort(s.key); setDemandPage(1) }} className={`px-3 py-1 rounded text-xs ${demandSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setDemandOrder(demandOrder === 'asc' ? 'desc' : 'asc'); setDemandPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{demandOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<Pagination page={demandPage} pageSize={PAGE_SIZE} total={demandData.length} onPageChange={setDemandPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'major_category', label: '类型', align: 'center' },
{ key: 'base_unit', label: '单位', align: 'center' },
{ key: 'estimated_demand', label: '预计需求量', align: 'right', render: (r) => formatNumber(r.estimated_demand) },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
]}
data={demandData.slice((demandPage - 1) * PAGE_SIZE, demandPage * PAGE_SIZE)}
/>
</div>
]}
defaultSort="estimated_demand"
pageSize={PAGE_SIZE}
columns={[
{ key: 'material_name', label: '物料' },
{ key: 'major_category', label: '类型', align: 'center' },
{ key: 'base_unit', label: '单位', align: 'center' },
{ key: 'estimated_demand', label: '预计需求量', align: 'right', render: (r) => formatNumber(r.estimated_demand) },
{ key: 'dish_count', label: '涉及菜品数', align: 'right', render: (r) => formatNumber(r.dish_count) },
]}
/>
</CollapsibleSection>
</div>
)
@@ -1,39 +1,25 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatNumber } from '@/lib/utils'
const PAGE_SIZE = 20
const LEVEL_BG: Record<string, string> = {
red: 'bg-red-100 text-red-700 border-red-300',
orange: 'bg-orange-100 text-orange-700 border-orange-300',
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-300',
}
export function AttendanceAlertTab() {
const [summaryPage, setSummaryPage] = useState(1)
const [alertPage, setAlertPage] = useState(1)
const [summaryFilter, setSummaryFilter] = useState('')
const [summarySort, setSummarySort] = useState('emp_count')
const [summaryOrder, setSummaryOrder] = useState('desc')
const [alertFilter, setAlertFilter] = useState('')
const [alertLevelFilter, setAlertLevelFilter] = useState('')
const [alertSort, setAlertSort] = useState('attend_diff')
const [alertOrder, setAlertOrder] = useState('desc')
export function AttendanceAlertTab({ month }: { month: string }) {
const { data: summaryData, isLoading: summaryLoading } = useQuery({
queryKey: ['ss/attendance-summary'],
queryFn: () => api.get('/smart-scheduling/attendance-summary'),
queryKey: ['ss/attendance-summary', month],
queryFn: () => api.get('/smart-scheduling/attendance-summary', { params: { month } }),
})
const { data: alertData, isLoading: alertLoading } = useQuery({
queryKey: ['ss/attendance-alert'],
queryFn: () => api.get('/smart-scheduling/attendance-alert'),
queryKey: ['ss/attendance-alert', month],
queryFn: () => api.get('/smart-scheduling/attendance-alert', { params: { month } }),
})
const summaryRows = (summaryData as any)?.data || []
@@ -41,49 +27,6 @@ export function AttendanceAlertTab() {
const storeNames = [...new Set(summaryRows.map((r: any) => r.store_name))].sort() as string[]
const summarySorts = [
{ key: 'emp_count', label: '人数' },
{ key: 'avg_attend_days', label: '均出勤天' },
{ key: 'avg_hours', label: '均工时' },
{ key: 'total_absent_days', label: '旷工天数' },
{ key: 'total_late_deduction', label: '迟到扣款' },
{ key: 'low_attendance_rate_pct', label: '低出勤率' },
]
const summaryFiltered = useMemo(() => {
let r = summaryRows
if (summaryFilter) r = r.filter((s: any) => s.store_name === summaryFilter)
return [...r].sort((a: any, b: any) => {
const av = parseFloat(a[summarySort]) || 0
const bv = parseFloat(b[summarySort]) || 0
return summaryOrder === 'desc' ? bv - av : av - bv
})
}, [summaryRows, summaryFilter, summarySort, summaryOrder])
const summaryTotal = summaryFiltered.length
const summaryPaged = summaryFiltered.slice((summaryPage - 1) * PAGE_SIZE, summaryPage * PAGE_SIZE)
const alertSorts = [
{ key: 'attend_diff', label: '偏差天' },
{ key: 'absent_days', label: '旷工天' },
{ key: 'late_deduction', label: '迟到扣款' },
{ key: 'actual_hours', label: '实际工时' },
]
const alertFiltered = useMemo(() => {
let r = alertRows
if (alertFilter) r = r.filter((s: any) => s.store_name === alertFilter)
if (alertLevelFilter) r = r.filter((s: any) => s.alert_level === alertLevelFilter)
return [...r].sort((a: any, b: any) => {
const av = parseFloat(a[alertSort]) || 0
const bv = parseFloat(b[alertSort]) || 0
return alertOrder === 'desc' ? bv - av : av - bv
})
}, [alertRows, alertFilter, alertLevelFilter, alertSort, alertOrder])
const alertTotal = alertFiltered.length
const alertPaged = alertFiltered.slice((alertPage - 1) * PAGE_SIZE, alertPage * PAGE_SIZE)
const redCount = alertRows.filter((r: any) => r.alert_level === 'red').length
const orangeCount = alertRows.filter((r: any) => r.alert_level === 'orange').length
const yellowCount = alertRows.filter((r: any) => r.alert_level === 'yellow').length
@@ -108,21 +51,20 @@ export function AttendanceAlertTab() {
</div>
<CollapsibleSection title="门店考勤汇总" subtitle="各门店出勤天数、旷工、迟到扣款、低出勤率">
<div className="flex gap-2 mb-3 flex-wrap">
<select value={summaryFilter} onChange={(e) => { setSummaryFilter(e.target.value); setSummaryPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<span className="mx-2 text-muted-foreground">|</span>
{summarySorts.map(s => (
<button key={s.key} onClick={() => { setSummarySort(s.key); setSummaryPage(1) }} className={`px-3 py-1 rounded text-xs ${summarySort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setSummaryOrder(summaryOrder === 'asc' ? 'desc' : 'asc'); setSummaryPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{summaryOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={summaryRows}
sortOptions={[
{ key: 'emp_count', label: '人数' },
{ key: 'avg_attend_days', label: '均出勤天' },
{ key: 'avg_hours', label: '均工时' },
{ key: 'total_absent_days', label: '旷工天数' },
{ key: 'total_late_deduction', label: '迟到扣款' },
{ key: 'low_attendance_rate_pct', label: '低出勤率' },
]}
defaultSort="emp_count"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'emp_count', label: '人数', align: 'right', render: (r) => formatNumber(r.emp_count) },
@@ -134,33 +76,29 @@ export function AttendanceAlertTab() {
{ key: 'punch_issue_count', label: '打卡异常人数', align: 'right', render: (r) => <span className={parseFloat(r.punch_issue_count) > 5 ? 'text-orange-600' : ''}>{r.punch_issue_count || 0}</span> },
{ key: 'low_attendance_rate_pct', label: '低出勤率%', align: 'right', render: (r) => <span className={parseFloat(r.low_attendance_rate_pct) > 20 ? 'text-red-600 font-medium' : ''}>{r.low_attendance_rate_pct ? r.low_attendance_rate_pct + '%' : '0%'}</span> },
]}
data={summaryPaged}
/>
<Pagination page={summaryPage} pageSize={PAGE_SIZE} total={summaryTotal} onPageChange={setSummaryPage} />
</CollapsibleSection>
<CollapsibleSection title="考勤异常明细" subtitle="员工级考勤预警列表(按严重程度排序)">
<div className="flex gap-2 mb-3 flex-wrap">
<select value={alertFilter} onChange={(e) => { setAlertFilter(e.target.value); setAlertPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select value={alertLevelFilter} onChange={(e) => { setAlertLevelFilter(e.target.value); setAlertPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
<option value="red"></option>
<option value="orange"></option>
<option value="yellow"></option>
</select>
<span className="mx-2 text-muted-foreground">|</span>
{alertSorts.map(s => (
<button key={s.key} onClick={() => { setAlertSort(s.key); setAlertPage(1) }} className={`px-3 py-1 rounded text-xs ${alertSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setAlertOrder(alertOrder === 'asc' ? 'desc' : 'asc'); setAlertPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{alertOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={alertRows}
sortOptions={[
{ key: 'attend_diff', label: '偏差天' },
{ key: 'absent_days', label: '旷工天' },
{ key: 'late_deduction', label: '迟到扣款' },
{ key: 'actual_hours', label: '实际工时' },
]}
defaultSort="attend_diff"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
statusFilterKey="alert_level"
statusFilterLabel="全部等级"
statusOptions={[
{ value: 'red', label: '红色' },
{ value: 'orange', label: '橙色' },
{ value: 'yellow', label: '黄色' },
]}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'employee_code', label: '工号' },
@@ -174,9 +112,7 @@ export function AttendanceAlertTab() {
{ key: 'actual_hours', label: '实际工时', align: 'right', render: (r) => formatNumber(r.actual_hours) },
{ key: 'alert_type', label: '预警类型', render: (r) => <span className={`px-2 py-0.5 rounded text-xs border ${LEVEL_BG[r.alert_level] || ''}`}>{r.alert_type}</span> },
]}
data={alertPaged}
/>
<Pagination page={alertPage} pageSize={PAGE_SIZE} total={alertTotal} onPageChange={setAlertPage} />
</CollapsibleSection>
</div>
)
@@ -1,82 +1,57 @@
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
const PAGE_SIZE = 20
export function EfficiencyBenchmarkTab() {
export function EfficiencyBenchmarkTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('revenue_per_emp')
const [order, setOrder] = useState('desc')
const [posPage, setPosPage] = useState(1)
const [posFilter, setPosFilter] = useState('')
const [posSort, setPosSort] = useState('total_emp')
const [posOrder, setPosOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery({
queryKey: ['ss/efficiency', page, sort, order],
queryFn: () => api.get(`/smart-scheduling/efficiency-ranking?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}`),
queryKey: ['ss/efficiency', page, sort, order, month],
queryFn: () => api.get(`/smart-scheduling/efficiency-ranking`, { params: { page, page_size: PAGE_SIZE, sort, order, month } }),
})
const { data: posData } = useQuery({
queryKey: ['ss/position-distribution'],
queryFn: () => api.get('/smart-scheduling/position-distribution'),
queryKey: ['ss/position-distribution', month],
queryFn: () => api.get('/smart-scheduling/position-distribution', { params: { month } }),
})
const rows = (data as any)?.data || []
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
const posRows = (posData as any)?.data || []
const posStoreNames = [...new Set(posRows.map((r: any) => r.store_name))].sort() as string[]
const posSorts = [
{ key: 'total_emp', label: '总人数' },
{ key: 'manager_pct', label: '管理占比' },
{ key: 'kitchen_pct', label: '后厨占比' },
{ key: 'front_pct', label: '前厅占比' },
{ key: 'part_time_count', label: '兼职数' },
]
const posFiltered = useMemo(() => {
let r = posRows
if (posFilter) r = r.filter((s: any) => s.store_name === posFilter)
return [...r].sort((a: any, b: any) => {
const av = parseFloat(a[posSort]) || 0
const bv = parseFloat(b[posSort]) || 0
return posOrder === 'desc' ? bv - av : av - bv
})
}, [posRows, posFilter, posSort, posOrder])
const posTotal = posFiltered.length
const posPaged = posFiltered.slice((posPage - 1) * PAGE_SIZE, posPage * PAGE_SIZE)
if (isLoading) return <LoadingSpinner text="加载人效数据..." />
const sorts = [
{ key: 'revenue_per_emp', label: '人均创收' },
{ key: 'emp_count', label: '人数' },
{ key: 'gross_pay', label: '应发工资' },
{ key: 'wage_rate', label: '人力成本率' },
{ key: 'avg_hours', label: '人均工时' },
]
return (
<div className="space-y-4">
<CollapsibleSection title="门店人效排名" subtitle="人均创收、人力成本率、人均工时、加班占比">
<div className="flex gap-2 mb-3 flex-wrap">
{sorts.map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
sortOptions={[
{ key: 'revenue_per_emp', label: '人均创收' },
{ key: 'emp_count', label: '人数' },
{ key: 'gross_pay', label: '应发工资' },
{ key: 'wage_rate', label: '人力成本率' },
{ key: 'avg_hours', label: '人均工时' },
]}
defaultSort="revenue_per_emp"
defaultOrder="desc"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'emp_count', label: '人数', align: 'right', render: (r) => formatNumber(r.emp_count) },
@@ -89,27 +64,23 @@ export function EfficiencyBenchmarkTab() {
{ key: 'revenue_per_hour', label: '时均创收', align: 'right', render: (r) => formatCurrency(r.revenue_per_hour) },
{ key: 'overtime_rate', label: '加班占比%', align: 'right', render: (r) => <span className={parseFloat(r.overtime_rate) > 5 ? 'text-orange-600' : ''}>{formatPercent(r.overtime_rate)}</span> },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
<CollapsibleSection title="岗位配比分析" subtitle="各门店前厅/后厨/管理岗位人数及占比">
<div className="flex gap-2 mb-3 flex-wrap">
<select value={posFilter} onChange={(e) => { setPosFilter(e.target.value); setPosPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{posStoreNames.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<span className="mx-2 text-muted-foreground">|</span>
{posSorts.map(s => (
<button key={s.key} onClick={() => { setPosSort(s.key); setPosPage(1) }} className={`px-3 py-1 rounded text-xs ${posSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setPosOrder(posOrder === 'asc' ? 'desc' : 'asc'); setPosPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{posOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={posRows}
sortOptions={[
{ key: 'total_emp', label: '总人数' },
{ key: 'manager_pct', label: '管理占比' },
{ key: 'kitchen_pct', label: '后厨占比' },
{ key: 'front_pct', label: '前厅占比' },
{ key: 'part_time_count', label: '兼职数' },
]}
defaultSort="total_emp"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'total_emp', label: '总人数', align: 'right', render: (r) => formatNumber(r.total_emp) },
@@ -121,9 +92,7 @@ export function EfficiencyBenchmarkTab() {
{ key: 'front_pct', label: '前厅占比%', align: 'right', render: (r) => `${r.front_pct}%` },
{ key: 'part_time_count', label: '兼职', align: 'right', render: (r) => formatNumber(r.part_time_count) },
]}
data={posPaged}
/>
<Pagination page={posPage} pageSize={PAGE_SIZE} total={posTotal} onPageChange={setPosPage} />
</CollapsibleSection>
</div>
)
@@ -1,9 +1,8 @@
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatNumber } from '@/lib/utils'
@@ -15,105 +14,64 @@ const STATUS_COLORS: Record<string, string> = {
'在职': 'text-green-600',
}
export function EmployeeAnalysisTab() {
export function EmployeeAnalysisTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('gross_pay')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const [storeFilter, setStoreFilter] = useState('')
const [posPage, setPosPage] = useState(1)
const [turnoverPage, setTurnoverPage] = useState(1)
const [posSort, setPosSort] = useState('avg_gross')
const [posOrder, setPosOrder] = useState('desc')
const [turnoverSort, setTurnoverSort] = useState('turnover_rate')
const [turnoverOrder, setTurnoverOrder] = useState('desc')
const { data, isLoading } = useQuery({
queryKey: ['ss/employee', page, sort, order, storeFilter],
queryFn: () => api.get(`/smart-scheduling/employee-analysis?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}${storeFilter ? `&store=${storeFilter}` : ''}`),
queryKey: ['ss/employee', page, sort, order, storeFilter, month],
queryFn: () => api.get(`/smart-scheduling/employee-analysis`, { params: { page, page_size: PAGE_SIZE, sort, order, month, ...(storeFilter ? { store: storeFilter } : {}) } }),
})
const { data: posData } = useQuery({
queryKey: ['ss/position-salary'],
queryFn: () => api.get('/smart-scheduling/position-salary-compare'),
queryKey: ['ss/position-salary', month],
queryFn: () => api.get('/smart-scheduling/position-salary-compare', { params: { month } }),
})
const { data: turnoverData } = useQuery({
queryKey: ['ss/turnover'],
queryFn: () => api.get('/smart-scheduling/turnover-stats'),
queryKey: ['ss/turnover', month],
queryFn: () => api.get('/smart-scheduling/turnover-stats', { params: { month } }),
})
const rows = (data as any)?.data || []
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
const posRows = (posData as any)?.data || []
const turnoverRows = (turnoverData as any)?.data || []
const posSorts = [
{ key: 'avg_gross', label: '均应发' },
{ key: 'emp_count', label: '人数' },
{ key: 'store_count', label: '门店数' },
{ key: 'avg_hourly_rate', label: '均时薪' },
{ key: 'avg_hours', label: '均工时' },
]
const posFiltered = useMemo(() => {
return [...posRows].sort((a: any, b: any) => {
const av = parseFloat(a[posSort]) || 0
const bv = parseFloat(b[posSort]) || 0
return posOrder === 'desc' ? bv - av : av - bv
})
}, [posRows, posSort, posOrder])
const posTotal = posFiltered.length
const posPaged = posFiltered.slice((posPage - 1) * PAGE_SIZE, posPage * PAGE_SIZE)
const turnoverSorts = [
{ key: 'turnover_rate', label: '离职率' },
{ key: 'left_count', label: '离职人数' },
{ key: 'new_hire_rate', label: '新员工占比' },
{ key: 'total_emp', label: '总人数' },
]
const turnoverFiltered = useMemo(() => {
return [...turnoverRows].sort((a: any, b: any) => {
const av = parseFloat(a[turnoverSort]) || 0
const bv = parseFloat(b[turnoverSort]) || 0
return turnoverOrder === 'desc' ? bv - av : av - bv
})
}, [turnoverRows, turnoverSort, turnoverOrder])
const turnoverTotal = turnoverFiltered.length
const turnoverPaged = turnoverFiltered.slice((turnoverPage - 1) * PAGE_SIZE, turnoverPage * PAGE_SIZE)
const storeNames = [...new Set(turnoverRows.map((r: any) => r.store_name))].sort() as string[]
if (isLoading) return <LoadingSpinner text="加载员工数据..." />
const sorts = [
{ key: 'gross_pay', label: '应发工资' },
{ key: 'net_pay', label: '实发工资' },
{ key: 'actual_hours', label: '工时' },
{ key: 'perf_amount', label: '绩效' },
{ key: 'overtime_pay', label: '加班费' },
{ key: 'hourly_rate', label: '时薪' },
]
return (
<div className="space-y-4">
<CollapsibleSection title="员工薪资明细" subtitle="员工级薪资构成、工时、绩效、有效时薪">
<div className="flex gap-2 mb-3 flex-wrap">
<select value={storeFilter} onChange={(e) => { setStoreFilter(e.target.value); setPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{storeNames.map((s: any) => <option key={s} value={s}>{s}</option>)}
</select>
<span className="mx-2 text-muted-foreground">|</span>
{sorts.map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={storeFilter}
onFilterChange={setStoreFilter}
filterKey="store_name"
filterLabel="全部门店"
filterOptions={storeNames}
sortOptions={[
{ key: 'gross_pay', label: '应发工资' },
{ key: 'net_pay', label: '实发工资' },
{ key: 'actual_hours', label: '工时' },
{ key: 'perf_amount', label: '绩效' },
{ key: 'overtime_pay', label: '加班费' },
{ key: 'hourly_rate', label: '时薪' },
]}
defaultSort="gross_pay"
defaultOrder="desc"
columns={[
{ key: 'employee_code', label: '工号' },
{ key: 'store_name', label: '门店' },
@@ -129,22 +87,24 @@ export function EmployeeAnalysisTab() {
{ key: 'effective_hourly_rate', label: '有效时薪', align: 'right', render: (r) => <span className={parseFloat(r.effective_hourly_rate) > 100 ? 'text-green-600' : parseFloat(r.effective_hourly_rate) < 50 ? 'text-red-600' : ''}>{formatCurrency(r.effective_hourly_rate)}</span> },
{ key: 'overtime_rate', label: '加班占比%', align: 'right', render: (r) => <span className={parseFloat(r.overtime_rate) > 5 ? 'text-orange-600' : ''}>{r.overtime_rate}%</span> },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
<CollapsibleSection title="岗位薪资对比" subtitle="相同岗位在不同门店的薪资水平差异">
<div className="flex gap-2 mb-3 flex-wrap">
{posSorts.map(s => (
<button key={s.key} onClick={() => { setPosSort(s.key); setPosPage(1) }} className={`px-3 py-1 rounded text-xs ${posSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setPosOrder(posOrder === 'asc' ? 'desc' : 'asc'); setPosPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{posOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={posRows}
filterKey="position"
filterLabel="全部岗位"
sortOptions={[
{ key: 'avg_gross', label: '均应发' },
{ key: 'emp_count', label: '人数' },
{ key: 'store_count', label: '门店数' },
{ key: 'avg_hourly_rate', label: '均时薪' },
{ key: 'avg_hours', label: '均工时' },
]}
defaultSort="avg_gross"
defaultOrder="desc"
pageSize={PAGE_SIZE}
columns={[
{ key: 'position', label: '岗位' },
{ key: 'emp_count', label: '人数', align: 'right', render: (r) => formatNumber(r.emp_count) },
@@ -157,22 +117,22 @@ export function EmployeeAnalysisTab() {
{ key: 'avg_perf_score', label: '均绩效分', align: 'right', render: (r) => r.avg_perf_score },
{ key: 'avg_hourly_rate', label: '均有效时薪', align: 'right', render: (r) => formatCurrency(r.avg_hourly_rate) },
]}
data={posPaged}
/>
<Pagination page={posPage} pageSize={PAGE_SIZE} total={posTotal} onPageChange={setPosPage} />
</CollapsibleSection>
<CollapsibleSection title="离职率统计" subtitle="各门店离职率、新员工占比">
<div className="flex gap-2 mb-3 flex-wrap">
{turnoverSorts.map(s => (
<button key={s.key} onClick={() => { setTurnoverSort(s.key); setTurnoverPage(1) }} className={`px-3 py-1 rounded text-xs ${turnoverSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setTurnoverOrder(turnoverOrder === 'asc' ? 'desc' : 'asc'); setTurnoverPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{turnoverOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={turnoverRows}
sortOptions={[
{ key: 'turnover_rate', label: '离职率' },
{ key: 'left_count', label: '离职人数' },
{ key: 'new_hire_rate', label: '新员工占比' },
{ key: 'total_emp', label: '总人数' },
]}
defaultSort="turnover_rate"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'total_emp', label: '总人数', align: 'right', render: (r) => formatNumber(r.total_emp) },
@@ -181,9 +141,7 @@ export function EmployeeAnalysisTab() {
{ key: 'new_count', label: '新员工数', align: 'right', render: (r) => <span className="text-blue-500">{r.new_count}</span> },
{ key: 'new_hire_rate', label: '新员工占比%', align: 'right', render: (r) => <span className={parseFloat(r.new_hire_rate) > 20 ? 'text-orange-600' : ''}>{r.new_hire_rate ? r.new_hire_rate + '%' : '0%'}</span> },
]}
data={turnoverPaged}
/>
<Pagination page={turnoverPage} pageSize={PAGE_SIZE} total={turnoverTotal} onPageChange={setTurnoverPage} />
</CollapsibleSection>
</div>
)
@@ -18,10 +18,10 @@ const CATEGORY_LABELS: Record<string, string> = {
'离职': '人员稳定',
}
export function OverallAnalysisTab() {
export function OverallAnalysisTab({ month }: { month: string }) {
const { data, isLoading } = useQuery({
queryKey: ['ss/overall-analysis'],
queryFn: () => api.get('/smart-scheduling/overall-analysis'),
queryKey: ['ss/overall-analysis', month],
queryFn: () => api.get('/smart-scheduling/overall-analysis', { params: { month } }),
})
if (isLoading) return <LoadingSpinner text="生成总体智能分析..." />
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatNumber } from '@/lib/utils'
@@ -56,7 +56,7 @@ const tableColumns = [
{ key: 'action', label: '建议', render: (r: any) => <span className={ACTION_COLORS[r.action] || ''}>{r.action}</span> },
]
export function SchedulingSuggestionTab() {
export function SchedulingSuggestionTab({ month }: { month: string }) {
const { data: storesData } = useQuery({
queryKey: ['ss/stores'],
queryFn: () => api.get('/smart-scheduling/stores'),
@@ -67,8 +67,8 @@ export function SchedulingSuggestionTab() {
const [kitchenTarget, setKitchenTarget] = useState(25)
const { data, isLoading } = useQuery({
queryKey: ['ss/suggestion', storeName, frontTarget, kitchenTarget],
queryFn: () => api.get(`/smart-scheduling/scheduling-suggestion?store=${storeName}&front_target=${frontTarget}&kitchen_target=${kitchenTarget}`),
queryKey: ['ss/suggestion', storeName, frontTarget, kitchenTarget, month],
queryFn: () => api.get(`/smart-scheduling/scheduling-suggestion?store=${storeName}&front_target=${frontTarget}&kitchen_target=${kitchenTarget}&month=${month}`),
enabled: !!storeName,
})
@@ -123,11 +123,35 @@ export function SchedulingSuggestionTab() {
<div className="space-y-4">
<div>
<h3 className="text-sm font-bold mb-2"></h3>
<DataTable columns={tableColumns} data={workdayRows} rowClassName={(r) => ROW_BG[r.action] || ''} />
<FilterableTable
data={workdayRows}
sortOptions={[
{ key: 'hour', label: '时段' },
{ key: 'avg_daily_bills', label: '日均账单' },
{ key: 'staff_gap', label: '总缺口' },
]}
defaultSort="hour"
defaultOrder="asc"
filterKey="action"
filterLabel="全部建议"
columns={tableColumns}
/>
</div>
<div>
<h3 className="text-sm font-bold mb-2"></h3>
<DataTable columns={tableColumns} data={weekendRows} rowClassName={(r) => ROW_BG[r.action] || ''} />
<FilterableTable
data={weekendRows}
sortOptions={[
{ key: 'hour', label: '时段' },
{ key: 'avg_daily_bills', label: '日均账单' },
{ key: 'staff_gap', label: '总缺口' },
]}
defaultSort="hour"
defaultOrder="asc"
filterKey="action"
filterLabel="全部建议"
columns={tableColumns}
/>
</div>
</div>
</>
@@ -20,7 +20,7 @@ const LEVEL_ROW_STYLES: Record<string, string> = {
yellow: 'bg-yellow-50/30',
}
export function StaffingForecastTab() {
export function StaffingForecastTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [storeFilter, setStoreFilter] = useState('')
const [actionFilter, setActionFilter] = useState('')
@@ -30,8 +30,8 @@ export function StaffingForecastTab() {
const [showRules, setShowRules] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['ss/staffing-forecast'],
queryFn: () => api.get('/smart-scheduling/staffing-forecast'),
queryKey: ['ss/staffing-forecast', month],
queryFn: () => api.get('/smart-scheduling/staffing-forecast', { params: { month } }),
})
const result = (data as any)?.data || {}
@@ -2,7 +2,6 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatNumber } from '@/lib/utils'
@@ -14,7 +13,7 @@ const STATUS_COLORS: Record<string, string> = {
'过剩': 'text-gray-400',
}
export function StaffingMatchTab() {
export function StaffingMatchTab({ month }: { month: string }) {
const { data: storesData } = useQuery({
queryKey: ['ss/stores'],
queryFn: () => api.get('/smart-scheduling/stores'),
@@ -23,8 +22,8 @@ export function StaffingMatchTab() {
const [storeName, setStoreName] = useState('七里庄店')
const { data, isLoading } = useQuery({
queryKey: ['ss/staffing-match', storeName],
queryFn: () => api.get(`/smart-scheduling/staffing-match?store=${storeName}`),
queryKey: ['ss/staffing-match', storeName, month],
queryFn: () => api.get('/smart-scheduling/staffing-match', { params: { store: storeName, month } }),
enabled: !!storeName,
})
@@ -2,42 +2,31 @@ import { useState, useMemo, Fragment } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatNumber } from '@/lib/utils'
const PAGE_SIZE = 20
export function TrafficHeatmapTab() {
export function TrafficHeatmapTab({ month }: { month: string }) {
const [storeName, setStoreName] = useState('')
const [overviewPage, setOverviewPage] = useState(1)
const [mealPage, setMealPage] = useState(1)
const [overviewSort, setOverviewSort] = useState('total_bills')
const [overviewOrder, setOverviewOrder] = useState('desc')
const [overviewFilter, setOverviewFilter] = useState('')
const [mealSort, setMealSort] = useState('total')
const [mealOrder, setMealOrder] = useState('desc')
const [mealFilter, setMealFilter] = useState('')
const { data: overview } = useQuery({
queryKey: ['ss/traffic-overview'],
queryFn: () => api.get('/smart-scheduling/traffic-overview'),
queryKey: ['ss/traffic-overview', month],
queryFn: () => api.get('/smart-scheduling/traffic-overview', { params: { month } }),
})
const { data: heatmap, isLoading } = useQuery({
queryKey: ['ss/traffic-heatmap', storeName],
queryFn: () => api.get(`/smart-scheduling/traffic-heatmap${storeName ? `?store=${storeName}` : ''}`),
queryKey: ['ss/traffic-heatmap', storeName, month],
queryFn: () => api.get('/smart-scheduling/traffic-heatmap', { params: { store: storeName || undefined, month } }),
})
const { data: staffing } = useQuery({
queryKey: ['ss/traffic-heatmap-staffing', storeName],
queryFn: () => api.get(`/smart-scheduling/traffic-heatmap-staffing${storeName ? `?store=${storeName}` : ''}`),
queryKey: ['ss/traffic-heatmap-staffing', storeName, month],
queryFn: () => api.get('/smart-scheduling/traffic-heatmap-staffing', { params: { store: storeName || undefined, month } }),
})
const { data: mealPeriod } = useQuery({
queryKey: ['ss/meal-period-traffic', storeName],
queryFn: () => api.get(`/smart-scheduling/meal-period-traffic${storeName ? `?store=${storeName}` : ''}`),
queryKey: ['ss/meal-period-traffic', storeName, month],
queryFn: () => api.get('/smart-scheduling/meal-period-traffic', { params: { store: storeName || undefined, month } }),
})
const stores = (overview as any)?.data || []
@@ -80,29 +69,8 @@ export function TrafficHeatmapTab() {
mealMap[r.store_name][r.meal_period] = r.bills
})
const overviewSorts = [
{ key: 'total_bills', label: '月账单量' },
{ key: 'peak_bills', label: '峰值' },
{ key: 'peak_valley_ratio', label: '峰谷比' },
{ key: 'peak_concentration_pct', label: '集中度' },
]
const overviewFiltered = useMemo(() => {
let r = stores
if (overviewFilter) r = r.filter((s: any) => s.store_name.includes(overviewFilter))
r = [...r].sort((a: any, b: any) => {
const av = parseFloat(a[overviewSort]) || 0
const bv = parseFloat(b[overviewSort]) || 0
return overviewOrder === 'desc' ? bv - av : av - bv
})
return r
}, [stores, overviewFilter, overviewSort, overviewOrder])
const overviewTotal = overviewFiltered.length
const overviewPaged = overviewFiltered.slice((overviewPage - 1) * PAGE_SIZE, overviewPage * PAGE_SIZE)
const mealStores = useMemo(() => {
let r = Object.keys(mealMap).map(s => ({
return Object.keys(mealMap).map(s => ({
store_name: s,
早市: mealMap[s]?.['早市'] || 0,
午市: mealMap[s]?.['午市'] || 0,
@@ -111,25 +79,7 @@ export function TrafficHeatmapTab() {
夜宵: mealMap[s]?.['夜宵'] || 0,
total: Object.values(mealMap[s] || {}).reduce((a: number, b: number) => a + b, 0),
}))
if (mealFilter) r = r.filter((s: any) => s.store_name.includes(mealFilter))
r = [...r].sort((a: any, b: any) => {
const av = parseFloat(a[mealSort]) || 0
const bv = parseFloat(b[mealSort]) || 0
return mealOrder === 'desc' ? bv - av : av - bv
})
return r
}, [mealMap, mealFilter, mealSort, mealOrder])
const mealTotal = mealStores.length
const mealPaged = mealStores.slice((mealPage - 1) * PAGE_SIZE, mealPage * PAGE_SIZE)
const mealSorts = [
{ key: 'total', label: '总账单' },
{ key: '午市', label: '午市' },
{ key: '晚市', label: '晚市' },
{ key: '早市', label: '早市' },
{ key: '夜宵', label: '夜宵' },
]
}, [mealMap])
return (
<div className="space-y-4">
@@ -182,21 +132,18 @@ export function TrafficHeatmapTab() {
</CollapsibleSection>
<CollapsibleSection title="门店客流概览" subtitle="各门店月度账单量、峰谷比、客流集中度">
<div className="flex gap-2 mb-3 flex-wrap">
<select value={overviewFilter} onChange={(e) => { setOverviewFilter(e.target.value); setOverviewPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<span className="mx-2 text-muted-foreground">|</span>
{overviewSorts.map(s => (
<button key={s.key} onClick={() => { setOverviewSort(s.key); setOverviewPage(1) }} className={`px-3 py-1 rounded text-xs ${overviewSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOverviewOrder(overviewOrder === 'asc' ? 'desc' : 'asc'); setOverviewPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{overviewOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={stores}
sortOptions={[
{ key: 'total_bills', label: '月账单量' },
{ key: 'peak_bills', label: '峰值' },
{ key: 'peak_valley_ratio', label: '峰谷比' },
{ key: 'peak_concentration_pct', label: '集中度' },
]}
defaultSort="total_bills"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'total_bills', label: '月账单量', align: 'right', render: (r) => formatNumber(r.total_bills) },
@@ -206,27 +153,23 @@ export function TrafficHeatmapTab() {
{ key: 'peak_concentration_pct', label: '高峰集中度%', align: 'right', render: (r) => <span className={parseFloat(r.peak_concentration_pct) > 60 ? 'text-orange-600 font-medium' : ''}>{r.peak_concentration_pct}%</span> },
{ key: 'volatility_level', label: '波动等级', render: (r) => <span className={r.volatility_level === '波动极大' ? 'text-red-600' : r.volatility_level === '波动较大' ? 'text-orange-600' : ''}>{r.volatility_level}</span> },
]}
data={overviewPaged}
/>
<Pagination page={overviewPage} pageSize={PAGE_SIZE} total={overviewTotal} onPageChange={setOverviewPage} />
</CollapsibleSection>
<CollapsibleSection title="餐段客流分布" subtitle="各门店早市/午市/下午茶/晚市/夜宵账单占比">
<div className="flex gap-2 mb-3 flex-wrap">
<select value={mealFilter} onChange={(e) => { setMealFilter(e.target.value); setMealPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<span className="mx-2 text-muted-foreground">|</span>
{mealSorts.map(s => (
<button key={s.key} onClick={() => { setMealSort(s.key); setMealPage(1) }} className={`px-3 py-1 rounded text-xs ${mealSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setMealOrder(mealOrder === 'asc' ? 'desc' : 'asc'); setMealPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{mealOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={mealStores}
sortOptions={[
{ key: 'total', label: '总账单' },
{ key: '午市', label: '午市' },
{ key: '晚市', label: '晚市' },
{ key: '早市', label: '早市' },
{ key: '夜宵', label: '夜宵' },
]}
defaultSort="total"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: '早市', label: '早市', align: 'right', render: (r) => formatNumber(r['早市']) },
@@ -235,9 +178,7 @@ export function TrafficHeatmapTab() {
{ key: '晚市', label: '晚市', align: 'right', render: (r) => formatNumber(r['晚市']) },
{ key: '夜宵', label: '夜宵', align: 'right', render: (r) => formatNumber(r['夜宵']) },
]}
data={mealPaged}
/>
<Pagination page={mealPage} pageSize={PAGE_SIZE} total={mealTotal} onPageChange={setMealPage} />
</CollapsibleSection>
</div>
)
@@ -2,8 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent } from '@/lib/utils'
@@ -21,7 +20,7 @@ export function BreakEvenTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('safety_margin_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('asc')
const [order, setOrder] = useState<'asc' | 'desc'>('asc')
const { data, isLoading } = useQuery({ queryKey: ['se/break-even', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/break-even?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
if (isLoading) return <LoadingSpinner text="加载盈亏平衡分析..." />
@@ -46,20 +45,23 @@ export function BreakEvenTab({ month }: { month: string }) {
return (
<div className="space-y-4">
<CollapsibleSection title="盈亏平衡分析" subtitle="盈亏平衡销售额 = 固定费用 ÷ (1 - 食材成本率),安全边际 = (实收 - 平衡点) ÷ 平衡点">
<div className="flex gap-2 mb-3 flex-wrap">
{filters.map(f => (
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{sorts.map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="safety_margin_pct"
defaultOrder="asc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -72,9 +74,7 @@ export function BreakEvenTab({ month }: { month: string }) {
{ key: 'safety_margin_pct', label: '安全边际率', align: 'right', render: (r) => <span className={parseFloat(r.safety_margin_pct) < 0 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.safety_margin_pct)}</span> },
{ key: 'safety_status', label: '安全状态', render: (r) => <span className={SAFETY_COLORS[r.safety_status] || ''}>{r.safety_status}</span> },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
</div>
)
@@ -2,8 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent } from '@/lib/utils'
@@ -13,7 +12,7 @@ export function ContributionTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('actual_store_contribution_rate_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('asc')
const [order, setOrder] = useState<'asc' | 'desc'>('asc')
const { data, isLoading } = useQuery({ queryKey: ['se/contribution', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-contribution?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
if (isLoading) return <LoadingSpinner text="加载门店贡献利润..." />
@@ -39,20 +38,23 @@ export function ContributionTab({ month }: { month: string }) {
return (
<div className="space-y-4">
<CollapsibleSection title="门店贡献利润分析" subtitle="实收 - 食材成本 - 营业费用 = 门店贡献利润">
<div className="flex gap-2 mb-3 flex-wrap">
{filters.map(f => (
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{sorts.map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="actual_store_contribution_rate_pct"
defaultOrder="asc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -65,9 +67,7 @@ export function ContributionTab({ month }: { month: string }) {
{ key: 'actual_store_contribution_rate_pct', label: '实际贡献率', align: 'right', render: (r) => <span className={parseFloat(r.actual_store_contribution_rate_pct) < 0 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.actual_store_contribution_rate_pct)}</span> },
{ key: 'contribution_variance', label: '贡献差异', align: 'right', render: (r) => <span className={parseFloat(r.contribution_variance) < 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(r.contribution_variance)}</span> },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
</div>
)
@@ -2,8 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent } from '@/lib/utils'
@@ -13,7 +12,7 @@ export function DeliveryTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('commission_to_delivery_sales_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery({ queryKey: ['se/delivery', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/delivery-commission?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
if (isLoading) return <LoadingSpinner text="加载外卖佣金分析..." />
@@ -38,20 +37,23 @@ export function DeliveryTab({ month }: { month: string }) {
return (
<div className="space-y-4">
<CollapsibleSection title="外卖佣金分析" subtitle="外卖收入占比、佣金率及综合平台成本率">
<div className="flex gap-2 mb-3 flex-wrap">
{filters.map(f => (
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{sorts.map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="commission_to_delivery_sales_pct"
defaultOrder="desc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -63,9 +65,7 @@ export function DeliveryTab({ month }: { month: string }) {
{ key: 'delivery_type', label: '外卖类型' },
{ key: 'suggestion', label: '建议' },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
</div>
)
@@ -2,8 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
@@ -13,7 +12,7 @@ export function EfficiencyTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('received_per_sqm')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery({ queryKey: ['se/efficiency', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/efficiency?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
if (isLoading) return <LoadingSpinner text="加载人效坪效数据..." />
@@ -38,20 +37,23 @@ export function EfficiencyTab({ month }: { month: string }) {
return (
<div className="space-y-4">
<CollapsibleSection title="人效坪效分析" subtitle="坪效(元/㎡) + 人工成本率 + 客单价 + 营业效率评估">
<div className="flex gap-2 mb-3 flex-wrap">
{filters.map(f => (
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{sorts.map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="received_per_sqm"
defaultOrder="desc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -65,9 +67,7 @@ export function EfficiencyTab({ month }: { month: string }) {
{ key: 'efficiency_level', label: '效率等级', render: (r) => <span className={r.efficiency_level === '坪效偏低' || r.efficiency_level === '坪效极低' ? 'text-red-600' : r.efficiency_level === '坪效优秀' ? 'text-green-600' : ''}>{r.efficiency_level}</span> },
{ key: 'suggestion', label: '建议' },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
</div>
)
@@ -2,8 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent } from '@/lib/utils'
@@ -13,7 +12,7 @@ export function FixedVariableTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('fixed_rate_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery({ queryKey: ['se/fixed-variable', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/fixed-variable?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
if (isLoading) return <LoadingSpinner text="加载固定/变动费用拆分..." />
@@ -38,20 +37,23 @@ export function FixedVariableTab({ month }: { month: string }) {
return (
<div className="space-y-4">
<CollapsibleSection title="固定/变动费用拆分" subtitle="固定费用(租金+宿舍+维修50%) vs 变动费用(人工+水电+佣金+刷卡+维修50%)">
<div className="flex gap-2 mb-3 flex-wrap">
{filters.map(f => (
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{sorts.map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="fixed_rate_pct"
defaultOrder="desc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -62,9 +64,7 @@ export function FixedVariableTab({ month }: { month: string }) {
{ key: 'contribution_after_variable', label: '边际贡献(扣变动)', align: 'right', render: (r) => <span className={parseFloat(r.contribution_after_variable) < 0 ? 'text-red-600 font-medium' : 'text-green-600'}>{formatCurrency(r.contribution_after_variable)}</span> },
{ key: 'break_even_sales', label: '盈亏平衡销售额', align: 'right', render: (r) => formatCurrency(r.break_even_sales) },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
</div>
)
@@ -2,8 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
@@ -189,7 +188,7 @@ export function LossDiagnosisTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('actual_store_contribution')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('asc')
const [order, setOrder] = useState<'asc' | 'desc'>('asc')
const [selectedStore, setSelectedStore] = useState<StoreDetail | null>(null)
const { data, isLoading } = useQuery({ queryKey: ['se/loss-diagnosis', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/loss-diagnosis?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
@@ -212,32 +211,36 @@ export function LossDiagnosisTab({ month }: { month: string }) {
</div>
<CollapsibleSection title="亏损门店诊断" subtitle="点击门店名称查看详情">
<div className="flex gap-2 mb-3 flex-wrap">
{[
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={[
{ key: '', label: '全部' },
{ key: 'P0', label: 'P0 紧急' },
{ key: 'P1', label: 'P1 整改' },
{ key: 'P2', label: 'P2 观察' },
].map(f => (
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{[
]}
sortOptions={[
{ key: 'actual_store_contribution', label: '贡献利润' },
{ key: 'actual_store_contribution_rate_pct', label: '贡献率' },
{ key: 'received', label: '实收' },
{ key: 'wage_rate_pct', label: '人工率' },
{ key: 'rent_rate_pct', label: '租金率' },
{ key: 'received_per_sqm', label: '坪效' },
].map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
]}
defaultSort="actual_store_contribution"
defaultOrder="asc"
onRowClick={(r) => setSelectedStore(r as StoreDetail)}
columns={[
{ key: 'sales_store_name', label: '门店', render: (r) => <button className="text-blue-600 hover:underline font-medium" onClick={() => setSelectedStore(r as StoreDetail)}>{r.sales_store_name}</button> },
{ key: 'priority', label: '优先级', render: (r) => <span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_COLORS[r.priority] || ''}`}>{r.priority}</span> },
@@ -251,9 +254,7 @@ export function LossDiagnosisTab({ month }: { month: string }) {
{ key: 'diagnosis_reasons', label: '亏损原因', render: (r) => <span className="text-xs">{r.diagnosis_reasons}</span> },
{ key: 'suggested_actions', label: '建议动作', render: (r) => <span className="text-xs font-medium">{r.suggested_actions}</span> },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
<StoreDetailModal store={selectedStore} onClose={() => setSelectedStore(null)} />
@@ -2,8 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
@@ -22,7 +21,7 @@ export function RentRiskTab({ month }: { month: string }) {
const [sort, setSort] = useState('rent_rate_pct')
const [filter, setFilter] = useState('')
const [riskOnly, setRiskOnly] = useState(false)
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery({ queryKey: ['se/rent', month, page, sort, filter, riskOnly, order], queryFn: () => api.get(`/store-expense/rent-risk?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&risk_only=${riskOnly}&order=${order}&month=${month}`) })
if (isLoading) return <LoadingSpinner text="加载房租及租约风险..." />
@@ -49,23 +48,27 @@ export function RentRiskTab({ month }: { month: string }) {
<div className="space-y-4">
<CollapsibleSection title="房租及租约风险" subtitle="租金占比 + 租约到期日,识别续租和关停风险门店">
<div className="flex gap-2 mb-3 flex-wrap">
{filters.map(f => (
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{sorts.map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setRiskOnly(!riskOnly); setPage(1) }} className={`px-3 py-1 rounded text-xs ${riskOnly ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
{riskOnly ? '显示全部' : '仅显示风险门店'}
</button>
</div>
<DataTable
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="rent_rate_pct"
defaultOrder="desc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -77,9 +80,7 @@ export function RentRiskTab({ month }: { month: string }) {
{ key: 'risk_level', label: '风险等级', render: (r) => <span className={RISK_COLORS[r.risk_level] || ''}>{r.risk_level}</span> },
{ key: 'suggestion', label: '建议' },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
</div>
)
@@ -2,8 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
@@ -111,7 +110,7 @@ export function StoreEvaluationTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('actual_store_contribution_rate_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('asc')
const [order, setOrder] = useState<'asc' | 'desc'>('asc')
const [selectedStore, setSelectedStore] = useState<EvalStoreDetail | null>(null)
const { data, isLoading } = useQuery({ queryKey: ['se/evaluation', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-evaluation?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
@@ -131,31 +130,35 @@ export function StoreEvaluationTab({ month }: { month: string }) {
</div>
<CollapsibleSection title="门店关停/续租/改造评估" subtitle="点击门店名称查看详情">
<div className="flex gap-2 mb-3 flex-wrap">
{[
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={[
{ key: '', label: '全部' },
{ key: 'profitable', label: '盈利门店' },
{ key: 'loss', label: '亏损门店' },
].map(f => (
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{[
]}
sortOptions={[
{ key: 'actual_store_contribution_rate_pct', label: '贡献率' },
{ key: 'received', label: '实收' },
{ key: 'actual_store_contribution', label: '贡献利润' },
{ key: 'received_per_sqm', label: '坪效' },
{ key: 'wage_rate_pct', label: '人工率' },
{ key: 'rent_rate_pct', label: '租金率' },
].map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
]}
defaultSort="actual_store_contribution_rate_pct"
defaultOrder="asc"
onRowClick={(r) => setSelectedStore(r as EvalStoreDetail)}
columns={[
{ key: 'sales_store_name', label: '门店', render: (r) => <button className="text-blue-600 hover:underline font-medium" onClick={() => setSelectedStore(r as EvalStoreDetail)}>{r.sales_store_name}</button> },
{ key: 'priority', label: '优先级', render: (r) => <span className={PRIORITY_COLORS[r.priority] || ''}>{r.priority}</span> },
@@ -167,9 +170,7 @@ export function StoreEvaluationTab({ month }: { month: string }) {
{ key: 'lease_expiry_date', label: '租约到期', render: (r) => r.lease_expiry_date ? new Date(r.lease_expiry_date).toLocaleDateString('zh-CN') : '-' },
{ key: 'evaluation_detail', label: '评估详情', render: (r) => <span className="text-xs">{r.evaluation_detail}</span> },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
<EvalDetailModal store={selectedStore} onClose={() => setSelectedStore(null)} />
@@ -2,8 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
@@ -13,7 +12,7 @@ export function StoreRankingTab({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('operating_expense_rate_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState('desc')
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery({ queryKey: ['se/ranking', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-ranking?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
@@ -41,20 +40,23 @@ export function StoreRankingTab({ month }: { month: string }) {
return (
<div className="space-y-4">
<CollapsibleSection title="门店费用率排名" subtitle="按费用率降序,支持筛选和排序">
<div className="flex gap-2 mb-3 flex-wrap">
{filters.map(f => (
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
{sorts.map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
))}
<span className="mx-2 text-muted-foreground">|</span>
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
</button>
</div>
<DataTable
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={filters}
sortOptions={sorts}
defaultSort="operating_expense_rate_pct"
defaultOrder="desc"
columns={[
{ key: 'sales_store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
@@ -67,9 +69,7 @@ export function StoreRankingTab({ month }: { month: string }) {
{ key: 'actual_store_contribution_rate_pct', label: '贡献率', align: 'right', render: (r) => formatPercent(r.actual_store_contribution_rate_pct) },
{ key: 'received_per_sqm', label: '坪效', align: 'right', render: (r) => formatNumber(r.received_per_sqm) },
]}
data={rows}
/>
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
</CollapsibleSection>
</div>
)
+131 -116
View File
@@ -7,6 +7,7 @@ import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { Landmark, Settings2, RotateCcw } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
// ============ 评分规则类型 ============
interface ScoringRule {
@@ -83,12 +84,13 @@ function getGrade(totalScore: number): { grade: string; color: string; label: st
export function BankPage() {
const [config, setConfig] = useState<ScoringConfig>(DEFAULT_CONFIG)
const [showConfig, setShowConfig] = useState(false)
const [month, setMonth] = useState('2026-04')
useEffect(() => { setConfig(loadConfig()) }, [])
const { data: reportData, isLoading } = useQuery({
queryKey: ['bank/report'],
queryFn: () => api.get('/bank/report'),
queryKey: ['bank/report', month],
queryFn: () => api.get('/bank/report', { params: { month } }),
})
const d = (reportData as any)?.data
@@ -101,8 +103,9 @@ export function BankPage() {
const stability = d?.stability
// 计算各项指标值
const hasData = ov && wf && stability && ov.received > 0 && daily.length > 0
const metrics = useMemo(() => {
if (!ov || !wf || !stability) return null
if (!hasData) return null
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)
@@ -181,11 +184,14 @@ export function BankPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-xl font-bold"><Landmark size={20} /> </h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · 20264</p>
<p className="mt-0.5 text-xs text-muted-foreground"> · </p>
</div>
<div className="flex items-center gap-2">
<MonthPicker month={month} onChange={setMonth} />
<button onClick={() => setShowConfig(!showConfig)} className="flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs hover:bg-accent">
<Settings2 size={14} />
</button>
</div>
<button onClick={() => setShowConfig(!showConfig)} className="flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs hover:bg-accent">
<Settings2 size={14} />
</button>
</div>
{/* 评分规则配置面板 */}
@@ -228,114 +234,9 @@ export function BankPage() {
</CollapsibleSection>
)}
{/* ① 企业经营概况 */}
<CollapsibleSection title="企业经营概况" subtitle="月度核心指标与日度营收趋势">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="月度实收" value={ov?.received} format="currency" description={`${formatNumber(ov?.bill_count)} 笔账单`} />
<MetricCard title="客单价" value={ov?.avg_bill_value} format="currency" description="实收 ÷ 账单数" />
<MetricCard title="理论毛利率" value={ov?.theoretical_margin_pct} format="percent" description="菜品定价毛利空间" />
<MetricCard title="门店数量" value={stores.length} unit="家" description={`活跃经营门店总数`} />
</div>
<div className="mt-4">
<ResponsiveContainer width="100%" height={220}>
<LineChart data={dailyChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '日实收']} />
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="日实收" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
</CollapsibleSection>
{/* ② 盈利能力分析 */}
<CollapsibleSection title="盈利能力分析" subtitle="利润瀑布 · 净利率 · 成本费用结构">
{wf && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<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.store_contribution / wf.received * 100 : 0} format="percent" description="门店贡献利润占实收比例" />
</div>
<div className="mt-4">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={waterfallChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="middle" height={60} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '金额']} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} shape={(props: any) => {
const { x, y, width, height, payload, fill } = props
if (!payload.value || payload.value === 0) return <rect />
const scale = height / payload.value
const newY = y - payload.base * scale
return <rect x={x} y={newY} width={width} height={height} fill={fill} rx={4} ry={4} />
}}>
{waterfallChart.map((entry, i) => <Cell key={i} fill={entry.fill} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</>
)}
</CollapsibleSection>
{/* ③ 门店资产质量 */}
<CollapsibleSection title="门店资产质量" subtitle="风险等级分布 · 营收TOP10">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={riskPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.name}: ${e.value}`}>
{riskPie.map((entry: any, i: number) => <Cell key={i} fill={entry.fill} />)}
</Pie>
<Tooltip formatter={(v: any, name: any) => [`${v}`, name]} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</PieChart>
</ResponsiveContainer>
</div>
<div>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={top10} layout="vertical" margin={{ top: 5, right: 10, left: 80, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="store_name" tick={{ fontSize: 9 }} width={80} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '实收']} />
<Bar dataKey="received" fill="#3b82f6" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</CollapsibleSection>
{/* ④ 经营稳定性指标 */}
<CollapsibleSection title="经营稳定性指标" subtitle="营收波动率 · 优惠率 · 会员占比 · 渠道结构">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="日度营收波动率" value={stability ? stability.cv * 100 : 0} format="percent" status={stability && stability.cv < 0.2 ? 'good' : stability && stability.cv < 0.3 ? 'warn' : 'bad'} description={`变异系数CV · 日均${formatCurrency(stability?.mean_daily_revenue)}`} />
<MetricCard title="优惠率" value={ov?.discount_rate_pct} format="percent" status={Number(ov?.discount_rate_pct) > 25 ? 'bad' : Number(ov?.discount_rate_pct) > 18 ? 'warn' : 'good'} description="优惠额 ÷ (实收+优惠额)" />
<MetricCard title="会员消费占比" value={ov?.member_share_pct} format="percent" status={Number(ov?.member_share_pct) > 15 ? 'good' : 'warn'} description="会员账单 ÷ 总账单" />
<MetricCard title="活跃渠道数" value={channelPie.length} unit="个" description="有交易记录的支付渠道" />
</div>
{channelPie.length > 0 && (
<div className="mt-4">
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={channelPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label={(e: any) => `${CHANNEL_LABELS[e.name] || e.name} ${totalChannel > 0 ? (e.value / totalChannel * 100).toFixed(1) : 0}%`}>
{channelPie.map((entry: any, i: number) => <Cell key={i} fill={CHANNEL_COLORS[entry.name] || '#999'} />)}
</Pie>
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), CHANNEL_LABELS[name] || name]} />
<Legend wrapperStyle={{ fontSize: 10 }} formatter={(v: any) => CHANNEL_LABELS[v] || v} />
</PieChart>
</ResponsiveContainer>
</div>
)}
</CollapsibleSection>
{/* ⑤ 银行授信评估摘要 */}
{/* ① 银行授信评估摘要 */}
<CollapsibleSection title="银行授信评估摘要" subtitle="基于可配置评分规则的信用等级评估">
{scoring && (
{scoring ? (
<>
{/* 信用等级总览 */}
<div className="flex items-center gap-6 rounded-lg border p-4" style={{ borderColor: scoring.grade.color + '40' }}>
@@ -367,7 +268,7 @@ export function BankPage() {
{scoring.items.map(item => (
<tr key={item.key} className="border-b">
<td className="py-2 pr-4 font-medium">{item.label}</td>
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.value) : `${item.value.toFixed(2)}${item.unit}`}</td>
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.value) : `${Number(item.value || 0).toFixed(2)}${item.unit}`}</td>
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.rule.excellent) : `${item.rule.excellent}${item.unit}`}</td>
<td className="py-2 pr-4">{item.unit === '元' ? formatCurrency(item.rule.pass) : `${item.rule.pass}${item.unit}`}</td>
<td className="py-2 pr-4">{item.rule.weight}%</td>
@@ -387,7 +288,7 @@ export function BankPage() {
{scoring.items.filter(i => i.score < 60).map(i => (
<div key={i.key} className="flex items-center gap-2 rounded border border-yellow-200 bg-yellow-50/40 p-2 text-xs">
<span className="text-yellow-600"></span>
<span><strong>{i.label}</strong> {i.score} {i.unit === '元' ? formatCurrency(i.value) : `${i.value.toFixed(2)}${i.unit}`}{i.rule.higherIsBetter ? `建议提升至 ${i.unit === '元' ? formatCurrency(i.rule.excellent) : i.rule.excellent + i.unit} 以上` : `建议控制在 ${i.rule.excellent}${i.unit} 以内`}</span>
<span><strong>{i.label}</strong> {i.score} {i.unit === '元' ? formatCurrency(i.value) : `${Number(i.value || 0).toFixed(2)}${i.unit}`}{i.rule.higherIsBetter ? `建议提升至 ${i.unit === '元' ? formatCurrency(i.rule.excellent) : i.rule.excellent + i.unit} 以上` : `建议控制在 ${i.rule.excellent}${i.unit} 以内`}</span>
</div>
))}
{scoring.items.filter(i => i.score < 60).length === 0 && (
@@ -398,6 +299,120 @@ export function BankPage() {
)}
</div>
</>
) : (
<div className="flex items-center gap-3 rounded-lg border border-muted bg-muted/30 p-6 text-sm text-muted-foreground">
<span className="text-2xl">📋</span>
<div>
<p className="font-medium"></p>
<p className="mt-1 text-xs"></p>
</div>
</div>
)}
</CollapsibleSection>
{/* ② 企业经营概况 */}
<CollapsibleSection title="企业经营概况" subtitle="月度核心指标与日度营收趋势">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="月度实收" value={ov?.received} format="currency" description={`${formatNumber(ov?.bill_count)} 笔账单`} />
<MetricCard title="客单价" value={ov?.avg_bill_value} format="currency" description="实收 ÷ 账单数" />
<MetricCard title="理论毛利率" value={ov?.theoretical_margin_pct} format="percent" description="菜品定价毛利空间" />
<MetricCard title="门店数量" value={stores.length} unit="家" description={`活跃经营门店总数`} />
</div>
<div className="mt-4">
<ResponsiveContainer width="100%" height={220}>
<LineChart data={dailyChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '日实收']} />
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="日实收" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
</CollapsibleSection>
{/* ③ 盈利能力分析 */}
<CollapsibleSection title="盈利能力分析" subtitle="利润瀑布 · 净利率 · 成本费用结构">
{wf && wf.received > 0 && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<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.store_contribution / wf.received * 100 : 0} format="percent" description="门店贡献利润占实收比例" />
</div>
<div className="mt-4">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={waterfallChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="middle" height={60} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '金额']} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} shape={(props: any) => {
const { x, y, width, height, payload, fill } = props
if (!payload.value || payload.value === 0) return <rect />
const absHeight = Math.abs(height)
const scale = absHeight / Math.abs(payload.value)
const newY = y - payload.base * scale
return <rect x={x} y={newY} width={width} height={absHeight} fill={fill} rx={4} ry={4} />
}}>
{waterfallChart.map((entry, i) => <Cell key={i} fill={entry.fill} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</>
)}
</CollapsibleSection>
{/* ④ 门店资产质量 */}
<CollapsibleSection title="门店资产质量" subtitle="风险等级分布 · 营收TOP10">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={riskPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.name}: ${e.value}`}>
{riskPie.map((entry: any, i: number) => <Cell key={i} fill={entry.fill} />)}
</Pie>
<Tooltip formatter={(v: any, name: any) => [`${v}`, name]} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</PieChart>
</ResponsiveContainer>
</div>
<div>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={top10} layout="vertical" margin={{ top: 5, right: 10, left: 80, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="store_name" tick={{ fontSize: 9 }} width={80} />
<Tooltip formatter={(v: any) => [formatCurrency(v), '实收']} />
<Bar dataKey="received" fill="#3b82f6" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</CollapsibleSection>
{/* ⑤ 经营稳定性指标 */}
<CollapsibleSection title="经营稳定性指标" subtitle="营收波动率 · 优惠率 · 会员占比 · 渠道结构">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="日度营收波动率" value={stability ? stability.cv * 100 : 0} format="percent" status={stability && stability.cv < 0.2 ? 'good' : stability && stability.cv < 0.3 ? 'warn' : 'bad'} description={`变异系数CV · 日均${formatCurrency(stability?.mean_daily_revenue)}`} />
<MetricCard title="优惠率" value={ov?.discount_rate_pct} format="percent" status={Number(ov?.discount_rate_pct) > 25 ? 'bad' : Number(ov?.discount_rate_pct) > 18 ? 'warn' : 'good'} description="优惠额 ÷ (实收+优惠额)" />
<MetricCard title="会员消费占比" value={ov?.member_share_pct} format="percent" status={Number(ov?.member_share_pct) > 15 ? 'good' : 'warn'} description="会员账单 ÷ 总账单" />
<MetricCard title="活跃渠道数" value={channelPie.length} unit="个" description="有交易记录的支付渠道" />
</div>
{channelPie.length > 0 && (
<div className="mt-4">
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={channelPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label={(e: any) => `${CHANNEL_LABELS[e.name] || e.name} ${totalChannel > 0 ? (e.value / totalChannel * 100).toFixed(1) : 0}%`}>
{channelPie.map((entry: any, i: number) => <Cell key={i} fill={CHANNEL_COLORS[entry.name] || '#999'} />)}
</Pie>
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), CHANNEL_LABELS[name] || name]} />
<Legend wrapperStyle={{ fontSize: 10 }} formatter={(v: any) => CHANNEL_LABELS[v] || v} />
</PieChart>
</ResponsiveContainer>
</div>
)}
</CollapsibleSection>
</div>
+288 -204
View File
@@ -1,12 +1,13 @@
import { useState } from 'react'
import { useState, useMemo, useCallback, useEffect, type ReactNode } 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 { FilterableTable } from '@/components/FilterableTable'
import { formatNumber, formatCurrency, cn } from '@/lib/utils'
import { Network, ChevronRight, ChevronDown, Layers, GitBranch, Box, Package, Loader2, X } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
function varianceColorClass(pct: number): string {
@@ -20,10 +21,53 @@ function varianceText(pct: number): string {
return `${pct.toFixed(2)}%`
}
interface BomTreeNode {
level: number
material_code: string
material_name: string
unit: string
theoretical_qty: number
issue_qty: number
theoretical_amt: number
issue_amt: number
avg_unit_price: number
is_finished_product: boolean
parent_material_code: string
root_product_code: string
root_product_name: string
root_recipe: string
path: string
children: BomTreeNode[]
}
function buildTree(flatTree: any[]): BomTreeNode[] {
const nodeMap = new Map<string, BomTreeNode>()
const roots: BomTreeNode[] = []
flatTree.forEach((n: any) => {
const node: BomTreeNode = { ...n, children: [] }
nodeMap.set(node.material_code + '_' + node.level, node)
})
flatTree.forEach((n: any) => {
const key = n.material_code + '_' + n.level
const node = nodeMap.get(key)!
if (n.level === 1) {
roots.push(node)
} else {
const parentKey = n.parent_material_code + '_' + (n.level - 1)
const parent = nodeMap.get(parentKey)
if (parent) parent.children.push(node)
else roots.push(node)
}
})
return roots
}
export function BomPenetrationPage() {
const [month, setMonth] = useState('2026-04')
const [selectedProduct, setSelectedProduct] = useState('0202028')
const [productFilter, setProductFilter] = useState('')
const [selectedProduct, setSelectedProduct] = useState<string | null>(null)
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
const [lazyChildren, setLazyChildren] = useState<Map<string, BomTreeNode[]>>(new Map())
const [loadingNodes, setLoadingNodes] = useState<Set<string>>(new Set())
const { data: overviewData, isLoading: overviewLoading } = useQuery({
queryKey: ['bom-penetration-overview', month],
@@ -42,11 +86,69 @@ export function BomPenetrationPage() {
enabled: !!selectedProduct,
})
const bomTree = productData?.bomTree || []
// BOM树构建
const bomTreeData = useMemo(() => buildTree(bomTree), [bomTree])
const maxLevel = useMemo(() => Math.max(0, ...bomTree.map((n: any) => n.level)), [bomTree])
const toggleNode = useCallback(async (code: string, isFinishedProduct: boolean) => {
if (expandedNodes.has(code)) {
setExpandedNodes(prev => { const n = new Set(prev); n.delete(code); return n })
return
}
setExpandedNodes(prev => new Set(prev).add(code))
if (isFinishedProduct && !lazyChildren.has(code)) {
setLoadingNodes(prev => new Set(prev).add(code))
try {
const res = await api.get(`/central-kitchen/bom-penetration?month=${month}&productCode=${code}`)
const children = buildTree(res.data.bomTree || [])
setLazyChildren(prev => new Map(prev).set(code, children))
} catch (e) {
setLazyChildren(prev => new Map(prev).set(code, []))
} finally {
setLoadingNodes(prev => { const n = new Set(prev); n.delete(code); return n })
}
}
}, [expandedNodes, lazyChildren, month])
const openProductModal = (code: string) => {
setSelectedProduct(code)
setExpandedNodes(new Set())
setLazyChildren(new Map())
setLoadingNodes(new Set())
}
// 数据加载后自动展开所有有子级的节点
useEffect(() => {
if (!bomTreeData.length) return
const toExpand = new Set<string>()
const collect = (nodes: BomTreeNode[]) => {
nodes.forEach(n => {
if (n.children.length > 0) {
toExpand.add(n.material_code)
collect(n.children)
}
})
}
collect(bomTreeData)
if (toExpand.size > 0) {
setExpandedNodes(prev => new Set([...prev, ...toExpand]))
}
}, [bomTreeData])
const closeModal = () => {
setSelectedProduct(null)
setExpandedNodes(new Set())
setLazyChildren(new Map())
setLoadingNodes(new Set())
}
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
@@ -63,13 +165,63 @@ export function BomPenetrationPage() {
领用成本: 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)
const renderTreeRows = (nodes: BomTreeNode[], depth: number): ReactNode[] => {
const rows: ReactNode[] = []
nodes.forEach((node) => {
const isExpanded = expandedNodes.has(node.material_code)
const hasChildren = node.children.length > 0
const lazyLoaded = lazyChildren.get(node.material_code)
const isLoading = loadingNodes.has(node.material_code)
const showChevron = hasChildren || node.is_finished_product
const childrenToRender = hasChildren ? node.children : (lazyLoaded || [])
const isZero = node.theoretical_qty === 0 && node.issue_qty === 0 && node.theoretical_amt === 0 && node.issue_amt === 0
rows.push(
<tr key={node.material_code + '_' + node.level} className={cn('border-b hover:bg-muted/50', node.is_finished_product && 'bg-purple-50/30', isZero && 'bg-yellow-50/40')}>
<td className="py-1.5 pr-3" style={{ paddingLeft: `${depth * 20 + 8}px` }}>
<div className="flex items-center gap-1.5">
{showChevron ? (
<button onClick={() => toggleNode(node.material_code, node.is_finished_product)} className="flex-shrink-0">
{isLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> :
isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
) : (
<span className="inline-block w-3.5" />
)}
{node.is_finished_product && <Box className="h-3.5 w-3.5 text-purple-600 flex-shrink-0" />}
<span className={cn('text-xs', node.is_finished_product && 'font-medium text-purple-700')}>{node.material_name}</span>
{isZero && <span className="rounded bg-yellow-100 px-1 py-0.5 text-[10px] text-yellow-700">0</span>}
</div>
</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{node.material_code}</td>
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{node.unit}</td>
<td className="py-1.5 pr-3 text-right text-xs">{formatNumber(node.theoretical_qty)}</td>
<td className="py-1.5 pr-3 text-right text-xs">{formatNumber(node.issue_qty)}</td>
<td className="py-1.5 pr-3 text-right text-xs">{formatCurrency(node.theoretical_amt)}</td>
<td className="py-1.5 pr-3 text-right text-xs">{formatCurrency(node.issue_amt)}</td>
<td className="py-1.5 pr-3 text-right text-xs">
<span className={cn('font-medium', node.issue_amt - node.theoretical_amt >= 0 ? 'text-yellow-600' : 'text-red-600')}>
{formatCurrency(node.issue_amt - node.theoretical_amt)}
</span>
</td>
<td className="py-1.5 pr-3" />
</tr>
)
if (isExpanded && childrenToRender.length > 0) {
rows.push(...renderTreeRows(childrenToRender, depth + 1))
}
if (isExpanded && !hasChildren && lazyLoaded && lazyLoaded.length === 0 && !isLoading) {
rows.push(
<tr key={node.material_code + '_empty'} className="border-b">
<td className="py-1 pr-3 text-xs text-muted-foreground" style={{ paddingLeft: `${(depth + 1) * 20 + 8}px` }}>
</td>
<td colSpan={8} />
</tr>
)
}
})
return rows
}
// Treemap数据
const treemapData = bomSummary.slice(0, 30).map((b: any) => ({
@@ -78,18 +230,14 @@ export function BomPenetrationPage() {
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>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Network className="h-6 w-6 text-purple-600" />
<h1 className="text-xl font-bold">BOM成本穿透</h1>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
@@ -115,45 +263,32 @@ export function BomPenetrationPage() {
<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 className="mt-3">
<FilterableTable
data={bomSummary}
searchKeys={['product_code', 'product_name']}
searchPlaceholder="搜索产品..."
sortOptions={[
{ key: 'bom_theoretical_amt', label: '理论成本' },
{ key: 'bom_issue_amt', label: '领用成本' },
{ key: 'variance_amt', label: '差异金额' },
{ key: 'variance_pct', label: '差异率' },
{ key: 'material_count', label: '材料数' },
]}
columns={[
{ key: 'product_code', label: '产品编码' },
{ key: 'product_name', label: '产品名称', render: (b) => <span className="font-medium text-blue-600">{b.product_name}</span> },
{ key: 'material_count', label: '材料数', align: 'right' },
{ key: 'sub_product_count', label: '半成品数', align: 'right', render: (b) => b.sub_product_count > 0 ? <span className="text-purple-600 font-medium">{b.sub_product_count}</span> : '-' },
{ key: 'bom_theoretical_amt', label: '理论成本', align: 'right', render: (b) => formatCurrency(b.bom_theoretical_amt) },
{ key: 'bom_issue_amt', label: '领用成本', align: 'right', render: (b) => formatCurrency(b.bom_issue_amt) },
{ key: 'bom_unit_theoretical_cost', label: '单位理论成本', align: 'right', render: (b) => formatNumber(b.bom_unit_theoretical_cost) },
{ key: 'bom_unit_issue_cost', label: '单位领用成本', align: 'right', render: (b) => formatNumber(b.bom_unit_issue_cost) },
{ key: 'variance_amt', label: '差异金额', align: 'right', render: (b) => <span className={b.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(b.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (b) => <span className={`font-medium ${varianceColorClass(b.variance_pct)}`}>{varianceText(b.variance_pct)}</span> },
]}
onRowClick={(b) => setSelectedProduct(b.product_code)}
/>
</div>
</CollapsibleSection>
@@ -170,156 +305,105 @@ export function BomPenetrationPage() {
</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 title="产品列表" subtitle={`${productList.length}个产品`}>
<FilterableTable
data={productList}
searchKeys={['product_code', 'product_name']}
searchPlaceholder="搜索产品..."
sortOptions={[
{ key: 'theoretical_cost', label: '理论成本' },
{ key: 'actual_cost', label: '实际成本' },
{ key: 'inbound_quantity', label: '入库量' },
{ key: 'material_count', label: '材料数' },
]}
columns={[
{ key: 'product_code', label: '产品编码' },
{ key: 'product_name', label: '产品名称', render: (p) => <span className="font-medium">{p.product_name}</span> },
{ key: 'category_minor', label: '品类' },
{ key: 'inbound_quantity', label: '入库量', align: 'right', render: (p) => formatNumber(p.inbound_quantity) },
{ key: 'theoretical_cost', label: '理论成本', align: 'right', render: (p) => formatCurrency(p.theoretical_cost) },
{ key: 'actual_cost', label: '实际成本', align: 'right', render: (p) => formatCurrency(p.actual_cost) },
{ key: 'material_count', label: '材料数', align: 'right' },
{ key: 'has_multi_level_bom', label: '多级BOM', align: 'center', render: (p) => p.has_multi_level_bom ? <span className="text-purple-600"></span> : '-' },
{ key: 'action', label: '', render: (p) => <button className="text-xs text-blue-600 hover:underline" onClick={(e) => { e.stopPropagation(); openProductModal(p.product_code) }}>BOM</button> },
]}
onRowClick={(p) => openProductModal(p.product_code)}
/>
</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 className="mb-3 flex items-center gap-2">
<Network className="h-4 w-4 text-purple-600" />
<span className="text-sm text-muted-foreground">BOM依赖关系</span>
</div>
<FilterableTable
data={multiLevelChains}
searchKeys={['finished_code', 'finished_name', 'sub_product_code', 'sub_product_name']}
searchPlaceholder="搜索成品/半成品..."
sortOptions={[
{ key: 'finished_name', label: '成品名称' },
{ key: 'sub_product_name', label: '半成品名称' },
]}
columns={[
{ key: 'finished_code', label: '成品编码' },
{ key: 'finished_name', label: '成品名称', render: (c) => <span className="font-medium">{c.finished_name}</span> },
{ key: 'finished_recipe', label: '成品配方' },
{ key: 'arrow', label: '', render: () => <ChevronRight className="h-4 w-4 text-purple-600" /> },
{ key: 'sub_product_code', label: '半成品编码' },
{ key: 'sub_product_finished_name', label: '半成品名称', render: (c) => <span className="font-medium text-purple-600">{c.sub_product_finished_name || c.sub_product_name}</span> },
{ key: 'sub_product_recipe', label: '半成品配方', render: (c) => c.sub_product_recipe || '-' },
]}
/>
</CollapsibleSection>
{/* BOM树弹窗 */}
{selectedProduct && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={closeModal}>
<div className="max-h-[85vh] w-[90vw] max-w-5xl overflow-hidden rounded-lg bg-white shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<GitBranch className="h-5 w-5 text-purple-600" />
<h2 className="text-base font-semibold">
BOM树穿透 - {productData?.productList?.find((p: any) => p.product_code === selectedProduct)?.product_name || selectedProduct}
</h2>
<span className="text-xs text-muted-foreground">{bomTree.length}{maxLevel}</span>
</div>
<button onClick={closeModal} className="rounded p-1 hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[calc(85vh-60px)] overflow-auto p-4">
{productLoading ? (
<LoadingSpinner />
) : bomTree.length === 0 ? (
<div className="py-8 text-center text-muted-foreground"></div>
) : (
<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>
</tr>
</thead>
<tbody>
{renderTreeRows(bomTreeData, 0)}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}
+143 -215
View File
@@ -5,6 +5,7 @@ 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 { ChefHat, TrendingDown, TrendingUp, AlertTriangle, Package, Factory } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
@@ -24,7 +25,6 @@ function varianceText(pct: number): string {
export function CentralKitchenPage() {
const [month, setMonth] = useState('2026-04')
const [productFilter, setProductFilter] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['central-kitchen-dashboard', month],
@@ -39,13 +39,6 @@ export function CentralKitchenPage() {
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' },
@@ -76,9 +69,11 @@ export function CentralKitchenPage() {
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>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ChefHat className="h-6 w-6 text-blue-600" />
<h1 className="text-xl font-bold"></h1>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
@@ -135,37 +130,29 @@ export function CentralKitchenPage() {
<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 className="mt-3">
<FilterableTable
data={categoryCost}
filterKey="category_minor"
filterLabel="全部品类"
sortOptions={[
{ key: 'theoretical_cost', label: '理论成本' },
{ key: 'material_actual_cost', label: '实际材料' },
{ key: 'allocated_mfg_cost', label: '制造费用' },
{ key: 'full_cost', label: '全成本' },
{ key: 'efficiency_var_pct', label: '效率差异' },
]}
columns={[
{ key: 'category_minor', label: '品类' },
{ key: 'product_count', label: '产品数', align: 'right' },
{ key: 'total_qty', label: '入库量', align: 'right', render: (c) => formatNumber(c.total_qty) },
{ key: 'theoretical_cost', label: '理论成本', align: 'right', render: (c) => formatCurrency(c.theoretical_cost) },
{ key: 'material_actual_cost', label: '实际材料', align: 'right', render: (c) => formatCurrency(c.material_actual_cost) },
{ key: 'allocated_mfg_cost', label: '制造费用', align: 'right', render: (c) => formatCurrency(c.allocated_mfg_cost) },
{ key: 'full_cost', label: '全成本', align: 'right', render: (c) => <span className="font-medium">{formatCurrency(c.full_cost)}</span> },
{ key: 'efficiency_var_pct', label: '效率差异', align: 'right', render: (c) => <span className={`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)}</span> },
]}
/>
</div>
</CollapsibleSection>
@@ -176,7 +163,19 @@ export function CentralKitchenPage() {
<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={() => ''} />
<Tooltip cursor={{ strokeDasharray: '3 3' }} content={({ active, payload }: any) => {
if (!active || !payload?.length) return null
const p = payload[0].payload
return (
<div className="rounded border bg-white px-3 py-2 text-xs shadow">
<div className="font-medium">{p.name}</div>
<div>: {p.category}</div>
<div>: {formatCurrency(p.x)}</div>
<div>: {formatCurrency(p.y)}</div>
<div>: {p.z.toFixed(2)}%</div>
</div>
)
}} />
<Scatter data={scatterData}>
{scatterData.map((entry: any, idx: number) => (
<Cell key={idx} fill={
@@ -199,191 +198,120 @@ export function CentralKitchenPage() {
{/* 产品明细表 */}
<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"
/>
}
subtitle={`${products.length}个产品`}
>
<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>
<FilterableTable
data={products}
searchKeys={['product_code', 'product_name', 'category_minor']}
searchPlaceholder="搜索产品名/编码/品类..."
sortOptions={[
{ key: 'theoretical_cost', label: '理论成本' },
{ key: 'standard_cost', label: '标准成本' },
{ key: 'material_actual_cost', label: '实际材料' },
{ key: 'full_manufacturing_cost', label: '全成本' },
{ key: 'efficiency_variance_pct', label: '效率差异' },
{ key: 'standard_variance_pct', label: '标准差异' },
{ key: 'product_margin', label: '产品毛利' },
{ key: 'inbound_quantity', label: '入库量' },
]}
columns={[
{ key: 'product_code', label: '编码' },
{ key: 'product_name', label: '产品名', render: (p) => <span className="font-medium">{p.product_name}</span> },
{ key: 'category_minor', label: '品类' },
{ key: 'inbound_quantity', label: '入库量', align: 'right', render: (p) => formatNumber(p.inbound_quantity) },
{ key: 'theoretical_cost', label: '理论成本', align: 'right', render: (p) => formatCurrency(p.theoretical_cost) },
{ key: 'standard_cost', label: '标准成本', align: 'right', render: (p) => formatCurrency(p.standard_cost) },
{ key: 'material_actual_cost', label: '实际材料', align: 'right', render: (p) => formatCurrency(p.material_actual_cost) },
{ key: 'allocated_manufacturing_cost', label: '制造费用', align: 'right', render: (p) => formatCurrency(p.allocated_manufacturing_cost) },
{ key: 'full_manufacturing_cost', label: '全成本', align: 'right', render: (p) => <span className="font-medium">{formatCurrency(p.full_manufacturing_cost)}</span> },
{ key: 'full_unit_cost', label: '单位成本', align: 'right', render: (p) => formatNumber(p.full_unit_cost) },
{ key: 'efficiency_variance_pct', label: '效率差异', align: 'right', render: (p) => <span className={`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)}</span> },
{ key: 'standard_variance_pct', label: '标准差异', align: 'right', render: (p) => <span className={`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)}</span> },
{ key: 'inbound_value', label: '入库价值', align: 'right', render: (p) => formatCurrency(p.inbound_value) },
{ key: 'product_margin', label: '产品毛利', align: 'right', render: (p) => <span className={p.product_margin >= 0 ? 'text-green-600' : 'text-red-600'}>{formatCurrency(p.product_margin)}</span> },
]}
/>
</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>
<FilterableTable
data={recipeEfficiency}
searchKeys={['recipe_name', 'item_name']}
searchPlaceholder="搜索配方/物料..."
sortOptions={[
{ key: 'theoretical_amt', label: '理论金额' },
{ key: 'actual_amt', label: '实际金额' },
{ key: 'variance_pct', label: '金额差异率' },
{ key: 'yield_diff', label: '出成差异' },
]}
columns={[
{ key: 'recipe_name', label: '配方', render: (r) => <span className="font-medium">{r.recipe_name}</span> },
{ key: 'item_name', label: '物料' },
{ key: 'specification', label: '规格' },
{ key: 'theoretical_qty', label: '理论用量', align: 'right', render: (r) => formatNumber(r.theoretical_qty) },
{ key: 'actual_qty', label: '实际用量', align: 'right', render: (r) => formatNumber(r.actual_qty) },
{ key: 'theoretical_amt', label: '理论金额', align: 'right', render: (r) => formatCurrency(r.theoretical_amt) },
{ key: 'actual_amt', label: '实际金额', align: 'right', render: (r) => formatCurrency(r.actual_amt) },
{ key: 'variance_pct', label: '金额差异率', align: 'right', render: (r) => <span className={`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)}</span> },
{ key: 'avg_actual_yield', label: '实际出成率', align: 'right', render: (r) => r.avg_actual_yield ? `${(r.avg_actual_yield * 100).toFixed(2)}%` : '-' },
{ key: 'avg_recipe_yield', label: '标准出成率', align: 'right', render: (r) => r.avg_recipe_yield ? `${(r.avg_recipe_yield * 100).toFixed(2)}%` : '-' },
{ key: 'yield_diff', label: '出成差异', align: 'right', render: (r) => <span className={`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` : '-'}</span> },
]}
/>
</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>
<FilterableTable
data={yieldAnalysis}
searchKeys={['recipe_name', 'product_name']}
searchPlaceholder="搜索配方/产品..."
sortOptions={[
{ key: 'theoretical_inbound_qty', label: '理论入库量' },
{ key: 'actual_inbound_qty', label: '实际入库量' },
{ key: 'avg_achievement_rate', label: '达成率' },
{ key: 'avg_expected_var_rate', label: '预期偏差率' },
{ key: 'total_inbound_amt', label: '入库金额' },
{ key: 'return_rate', label: '退库率' },
]}
columns={[
{ key: 'recipe_name', label: '配方', render: (y) => <span className="font-medium">{y.recipe_name}</span> },
{ key: 'product_name', label: '产品名' },
{ key: 'theoretical_inbound_qty', label: '理论入库量', align: 'right', render: (y) => formatNumber(y.theoretical_inbound_qty) },
{ key: 'actual_inbound_qty', label: '实际入库量', align: 'right', render: (y) => formatNumber(y.actual_inbound_qty) },
{ key: 'avg_achievement_rate', label: '达成率', align: 'right', render: (y) => <span className={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)}%` : '-'}</span> },
{ key: 'avg_expected_var_rate', label: '预期偏差率', align: 'right', render: (y) => <span className={`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)}%` : '-'}</span> },
{ key: 'total_inbound_amt', label: '入库金额', align: 'right', render: (y) => formatCurrency(y.total_inbound_amt) },
{ key: 'total_return_qty', label: '退库量', align: 'right', render: (y) => formatNumber(y.total_return_qty) },
{ key: 'return_rate', label: '退库率', align: 'right', render: (y) => <span className={y.return_rate > 1 ? 'text-red-600' : ''}>{y.return_rate ? `${y.return_rate.toFixed(2)}%` : '-'}</span> },
]}
/>
</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>
<FilterableTable
data={mfgPool}
searchKeys={['cost_type', 'cost_subtype', 'source_type']}
searchPlaceholder="搜索费用类型/子类/来源..."
sortOptions={[
{ key: 'source_amount', label: '原始金额' },
{ key: 'allocated_amount', label: '分摊金额' },
]}
columns={[
{ key: 'cost_type', label: '费用类型', render: (m) => <span className="font-medium">{m.cost_type}</span> },
{ key: 'cost_subtype', label: '费用子类' },
{ key: 'source_type', label: '来源' },
{ key: 'source_amount', label: '原始金额', align: 'right', render: (m) => formatCurrency(m.source_amount) },
{ key: 'share_pct', label: '分摊比例', align: 'right', render: (m) => m.share_pct ? `${(m.share_pct * 100).toFixed(2)}%` : '-' },
{ key: 'allocated_amount', label: '分摊金额', align: 'right', render: (m) => <span className="font-medium">{formatCurrency(m.allocated_amount)}</span> },
{ key: 'allocation_method', label: '分摊方法' },
{ key: 'include_in_rebuilt_cost', label: '计入重建', align: 'center', render: (m) => m.include_in_rebuilt_cost ? '✅' : '❌' },
{ key: 'is_provisional', label: '临时', align: 'center', render: (m) => m.is_provisional ? <AlertTriangle className="inline h-4 w-4 text-yellow-500" /> : '' },
{ key: 'note', label: '备注' },
]}
/>
<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>
+18 -13
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Tabs } from '@/components/Tabs'
import { MonthPicker } from '@/components/MonthPicker'
import { OverviewTab } from '@/components/cost-analysis/OverviewTab'
import { ProfitabilityTab } from '@/components/cost-analysis/ProfitabilityTab'
import { MaterialTab } from '@/components/cost-analysis/MaterialTab'
@@ -26,25 +27,29 @@ const TABS = [
export function CostAnalysisPage() {
const [activeTab, setActiveTab] = useState('overview')
const [month, setMonth] = useState('2026-04')
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground">BOM与成本报表的多维度运营分析</p>
<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">BOM与成本报表的多维度运营分析</p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
<div className="mt-4">
{activeTab === 'overview' && <OverviewTab />}
{activeTab === 'profitability' && <ProfitabilityTab />}
{activeTab === 'material' && <MaterialTab />}
{activeTab === 'bom' && <BomTab />}
{activeTab === 'supply' && <SupplyTab />}
{activeTab === 'packaging' && <PackagingTab />}
{activeTab === 'quality' && <QualityTab />}
{activeTab === 'store' && <StoreTab />}
{activeTab === 'explore' && <ExploreTab />}
{activeTab === 'adjustment' && <AdjustmentTab />}
{activeTab === 'overview' && <OverviewTab month={month} />}
{activeTab === 'profitability' && <ProfitabilityTab month={month} />}
{activeTab === 'material' && <MaterialTab month={month} />}
{activeTab === 'bom' && <BomTab month={month} />}
{activeTab === 'supply' && <SupplyTab month={month} />}
{activeTab === 'packaging' && <PackagingTab month={month} />}
{activeTab === 'quality' && <QualityTab month={month} />}
{activeTab === 'store' && <StoreTab month={month} />}
{activeTab === 'explore' && <ExploreTab month={month} />}
{activeTab === 'adjustment' && <AdjustmentTab month={month} />}
</div>
</div>
)
+110 -84
View File
@@ -1,22 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { formatCurrency, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter } from 'recharts'
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ReferenceLine } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const PAGE_SIZE = 15
export function CostPage() {
const [costPage, setCostPage] = useState(1)
const [invPage, setInvPage] = useState(1)
const [catPage, setCatPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const { data: costData, isLoading: costLoading } = useQuery({
@@ -24,21 +17,26 @@ export function CostPage() {
queryFn: () => api.get('/cost/comparison', { params: { month } }),
})
const [invOpen, setInvOpen] = useState(false)
const [catOpen, setCatOpen] = useState(false)
const { data: invData, isLoading: invLoading } = useQuery({
queryKey: ['cost/inventory', month],
queryFn: () => api.get('/cost/inventory', { params: { month } }),
enabled: invOpen,
})
const { data: catData, isLoading: catLoading } = useQuery({
queryKey: ['cost/category-benchmark', month],
queryFn: () => api.get('/cost/category-benchmark', { params: { month } }),
enabled: catOpen,
})
const costRows = (costData as any)?.data || []
const invRows = (invData as any)?.data || []
const catRows = (catData as any)?.data || []
const pageLoading = costLoading || invLoading || catLoading
const pageLoading = costLoading
const validCostRows = costRows.filter((r: any) => !r.variance_level?.includes('口径异常'))
const abnormalRows = costRows.filter((r: any) => r.variance_level?.includes('口径异常'))
@@ -50,11 +48,6 @@ export function CostPage() {
? validCostRows.reduce((s: number, r: any) => s + Number(r.actual_food_cost_rate_pct || 0), 0) / validCostRows.length
: 0
const highVarianceCount = validCostRows.filter((r: any) => Number(r.variance_to_theoretical_pct || 0) > 20).length
const abnormalInvCount = invRows.filter((r: any) => Number(r.estimated_inventory_days) > 7 || Number(r.negative_item_lines) > 0).length
const pagedCost = useMemo(() => costRows.slice((costPage - 1) * PAGE_SIZE, costPage * PAGE_SIZE), [costRows, costPage])
const pagedInv = useMemo(() => invRows.slice((invPage - 1) * PAGE_SIZE, invPage * PAGE_SIZE), [invRows, invPage])
const pagedCat = useMemo(() => catRows.slice((catPage - 1) * PAGE_SIZE, catPage * PAGE_SIZE), [catRows, catPage])
const scatterData = validCostRows.map((r: any) => ({
name: r.store_name,
@@ -63,6 +56,17 @@ export function CostPage() {
variance: Number(r.variance_to_theoretical_pct || 0),
}))
const scatterDomain = useMemo(() => {
if (scatterData.length === 0) return { x: [0, 40], y: [0, 50] }
const xs = scatterData.map((d: any) => d.theoretical)
const ys = scatterData.map((d: any) => d.actual)
const xMin = Math.floor(Math.min(...xs) - 1)
const xMax = Math.ceil(Math.max(...xs) + 1)
const yMin = Math.floor(Math.min(...ys) - 1)
const yMax = Math.ceil(Math.max(...ys) + 1)
return { x: [xMin, xMax], y: [yMin, yMax] }
}, [scatterData])
if (pageLoading) {
return <LoadingSpinner text="加载成本数据..." />
}
@@ -101,8 +105,9 @@ export function CostPage() {
<ResponsiveContainer width="100%" height={300}>
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" dataKey="theoretical" name="理论成本率" unit="%" tick={{ fontSize: 10 }} domain={[0, 40]} />
<YAxis type="number" dataKey="actual" name="实际成本率" unit="%" tick={{ fontSize: 10 }} domain={[0, 50]} />
<XAxis type="number" dataKey="theoretical" name="理论成本率" unit="%" tick={{ fontSize: 10 }} domain={scatterDomain.x} />
<YAxis type="number" dataKey="actual" name="实际成本率" unit="%" tick={{ fontSize: 10 }} domain={scatterDomain.y} />
<ReferenceLine segment={[{ x: scatterDomain.x[0], y: scatterDomain.y[0] }, { x: scatterDomain.x[1], y: scatterDomain.y[1] }]} stroke="#94a3b8" strokeDasharray="4 4" />
<Tooltip
cursor={{ strokeDasharray: '3 3' }}
content={({ active, payload }: any) => {
@@ -127,78 +132,99 @@ export function CostPage() {
{/* 成本对比表 */}
<CollapsibleSection title={`门店成本对比 (${costRows.length})`} subtitle="理论成本 vs 实际倒挤成本,按偏差降序">
<Pagination page={costPage} pageSize={PAGE_SIZE} total={costRows.length} onPageChange={setCostPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'theoretical_cost_rate_pct', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theoretical_cost_rate_pct) },
{ key: 'actual_food_cost_rate_pct', label: '实际成本', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) },
{ key: 'variance_to_theoretical_pct', label: '偏差', align: 'right', render: (r) => {
const v = Number(r.variance_to_theoretical_pct || 0)
return <span className={v > 20 ? 'font-medium text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{v.toFixed(1)}%</span>
}},
{ key: 'variance_level', label: '偏差等级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
r.variance_level === '高偏差' ? 'bg-red-100 text-red-700' :
r.variance_level === '中偏差' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>{r.variance_level || '-'}</span>
)},
{ key: 'actual_food_cost', label: '实际食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) },
]}
data={pagedCost}
/>
</div>
<FilterableTable
data={costRows}
sortOptions={[
{ key: 'variance_to_theoretical_pct', label: '偏差' },
{ key: 'theoretical_cost_rate_pct', label: '理论成本率' },
{ key: 'actual_food_cost_rate_pct', label: '实际成本率' },
{ key: 'actual_food_cost', label: '实际食材成本' },
{ key: 'sales_received', label: '销售额' },
]}
defaultSort="variance_to_theoretical_pct"
defaultOrder="desc"
filterKey="variance_level"
filterLabel="全部等级"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'theoretical_cost_rate_pct', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theoretical_cost_rate_pct) },
{ key: 'actual_food_cost_rate_pct', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) },
{ key: 'variance_to_theoretical_pct', label: '偏差', align: 'right', render: (r) => {
const v = Number(r.variance_to_theoretical_pct || 0)
return <span className={v > 20 ? 'font-medium text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{v.toFixed(1)}%</span>
}},
{ key: 'variance_level', label: '偏差等级', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
r.variance_level === '高偏差' ? 'bg-red-100 text-red-700' :
r.variance_level === '中偏差' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>{r.variance_level || '-'}</span>
)},
{ key: 'actual_food_cost', label: '实际食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) },
]}
/>
</CollapsibleSection>
{/* 库存效率 */}
<CollapsibleSection title={`库存效率 (${invRows.length})`} subtitle="库存天数、负耗用、异常货品" defaultOpen={false}>
<Pagination page={invPage} pageSize={PAGE_SIZE} total={invRows.length} onPageChange={setInvPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'business_type', label: '业态' },
{ key: 'scale_tier', label: '规模' },
{ key: 'estimated_inventory_days', label: '库存天数', align: 'right', render: (r) => {
const d = Number(r.estimated_inventory_days || 0)
return <span className={d > 7 ? 'font-medium text-red-600' : d > 4 ? 'text-yellow-600' : 'text-green-600'}>{d.toFixed(1)}</span>
}},
{ key: 'ending_inventory_amount', label: '期末库存', align: 'right', render: (r) => formatCurrency(r.ending_inventory_amount) },
{ key: 'negative_item_lines', label: '负耗用行数', align: 'center', render: (r) => {
const n = Number(r.negative_item_lines || 0)
return n > 0 ? <span className="font-medium text-red-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
{ key: 'abnormal_item_lines', label: '异常货品行', align: 'center', render: (r) => {
const n = Number(r.abnormal_item_lines || 0)
return n > 0 ? <span className="font-medium text-yellow-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
]}
data={pagedInv}
/>
</div>
<CollapsibleSection title="库存效率" subtitle="库存天数、负耗用、异常货品" defaultOpen={false} onToggle={setInvOpen}>
{invLoading ? <LoadingSpinner text="加载库存数据..." /> : <>
<FilterableTable
data={invRows}
sortOptions={[
{ key: 'ending_inventory_amount', label: '期末库存' },
{ key: 'negative_item_lines', label: '负耗用行数' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="ending_inventory_amount"
defaultOrder="desc"
filterKey="business_type"
filterLabel="全部业态"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'business_type', label: '业态' },
{ key: 'scale_tier', label: '规模' },
{ key: 'estimated_inventory_days', label: '库存天数', align: 'right', render: (r) => {
const d = Number(r.estimated_inventory_days || 0)
return <span className={d > 7 ? 'font-medium text-red-600' : d > 4 ? 'text-yellow-600' : 'text-green-600'}>{d.toFixed(1)}</span>
}},
{ key: 'ending_inventory_amount', label: '期末库存', align: 'right', render: (r) => formatCurrency(r.ending_inventory_amount) },
{ key: 'negative_item_lines', label: '负耗用行数', align: 'center', render: (r) => {
const n = Number(r.negative_item_lines || 0)
return n > 0 ? <span className="font-medium text-red-600">{n}</span> : <span className="text-muted-foreground">0</span>
}},
]}
/>
</>}
</CollapsibleSection>
{/* 原料分类差异 */}
<CollapsibleSection title={`原料分类成本基准 (${catRows.length})`} subtitle="按财务分类对比同业基准" defaultOpen={false}>
<Pagination page={catPage} pageSize={PAGE_SIZE} total={catRows.length} onPageChange={setCatPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'finance_category', label: '财务分类' },
{ key: 'actual_category_cost', label: '实际成本', align: 'right', render: (r) => formatCurrency(r.actual_category_cost) },
{ key: 'cost_per_10k_sales', label: '每万元成本', align: 'right', render: (r) => formatCurrency(r.cost_per_10k_sales) },
{ key: 'peer_median_cost_per_10k', label: '同业中位', align: 'right', render: (r) => formatCurrency(r.peer_median_cost_per_10k) },
{ key: 'excess_vs_peer_per_10k', label: '超出同业', align: 'right', render: (r) => {
const v = Number(r.excess_vs_peer_per_10k || 0)
return v > 0 ? <span className="text-red-600">+{formatCurrency(v)}</span> : <span className="text-green-600">{formatCurrency(v)}</span>
}},
]}
data={pagedCat}
/>
</div>
<CollapsibleSection title="原料分类成本基准" subtitle="按财务分类对比同业基准" defaultOpen={false} onToggle={setCatOpen}>
{catLoading ? <LoadingSpinner text="加载分类数据..." /> : <>
<FilterableTable
data={catRows}
sortOptions={[
{ key: 'actual_category_cost', label: '实际成本' },
{ key: 'cost_per_10k_sales', label: '每万元成本' },
{ key: 'excess_vs_peer_per_10k', label: '超出同业' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="excess_vs_peer_per_10k"
defaultOrder="desc"
filterKey="finance_category"
filterLabel="全部分类"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'finance_category', label: '财务分类' },
{ key: 'actual_category_cost', label: '实际成本', align: 'right', render: (r) => formatCurrency(r.actual_category_cost) },
{ key: 'cost_per_10k_sales', label: '每万元成本', align: 'right', render: (r) => formatCurrency(r.cost_per_10k_sales) },
{ key: 'peer_median_cost_per_10k', label: '同业中位', align: 'right', render: (r) => formatCurrency(r.peer_median_cost_per_10k) },
{ key: 'excess_vs_peer_per_10k', label: '超出同业', align: 'right', render: (r) => {
const v = Number(r.excess_vs_peer_per_10k || 0)
return v > 0 ? <span className="text-red-600">+{formatCurrency(v)}</span> : <span className="text-green-600">{formatCurrency(v)}</span>
}},
]}
/>
</>}
</CollapsibleSection>
</div>
)
+18 -16
View File
@@ -4,21 +4,18 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
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 { useState } from 'react'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
const RISK_COLORS = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
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({
@@ -52,13 +49,13 @@ export function DashboardPage() {
})
const { data: platformData } = useQuery({
queryKey: ['platform/economics'],
queryFn: () => api.get('/platform/economics'),
queryKey: ['platform/economics', month],
queryFn: () => api.get('/platform/economics', { params: { month } }),
})
const { data: alertsData } = useQuery({
queryKey: ['sa-alerts'],
queryFn: () => api.get('/situational-awareness/alerts'),
queryKey: ['sa-alerts', month],
queryFn: () => api.get('/situational-awareness/alerts', { params: { month } }),
})
const { data: expenseData } = useQuery({
@@ -169,7 +166,6 @@ export function DashboardPage() {
}, {})
const p0p1Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0') || s.action_priority?.startsWith('P1'))
const pagedP0P1 = useMemo(() => p0p1Stores.slice((priorityPage - 1) * PAGE_SIZE, priorityPage * PAGE_SIZE), [p0p1Stores, priorityPage])
// 闭环健康度综合得分
const loopScore = lh ? (
@@ -500,9 +496,18 @@ export function DashboardPage() {
</div>
}
>
<Pagination page={priorityPage} pageSize={PAGE_SIZE} total={p0p1Stores.length} onPageChange={setPriorityPage} />
<div className="mt-3">
<DataTable
<FilterableTable
data={p0p1Stores}
sortOptions={[
{ key: 'problem_count', label: '问题数' },
{ key: 'received', label: '实收' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="problem_count"
defaultOrder="desc"
filterKey="action_priority"
filterLabel="全部优先级"
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'action_priority', label: '优先级', render: (r) => <Badge type="priority" text={r.action_priority} /> },
@@ -510,10 +515,7 @@ export function DashboardPage() {
{ key: 'problem_combination', label: '问题组合' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
]}
data={pagedP0P1}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
</CollapsibleSection>
</div>
)
@@ -5,6 +5,7 @@ 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 { Truck, AlertTriangle, PackageSearch, BarChart3 } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
@@ -27,7 +28,6 @@ function varianceText(pct: number): string {
export function DistributionReconciliationPage() {
const [month, setMonth] = useState('2026-04')
const [storeFilter, setStoreFilter] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['distribution-reconciliation', month],
@@ -42,12 +42,6 @@ export function DistributionReconciliationPage() {
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' },
@@ -79,9 +73,11 @@ export function DistributionReconciliationPage() {
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>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Truck className="h-6 w-6 text-blue-600" />
<h1 className="text-xl font-bold"></h1>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
@@ -137,41 +133,30 @@ export function DistributionReconciliationPage() {
<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 className="mt-3">
<FilterableTable
data={categoryReconciliation}
filterKey="minor_category"
filterLabel="全部分类"
sortOptions={[
{ key: 'dist_amt', label: '配送金额' },
{ key: 'consumption_amt', label: '耗用金额' },
{ key: 'ending_amt', label: '期末库存' },
{ key: 'variance_amt', label: '差异金额' },
{ key: 'variance_pct', label: '差异率' },
]}
columns={[
{ key: 'minor_category', label: '品类' },
{ key: 'item_count', label: '品项数', align: 'right' },
{ key: 'dist_qty', label: '配送量', align: 'right', render: (c) => formatNumber(c.dist_qty) },
{ key: 'dist_amt', label: '配送金额', align: 'right', render: (c) => formatCurrency(c.dist_amt) },
{ key: 'dist_cost_excl_tax', label: '不含税成本', align: 'right', render: (c) => formatCurrency(c.dist_cost_excl_tax) },
{ key: 'consumption_amt', label: '耗用金额', align: 'right', render: (c) => formatCurrency(c.consumption_amt) },
{ key: 'ending_amt', label: '期末库存', align: 'right', render: (c) => formatCurrency(c.ending_amt) },
{ key: 'variance_amt', label: '差异金额', align: 'right', render: (c) => <span className={c.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(c.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (c) => <span className={`font-medium ${varianceColorClass(c.variance_pct)}`}>{varianceText(c.variance_pct)}</span> },
]}
/>
</div>
</CollapsibleSection>
@@ -182,7 +167,17 @@ export function DistributionReconciliationPage() {
<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={() => ''} />
<Tooltip cursor={{ strokeDasharray: '3 3' }} content={({ active, payload }: any) => {
if (!active || !payload?.length) return null
const p = payload[0].payload
return (
<div className="rounded border bg-white px-3 py-2 text-xs shadow">
<div className="font-medium">{p.name} ({p.store_code})</div>
<div>: {formatCurrency(p.x)}</div>
<div>: {p.y.toFixed(2)}%</div>
</div>
)
}} />
<Scatter data={storeScatterData}>
{storeScatterData.map((entry: any, idx: number) => (
<Cell key={idx} fill={
@@ -204,135 +199,88 @@ export function DistributionReconciliationPage() {
{/* 门店对账明细 */}
<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"
/>
}
subtitle={`${storeReconciliation.length}家门店`}
>
<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>
<FilterableTable
data={storeReconciliation}
searchKeys={['store_code', 'store_name']}
searchPlaceholder="搜索门店编码/名称..."
sortOptions={[
{ key: 'dist_amt', label: '配送金额' },
{ key: 'dist_cost_excl_tax', label: '不含税成本' },
{ key: 'opening_amt', label: '期初库存' },
{ key: 'consumption_amt', label: '实际耗用' },
{ key: 'ending_amt', label: '期末库存' },
{ key: 'reverse_consumption_amt', label: '倒挤应耗用' },
{ key: 'variance_amt', label: '差异金额' },
{ key: 'variance_pct', label: '差异率' },
{ key: 'neg_inventory_count', label: '负库存' },
]}
columns={[
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称', render: (s) => s.store_name || '-' },
{ key: 'dist_amt', label: '配送金额', align: 'right', render: (s) => formatCurrency(s.dist_amt) },
{ key: 'dist_cost_excl_tax', label: '不含税成本', align: 'right', render: (s) => formatCurrency(s.dist_cost_excl_tax) },
{ key: 'opening_amt', label: '期初库存', align: 'right', render: (s) => formatCurrency(s.opening_amt) },
{ key: 'consumption_amt', label: '实际耗用', align: 'right', render: (s) => formatCurrency(s.consumption_amt) },
{ key: 'ending_amt', label: '期末库存', align: 'right', render: (s) => formatCurrency(s.ending_amt) },
{ key: 'reverse_consumption_amt', label: '倒挤应耗用', align: 'right', render: (s) => formatCurrency(s.reverse_consumption_amt) },
{ key: 'variance_amt', label: '差异金额', align: 'right', render: (s) => <span className={s.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(s.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (s) => <span className={`font-medium ${varianceColorClass(s.variance_pct)}`}>{varianceText(s.variance_pct)}</span> },
{ key: 'neg_inventory_count', label: '负库存', align: 'right', render: (s) => <span className={s.neg_inventory_count > 0 ? 'text-red-600 font-medium' : ''}>{s.neg_inventory_count > 0 ? s.neg_inventory_count : '-'}</span> },
]}
/>
</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>
<FilterableTable
data={topVariances}
filterKey="item_name"
filterLabel="全部品项"
sortOptions={[
{ key: 'dist_amt', label: '配送金额' },
{ key: 'consumption_amt', label: '实际耗用' },
{ key: 'variance_amt', label: '差异金额' },
{ key: 'variance_pct', label: '差异率' },
]}
columns={[
{ key: 'store_code', label: '门店' },
{ key: 'item_code', label: '品项编码' },
{ key: 'item_name', label: '品项名称' },
{ key: 'minor_category', label: '品类' },
{ key: 'dist_qty', label: '配送量', align: 'right', render: (v) => formatNumber(v.dist_qty) },
{ key: 'dist_amt', label: '配送金额', align: 'right', render: (v) => formatCurrency(v.dist_amt) },
{ key: 'opening_amt', label: '期初', align: 'right', render: (v) => formatCurrency(v.opening_amt) },
{ key: 'consumption_amt', label: '实际耗用', align: 'right', render: (v) => formatCurrency(v.consumption_amt) },
{ key: 'ending_amt', label: '期末', align: 'right', render: (v) => formatCurrency(v.ending_amt) },
{ key: 'reverse_consumption_amt', label: '倒挤应耗用', align: 'right', render: (v) => formatCurrency(v.reverse_consumption_amt) },
{ key: 'variance_amt', label: '差异金额', align: 'right', render: (v) => <span className={`font-medium ${v.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>{formatCurrency(v.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (v) => <span className={varianceColorClass(v.variance_pct)}>{varianceText(v.variance_pct)}</span> },
]}
/>
</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>
<FilterableTable
data={unmatchedItems}
filterKey="item_name"
filterLabel="全部品项"
sortOptions={[
{ key: 'dist_amt', label: '配送金额' },
{ key: 'dist_qty', label: '配送量' },
{ key: 'store_count', label: '涉及门店数' },
]}
columns={[
{ key: 'item_code', label: '品项编码' },
{ key: 'item_name', label: '品项名称' },
{ key: 'minor_category', label: '品类' },
{ key: 'dist_qty', label: '配送量', align: 'right', render: (u) => formatNumber(u.dist_qty) },
{ key: 'dist_amt', label: '配送金额', align: 'right', render: (u) => formatCurrency(u.dist_amt) },
{ key: 'store_count', label: '涉及门店数', align: 'right' },
]}
/>
<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>
+12 -10
View File
@@ -1,26 +1,29 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { useState, useMemo } from 'react'
const PAGE_SIZE = 10
import { FilterableTable } from '@/components/FilterableTable'
export function IndicatorsPage() {
const [page, setPage] = useState(1)
const { data } = useQuery({
queryKey: ['indicators'],
queryFn: () => api.get('/tasks/indicators'),
})
const indicators = (data as any)?.data || []
const pagedIndicators = useMemo(() => indicators.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [indicators, page])
return (
<div className="space-y-4">
<h1 className="text-xl font-bold"></h1>
<Pagination page={page} pageSize={PAGE_SIZE} total={indicators.length} onPageChange={setPage} />
<DataTable
<FilterableTable
data={indicators}
sortOptions={[
{ key: 'indicator_name', label: '指标名称' },
{ key: 'update_frequency', label: '更新频率' },
{ key: 'owner', label: '负责人' },
]}
defaultSort="indicator_name"
defaultOrder="asc"
filterKey="update_frequency"
filterLabel="全部频率"
columns={[
{ key: 'indicator_name', label: '指标名称' },
{ key: 'business_definition', label: '业务定义', render: (r) => <span className="text-xs">{r.business_definition}</span> },
@@ -31,7 +34,6 @@ export function IndicatorsPage() {
{ key: 'red_threshold', label: '红线', align: 'right' },
{ key: 'version_date', label: '版本日期', render: (r) => r.version_date?.substring(0, 10) },
]}
data={pagedIndicators}
/>
</div>
)
+39 -29
View File
@@ -1,24 +1,20 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const PAGE_SIZE = 15
export function MemberPage() {
const [repeatPage, setRepeatPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const { data: comparisonData, isLoading: cmpLoading } = useQuery({
queryKey: ['member/comparison'],
queryFn: () => api.get('/member/comparison'),
queryKey: ['member/comparison', month],
queryFn: () => api.get('/member/comparison', { params: { month } }),
})
const { data: repeatData, isLoading: repeatLoading } = useQuery({
@@ -60,8 +56,6 @@ export function MemberPage() {
]
const PIE_COLORS = ['#3b82f6', '#e5e7eb']
const pagedRepeat = useMemo(() => repeatRows.slice((repeatPage - 1) * PAGE_SIZE, repeatPage * PAGE_SIZE), [repeatRows, repeatPage])
if (pageLoading) {
return <LoadingSpinner text="加载会员数据..." />
}
@@ -120,7 +114,16 @@ export function MemberPage() {
{/* 会员对比明细 */}
<CollapsibleSection title="会员与非会员指标对比" subtitle="优惠率、毛利率等关键指标">
<DataTable
<FilterableTable
data={cmpRows}
sortOptions={[
{ key: 'customer_type', label: '客户类型' },
{ key: 'bill_count', label: '账单数' },
{ key: 'received', label: '实收' },
{ key: 'avg_bill_value', label: '客单价' },
]}
defaultSort="customer_type"
defaultOrder="asc"
columns={[
{ key: 'customer_type', label: '客户类型' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
@@ -129,29 +132,36 @@ export function MemberPage() {
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) },
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
]}
data={cmpRows}
/>
</CollapsibleSection>
{/* 门店复购率 */}
<CollapsibleSection title={`门店复购率 (${repeatRows.length})`} subtitle="按复购率降序,低于30%需重点改善">
<Pagination page={repeatPage} pageSize={PAGE_SIZE} total={repeatRows.length} onPageChange={setRepeatPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'identified_members', label: '识别会员数', align: 'right', render: (r) => formatNumber(r.identified_members) },
{ key: 'repeat_members', label: '复购会员数', align: 'right', render: (r) => formatNumber(r.repeat_members) },
{ key: 'repeat_rate_pct', label: '复购', align: 'right', render: (r) => {
const v = Number(r.repeat_rate_pct || 0)
return <span className={v >= 50 ? 'font-medium text-green-600' : v >= 30 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'avg_orders', label: '人均消费次数', align: 'right', render: (r) => Number(r.avg_orders || 0).toFixed(2) },
{ key: 'repeat_revenue_share_pct', label: '复购收入占比', align: 'right', render: (r) => `${Number(r.repeat_revenue_share_pct || 0).toFixed(1)}%` },
]}
data={pagedRepeat}
/>
</div>
<FilterableTable
data={repeatRows}
filterKey="store_name"
filterLabel="全部门店"
sortOptions={[
{ key: 'repeat_rate_pct', label: '复购率' },
{ key: 'identified_members', label: '会员数' },
{ key: 'repeat_members', label: '复购' },
{ key: 'avg_orders', label: '消费次数' },
{ key: 'repeat_revenue_share_pct', label: '收入占比' },
]}
defaultSort="repeat_rate_pct"
defaultOrder="desc"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'identified_members', label: '识别会员数', align: 'right', render: (r) => formatNumber(r.identified_members) },
{ key: 'repeat_members', label: '复购会员数', align: 'right', render: (r) => formatNumber(r.repeat_members) },
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => {
const v = Number(r.repeat_rate_pct || 0)
return <span className={v >= 50 ? 'font-medium text-green-600' : v >= 30 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'avg_orders', label: '人均消费次数', align: 'right', render: (r) => Number(r.avg_orders || 0).toFixed(2) },
{ key: 'repeat_revenue_share_pct', label: '复购收入占比', align: 'right', render: (r) => `${Number(r.repeat_revenue_share_pct || 0).toFixed(1)}%` },
]}
/>
</CollapsibleSection>
</div>
)
+69 -28
View File
@@ -1,16 +1,14 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, Bar, CartesianGrid, XAxis, YAxis, Legend } from 'recharts'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { Badge } from '@/components/Badge'
import { MetricCard } from '@/components/MetricCard'
import { Pagination } from '@/components/Pagination'
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
const REVIEW_COLORS = { '达标': '#22c55e', '改善中': '#eab308', '未改善': '#ef4444' }
const PAGE_SIZE = 10
type ReviewTab = 'summary' | 'grade-change' | 'completion' | 'activity' | 'sku' | 'practice' | 'effectiveness'
@@ -25,8 +23,7 @@ const REVIEW_TABS: { key: ReviewTab; label: string }[] = [
]
export function MonthlyReviewPage() {
const [month, setMonth] = useState('2026-05')
const [page, setPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const [tab, setTab] = useState<ReviewTab>('summary')
const { data } = useQuery({
@@ -35,8 +32,8 @@ export function MonthlyReviewPage() {
})
const { data: gradeData } = useQuery({
queryKey: ['grade-change'],
queryFn: () => api.get('/tasks/grade-change'),
queryKey: ['grade-change', month],
queryFn: () => api.get('/tasks/grade-change', { params: { month } }),
enabled: tab === 'grade-change',
})
@@ -47,14 +44,14 @@ export function MonthlyReviewPage() {
})
const { data: activityData } = useQuery({
queryKey: ['monthly-review/activity-list'],
queryFn: () => api.get('/tasks/monthly-review/activity-list'),
queryKey: ['monthly-review/activity-list', month],
queryFn: () => api.get('/tasks/monthly-review/activity-list', { params: { month } }),
enabled: tab === 'activity',
})
const { data: skuGovData } = useQuery({
queryKey: ['monthly-review/sku-governance'],
queryFn: () => api.get('/tasks/monthly-review/sku-governance'),
queryKey: ['monthly-review/sku-governance', month],
queryFn: () => api.get('/tasks/monthly-review/sku-governance', { params: { month } }),
enabled: tab === 'sku',
})
@@ -67,7 +64,6 @@ export function MonthlyReviewPage() {
const rd = (data as any)?.data
const summary = rd?.summary || { total: 0, passed: 0, improving: 0, failed: 0 }
const details = rd?.details || []
const pagedDetails = useMemo(() => details.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [details, page])
const gradeRows = (gradeData as any)?.data || []
const completionRows = (completionData as any)?.data || []
@@ -124,9 +120,19 @@ export function MonthlyReviewPage() {
</div>
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<Pagination page={page} pageSize={PAGE_SIZE} total={details.length} onPageChange={setPage} />
<div className="mt-3">
<DataTable
<FilterableTable
data={details}
sortOptions={[
{ key: 'store_name', label: '门店' },
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'baseline_value', label: '基线' },
{ key: 'target_value', label: '目标' },
{ key: 'actual_value', label: '实际' },
]}
defaultSort="store_name"
defaultOrder="asc"
filterKey="review_result"
filterLabel="全部结果"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'problem_indicator', label: '问题指标' },
@@ -135,9 +141,7 @@ export function MonthlyReviewPage() {
{ key: 'actual_value', label: '实际', align: 'right' },
{ key: 'review_result', label: '结果', render: (r) => <Badge type="review" text={r.review_result} /> },
]}
data={pagedDetails}
/>
</div>
</div>
</>
)}
@@ -147,7 +151,17 @@ export function MonthlyReviewPage() {
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold">P0/P1/P2升降级面板</h2>
{gradeRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<DataTable
<FilterableTable
data={gradeRows}
sortOptions={[
{ key: 'change_month', label: '月份' },
{ key: 'store_name', label: '门店' },
{ key: 'change_type', label: '变化' },
]}
defaultSort="change_month"
defaultOrder="desc"
filterKey="change_type"
filterLabel="全部变化"
columns={[
{ key: 'change_month', label: '月份', render: (r) => r.change_month?.substring(0, 7) },
{ key: 'store_name', label: '门店' },
@@ -159,7 +173,6 @@ export function MonthlyReviewPage() {
} },
{ key: 'reason', label: '原因', render: (r) => <span className="text-xs">{r.reason}</span> },
]}
data={gradeRows}
/>
)}
</div>
@@ -183,7 +196,14 @@ export function MonthlyReviewPage() {
</div>
))}
</div>
<DataTable
<FilterableTable
data={completionRows}
sortOptions={[
{ key: 'priority', label: '优先级' },
{ key: 'completion_rate', label: '完成率' },
]}
defaultSort="priority"
defaultOrder="asc"
columns={[
{ key: 'priority', label: '优先级', render: (r) => <Badge type="priority" text={r.priority} /> },
{ key: 'total', label: '总数', align: 'right' },
@@ -193,7 +213,6 @@ export function MonthlyReviewPage() {
{ key: 'failed', label: '未改善', align: 'right', render: (r) => <span className="text-red-600">{r.failed}</span> },
{ key: 'completion_rate', label: '完成率', align: 'right', render: (r) => `${r.completion_rate}%` },
]}
data={completionRows}
/>
</>
)}
@@ -206,7 +225,16 @@ export function MonthlyReviewPage() {
<h2 className="mb-3 text-sm font-bold">//</h2>
<p className="mb-3 text-xs text-muted-foreground"></p>
{activityRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<DataTable
<FilterableTable
data={activityRows}
sortOptions={[
{ key: 'net_contribution', label: '净贡献' },
{ key: 'received', label: '实收' },
{ key: 'discount_cost_rate', label: '优惠成本率' },
{ key: 'bill_count', label: '账单数' },
]}
defaultSort="net_contribution"
defaultOrder="desc"
columns={[
{ key: 'marketing_plan', label: '活动方案' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
@@ -223,7 +251,6 @@ export function MonthlyReviewPage() {
return <span className="text-yellow-600"></span>
} },
]}
data={activityRows}
/>
)}
</div>
@@ -246,7 +273,15 @@ export function MonthlyReviewPage() {
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold">SKU治理清单 (C类)</h2>
{skuGov?.longtail?.length > 0 ? (
<DataTable
<FilterableTable
data={skuGov.longtail}
sortOptions={[
{ key: 'received_amount', label: '实收' },
{ key: 'bill_count', label: '账单数' },
{ key: 'revenue_share_pct', label: '营收占比' },
]}
defaultSort="received_amount"
defaultOrder="desc"
columns={[
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_level1', label: '一级分类' },
@@ -254,7 +289,6 @@ export function MonthlyReviewPage() {
{ key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) },
{ key: 'revenue_share_pct', label: '营收占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` },
]}
data={skuGov.longtail.slice(0, 20)}
/>
) : <p className="text-sm text-muted-foreground">SKU数据</p>}
</div>
@@ -276,7 +310,15 @@ export function MonthlyReviewPage() {
<h2 className="mb-3 text-sm font-bold"></h2>
<p className="mb-3 text-xs text-muted-foreground"></p>
{effectivenessRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<DataTable
<FilterableTable
data={effectivenessRows}
sortOptions={[
{ key: 'pass_rate', label: '达标率' },
{ key: 'task_count', label: '任务数' },
{ key: 'problem_indicator', label: '指标' },
]}
defaultSort="pass_rate"
defaultOrder="desc"
columns={[
{ key: 'problem_indicator', label: '指标' },
{ key: 'task_count', label: '任务数', align: 'right' },
@@ -294,7 +336,6 @@ export function MonthlyReviewPage() {
return <Badge type="review" text="低效" />
} },
]}
data={effectivenessRows}
/>
)}
</div>
+24 -7
View File
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
import { useState } from 'react'
import { Database, Table, Layers, Settings, ChevronRight, Box, ArrowRight } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -189,7 +189,12 @@ export function OntologyPage() {
</button>
<h2 className="text-lg font-bold">{labelOf(selectedTable)} <code className="text-sm text-muted-foreground">{selectedTable}</code></h2>
<div className="rounded-lg border">
<DataTable
<FilterableTable
data={dim?.rows || []}
sortOptions={dim?.columns?.slice(0, 5).map((c: any) => ({ key: c.column_name, label: c.column_name })) || []}
defaultSort={dim?.columns?.[0]?.column_name || ''}
defaultOrder="asc"
pageSize={20}
columns={dim?.columns?.map((c: any) => ({
key: c.column_name,
label: c.column_name,
@@ -201,7 +206,6 @@ export function OntologyPage() {
return <span className="text-xs">{String(val)}</span>
}
})) || []}
data={dim?.rows || []}
/>
</div>
<div className="text-xs text-muted-foreground"></div>
@@ -294,9 +298,12 @@ export function OntologyPage() {
</button>
<h2 className="text-lg font-bold">{labelOf(selectedTable)} <code className="text-sm text-muted-foreground">{selectedTable}</code></h2>
<div className="rounded-lg border">
<DataTable
columns={Object.keys(enumRows[0] || {}).map(k => ({ key: k, label: k }))}
<FilterableTable
data={enumRows}
sortOptions={Object.keys(enumRows[0] || {}).slice(0, 3).map(k => ({ key: k, label: k }))}
defaultSort={Object.keys(enumRows[0] || {})[0] || ''}
defaultOrder="asc"
columns={Object.keys(enumRows[0] || {}).map(k => ({ key: k, label: k }))}
/>
</div>
</div>
@@ -316,7 +323,18 @@ export function OntologyPage() {
))}
</div>
<div className="rounded-lg border">
<DataTable
<FilterableTable
data={metricFilter === '全部' ? metrics : metrics.filter((m: any) => m.metric_category === metricFilter)}
sortOptions={[
{ key: 'indicator_name', label: '指标名称' },
{ key: 'metric_category', label: '分类' },
{ key: 'owner', label: '负责人' },
{ key: 'update_frequency', label: '频率' },
]}
defaultSort="indicator_name"
defaultOrder="asc"
filterKey="metric_category"
filterLabel="全部分类"
columns={[
{ key: 'metric_code', label: '指标编码', render: (r: any) => <code className="text-xs text-blue-600">{r.metric_code || '—'}</code> },
{ key: 'indicator_name', label: '指标名称', render: (r: any) => <span className="font-medium">{r.indicator_name}</span> },
@@ -331,7 +349,6 @@ export function OntologyPage() {
{ key: 'update_frequency', label: '频率', align: 'center' as const },
{ key: 'version', label: '版本', render: (r: any) => <span className="text-xs text-muted-foreground">{r.version || '—'}</span> },
]}
data={metricFilter === '全部' ? metrics : metrics.filter((m: any) => m.metric_category === metricFilter)}
/>
</div>
</div>
+57 -53
View File
@@ -1,20 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const PAGE_SIZE = 15
export function PlatformPage() {
const [platformPage, setPlatformPage] = useState(1)
const [marketingPage, setMarketingPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const { data: platformData, isLoading: platformLoading } = useQuery({
@@ -23,8 +18,8 @@ export function PlatformPage() {
})
const { data: marketingData, isLoading: marketingLoading } = useQuery({
queryKey: ['marketing/plans'],
queryFn: () => api.get('/marketing/plans'),
queryKey: ['marketing/plans', month],
queryFn: () => api.get('/marketing/plans', { params: { month } }),
})
const platformRows = (platformData as any)?.data || []
@@ -56,9 +51,6 @@ export function PlatformPage() {
{ name: '京东', 实收: totalJd, 折扣: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_discount || 0), 0), 佣金: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_commission || 0), 0) },
]
const pagedPlatform = useMemo(() => storesWithPlatform.slice((platformPage - 1) * PAGE_SIZE, platformPage * PAGE_SIZE), [storesWithPlatform, platformPage])
const pagedMarketing = useMemo(() => marketingRows.slice((marketingPage - 1) * PAGE_SIZE, marketingPage * PAGE_SIZE), [marketingRows, marketingPage])
if (pageLoading) {
return <LoadingSpinner text="加载平台数据..." />
}
@@ -101,51 +93,63 @@ export function PlatformPage() {
{/* 门店平台经济性 */}
<CollapsibleSection title={`门店平台经济性 (${storesWithPlatform.length})`} subtitle="各门店三平台实收与成本率">
<Pagination page={platformPage} pageSize={PAGE_SIZE} total={storesWithPlatform.length} onPageChange={setPlatformPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'meituan_received', label: '美团实收', align: 'right', render: (r) => formatCurrency(r.meituan_received) },
{ key: 'meituan_cost_rate_pct', label: '美团成本率', align: 'right', render: (r) => {
const v = Number(r.meituan_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
{ key: 'taobao_received', label: '淘宝实收', align: 'right', render: (r) => formatCurrency(r.taobao_received) },
{ key: 'taobao_cost_rate_pct', label: '淘宝成本率', align: 'right', render: (r) => {
const v = Number(r.taobao_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
{ key: 'jd_received', label: '京东实收', align: 'right', render: (r) => formatCurrency(r.jd_received) },
{ key: 'jd_cost_rate_pct', label: '京东成本率', align: 'right', render: (r) => {
const v = Number(r.jd_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
]}
data={pagedPlatform}
/>
</div>
<FilterableTable
data={storesWithPlatform}
sortOptions={[
{ key: 'meituan_received', label: '美团实收' },
{ key: 'meituan_cost_rate_pct', label: '美团成本率' },
{ key: 'taobao_received', label: '淘宝实收' },
{ key: 'jd_received', label: '京东实收' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="meituan_received"
defaultOrder="desc"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'meituan_received', label: '美团实收', align: 'right', render: (r) => formatCurrency(r.meituan_received) },
{ key: 'meituan_cost_rate_pct', label: '美团成本率', align: 'right', render: (r) => {
const v = Number(r.meituan_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
{ key: 'taobao_received', label: '淘宝实收', align: 'right', render: (r) => formatCurrency(r.taobao_received) },
{ key: 'taobao_cost_rate_pct', label: '淘宝成本率', align: 'right', render: (r) => {
const v = Number(r.taobao_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
{ key: 'jd_received', label: '京东实收', align: 'right', render: (r) => formatCurrency(r.jd_received) },
{ key: 'jd_cost_rate_pct', label: '京东成本率', align: 'right', render: (r) => {
const v = Number(r.jd_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
]}
/>
</CollapsibleSection>
{/* 营销方案ROI */}
<CollapsibleSection title={`营销方案分析 (${marketingRows.length})`} subtitle="各营销方案的订单、实收、优惠率与毛利率">
<Pagination page={marketingPage} pageSize={PAGE_SIZE} total={marketingRows.length} onPageChange={setMarketingPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'marketing_plan', label: '营销方案' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => {
const v = Number(r.discount_rate_pct || 0)
return <span className={v >= 25 ? 'font-medium text-red-600' : v >= 15 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
]}
data={pagedMarketing}
/>
</div>
<FilterableTable
data={marketingRows}
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'bill_count', label: '账单数' },
{ key: 'avg_bill_value', label: '客单价' },
{ key: 'discount_rate_pct', label: '优惠率' },
{ key: 'theoretical_margin_pct', label: '毛利率' },
]}
defaultSort="received"
defaultOrder="desc"
columns={[
{ key: 'marketing_plan', label: '营销方案' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => {
const v = Number(r.discount_rate_pct || 0)
return <span className={v >= 25 ? 'font-medium text-red-600' : v >= 15 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
]}
/>
</CollapsibleSection>
</div>
)
+14 -5
View File
@@ -68,9 +68,11 @@ export function ProductionPlanPage() {
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>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="h-6 w-6 text-green-600" />
<h1 className="text-xl font-bold"></h1>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
@@ -79,12 +81,15 @@ export function ProductionPlanPage() {
<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">
{/* 核心指标 - 第一行:菜品与BOM */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<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="种" />
</div>
{/* 核心指标 - 第二行:销售额与生产计划 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<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="有销量需求的半成品数" />
@@ -213,6 +218,10 @@ export function ProductionPlanPage() {
{/* 门店要货建议 */}
<CollapsibleSection title="门店要货建议" subtitle={`${storeDemand.length}家门店的原料需求与库存对比`} defaultOpen={false}>
<div className="mb-3 flex items-center gap-2">
<Package className="h-4 w-4 text-green-600" />
<span className="text-sm text-muted-foreground"> = - </span>
</div>
<FilterableTable
data={storeDemand}
filterKey="store_name"
+71 -55
View File
@@ -2,51 +2,45 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { FilterableTable } from '@/components/FilterableTable'
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'
const PAGE_SIZE = 10
import { useState } from 'react'
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)
const [month, setMonth] = useState('2026-04')
const [weeklyModal, setWeeklyModal] = useState<{ taskId: number; storeName: string } | null>(null)
const [weeklyText, setWeeklyText] = useState('')
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: weeklyData, isLoading: weeklyLoading } = useQuery({
queryKey: ['tasks/weekly-check'],
queryKey: ['tasks/weekly-check', month],
queryFn: () => api.get('/tasks/weekly-check', { 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: benchmarkData } = useQuery({
queryKey: ['benchmark/composite'],
queryFn: () => api.get('/benchmark/composite'),
queryKey: ['benchmark/composite', month],
queryFn: () => api.get('/benchmark/composite', { params: { month } }),
})
const { data: regionData } = useQuery({
queryKey: ['region/summary'],
queryFn: () => api.get('/region/summary'),
queryKey: ['region/summary', month],
queryFn: () => api.get('/region/summary', { params: { month } }),
})
const riskRows = ((riskData as any)?.data || []).filter((r: any) => !riskFilter || r.risk_level === riskFilter)
@@ -58,10 +52,6 @@ export function RegionalPage() {
const p0p1 = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0') || s.action_priority?.startsWith('P1'))
const needCheck = weeklyRows.filter((w: any) => parseInt(w.consecutive_no_improve_weeks) >= 2)
const pagedRiskRows = useMemo(() => riskRows.slice((riskPage - 1) * PAGE_SIZE, riskPage * PAGE_SIZE), [riskRows, riskPage])
const pagedNeedCheck = useMemo(() => needCheck.slice((checkPage - 1) * PAGE_SIZE, checkPage * PAGE_SIZE), [needCheck, checkPage])
const pagedBenchmark = useMemo(() => benchmarkRows.slice((benchPage - 1) * PAGE_SIZE, benchPage * PAGE_SIZE), [benchmarkRows, benchPage])
const pageLoading = riskLoading || weeklyLoading || priorityLoading
const weeklyMutation = useMutation({
@@ -96,7 +86,16 @@ export function RegionalPage() {
{regionRows.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<DataTable
<FilterableTable
data={regionRows}
sortOptions={[
{ key: 'region', label: '区域' },
{ key: 'total_received', label: '实收' },
{ key: 'avg_bill_value', label: '客单价' },
{ key: 'store_count', label: '门店数' },
]}
defaultSort="total_received"
defaultOrder="desc"
columns={[
{ key: 'region', label: '区域' },
{ key: 'store_count', label: '门店数', align: 'right' },
@@ -109,7 +108,6 @@ export function RegionalPage() {
{ key: 'yellow_count', label: '黄', align: 'center', render: (r) => <span className={r.yellow_count > 0 ? 'font-bold text-yellow-600' : ''}>{r.yellow_count}</span> },
{ key: 'green_count', label: '绿', align: 'center', render: (r) => <span className="text-green-600">{r.green_count}</span> },
]}
data={regionRows}
/>
</div>
)}
@@ -130,9 +128,20 @@ export function RegionalPage() {
{/* 门店红黄绿列表 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({riskRows.length})</h2>
<Pagination page={riskPage} pageSize={PAGE_SIZE} total={riskRows.length} onPageChange={setRiskPage} />
<div className="mt-3">
<DataTable
<FilterableTable
data={riskRows}
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'discount_rate_pct', label: '优惠率' },
{ key: 'theoretical_margin_pct', label: '毛利率' },
{ key: 'anomaly_rate_pct', label: '异常率' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="received"
defaultOrder="desc"
filterKey="risk_level"
filterLabel="全部风险"
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'risk_level', label: '风险', render: (r) => <Badge type="risk" text={r.risk_level} /> },
@@ -141,19 +150,22 @@ export function RegionalPage() {
{ key: 'theoretical_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
{ key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => formatPercent(r.anomaly_rate_pct) },
]}
data={pagedRiskRows}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
</div>
{/* 需到店检查门店 + 周检录入 */}
{needCheck.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold text-red-600">2({needCheck.length})</h2>
<Pagination page={checkPage} pageSize={PAGE_SIZE} total={needCheck.length} onPageChange={setCheckPage} />
<div className="mt-3">
<DataTable
<FilterableTable
data={needCheck}
sortOptions={[
{ key: 'consecutive_no_improve_weeks', label: '未改善周数' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="consecutive_no_improve_weeks"
defaultOrder="desc"
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'problem_indicator', label: '问题指标' },
@@ -163,10 +175,7 @@ export function RegionalPage() {
<button onClick={(e) => { e.stopPropagation(); setWeeklyModal({ taskId: r.task_id, storeName: r.store_name }); setWeeklyText('') }} className="rounded-md border px-2 py-0.5 text-xs hover:bg-muted"></button>
) },
]}
data={pagedNeedCheck}
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
/>
</div>
</div>
)}
@@ -174,25 +183,32 @@ export function RegionalPage() {
{benchmarkRows.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<Pagination page={benchPage} pageSize={PAGE_SIZE} total={benchmarkRows.length} onPageChange={setBenchPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'benchmark_score', label: '标杆得分', align: 'right', render: (r) => {
const v = Number(r.benchmark_score || 0)
return <span className={v >= 80 ? 'text-green-600' : v >= 60 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}</span>
} },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) },
{ key: 'theoretical_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
{ key: 'member_bill_share_pct', label: '会员占比', align: 'right', render: (r) => formatPercent(r.member_bill_share_pct) },
]}
data={pagedBenchmark}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
<FilterableTable
data={benchmarkRows}
sortOptions={[
{ key: 'benchmark_score', label: '标杆得分' },
{ key: 'received', label: '实收' },
{ key: 'avg_bill_value', label: '客单价' },
{ key: 'discount_rate_pct', label: '优惠率' },
{ key: 'theoretical_margin_pct', label: '毛利率' },
{ key: 'member_bill_share_pct', label: '会员占比' },
]}
defaultSort="benchmark_score"
defaultOrder="desc"
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'benchmark_score', label: '标杆得分', align: 'right', render: (r) => {
const v = Number(r.benchmark_score || 0)
return <span className={v >= 80 ? 'text-green-600' : v >= 60 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}</span>
} },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) },
{ key: 'theoretical_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
{ key: 'member_bill_share_pct', label: '会员占比', align: 'right', render: (r) => formatPercent(r.member_bill_share_pct) },
]}
/>
</div>
)}
+19 -13
View File
@@ -7,6 +7,8 @@ import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { BarChart3, TrendingDown, Users, Receipt } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
import { useState } from 'react'
const CHANNEL_COLORS: Record<string, string> = {
wechat: '#07c160',
@@ -23,22 +25,23 @@ const CHANNEL_COLORS: Record<string, string> = {
export function RevenuePage() {
const navigate = useNavigate()
const [month, setMonth] = useState('2026-04')
const { data: dailyData, isLoading: dailyLoading } = useQuery({
queryKey: ['revenue/daily-summary'],
queryFn: () => api.get('/revenue/daily-summary'),
queryKey: ['revenue/daily-summary', month],
queryFn: () => api.get('/revenue/daily-summary', { params: { month } }),
})
const { data: channelData, isLoading: channelLoading } = useQuery({
queryKey: ['revenue/channel'],
queryFn: () => api.get('/revenue/channel'),
queryKey: ['revenue/channel', month],
queryFn: () => api.get('/revenue/channel', { params: { month } }),
})
const { data: mealPeriodData, isLoading: mealLoading } = useQuery({
queryKey: ['revenue/meal-period'],
queryFn: () => api.get('/revenue/meal-period'),
queryKey: ['revenue/meal-period', month],
queryFn: () => api.get('/revenue/meal-period', { params: { month } }),
})
const { data: storeRankingData, isLoading: rankLoading } = useQuery({
queryKey: ['revenue/store-ranking'],
queryFn: () => api.get('/revenue/store-ranking'),
queryKey: ['revenue/store-ranking', month],
queryFn: () => api.get('/revenue/store-ranking', { params: { month } }),
})
const pageLoading = dailyLoading || channelLoading || mealLoading || rankLoading
@@ -138,12 +141,15 @@ export function RevenuePage() {
return (
<div className="space-y-4">
{/* 标题 */}
<div className="flex items-center gap-2">
<BarChart3 className="text-blue-500" size={24} />
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · · 20264</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="text-blue-500" size={24} />
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · </p>
</div>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 核心指标 */}
+167 -88
View File
@@ -1,65 +1,88 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { useState, useEffect } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const PAGE_SIZE = 15
const ANOMALY_PAGE_SIZE = 20
function SectionLoading({ text }: { text: string }) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-muted border-t-primary" />
{text}
</div>
)
}
export function RiskPage() {
const [anomalyPage, setAnomalyPage] = useState(1)
const [cashierPage, setCashierPage] = useState(1)
const [zeroPage, setZeroPage] = useState(1)
const [riskTab, setRiskTab] = useState<'anomaly' | 'zero' | 'cashier'>('anomaly')
const [month, setMonth] = useState('2026-04')
const [anomalyPage, setAnomalyPage] = useState(1)
const [anomalyStore, setAnomalyStore] = useState('')
const [anomalyReason, setAnomalyReason] = useState('')
const [anomalyCashier, setAnomalyCashier] = useState('')
const [debouncedCashier, setDebouncedCashier] = useState('')
const { data: anomalyData, isLoading: anomalyLoading } = useQuery({
queryKey: ['risk/anomaly'],
queryFn: () => api.get('/risk/anomaly'),
useEffect(() => {
const t = setTimeout(() => setDebouncedCashier(anomalyCashier), 400)
return () => clearTimeout(t)
}, [anomalyCashier])
const { data: anomalyData, isLoading: anomalyLoading, isFetching: anomalyFetching } = useQuery({
queryKey: ['risk/anomaly', month, anomalyPage, anomalyStore, anomalyReason, debouncedCashier],
queryFn: () => api.get('/risk/anomaly', { params: { month, page: anomalyPage, page_size: ANOMALY_PAGE_SIZE, ...(anomalyStore ? { store: anomalyStore } : {}), ...(anomalyReason ? { reason: anomalyReason } : {}), ...(debouncedCashier ? { cashier: debouncedCashier } : {}) } }),
placeholderData: keepPreviousData,
})
const { data: zeroData, isLoading: zeroLoading } = useQuery({
queryKey: ['risk/zero-received'],
queryFn: () => api.get('/risk/zero-received'),
const { data: zeroData, isLoading: zeroLoading, isFetching: zeroFetching } = useQuery({
queryKey: ['risk/zero-received', month],
queryFn: () => api.get('/risk/zero-received', { params: { month } }),
staleTime: 5 * 60 * 1000,
placeholderData: keepPreviousData,
})
const { data: cashierData, isLoading: cashierLoading } = useQuery({
queryKey: ['risk/cashier'],
queryFn: () => api.get('/risk/cashier'),
const { data: cashierData, isLoading: cashierLoading, isFetching: cashierFetching } = useQuery({
queryKey: ['risk/cashier', month],
queryFn: () => api.get('/risk/cashier', { params: { month } }),
staleTime: 5 * 60 * 1000,
placeholderData: keepPreviousData,
})
const anomalyRows = (anomalyData as any)?.data || []
const anomalyMeta = (anomalyData as any)?.meta || { total: 0 }
const zeroRows = (zeroData as any)?.data || []
const cashierRows = (cashierData as any)?.data || []
const storeNames: string[] = Array.from(new Set<string>(cashierRows.map((r: any) => r.store_name as string).filter(Boolean))).sort()
const pageLoading = anomalyLoading || zeroLoading || cashierLoading
const initialLoading = !anomalyData && !zeroData && !cashierData
const totalAnomaly = anomalyRows.length
const totalAnomaly = anomalyMeta.total || anomalyRows.length
const totalZero = zeroRows.length
const highRiskCashiers = cashierRows.filter((r: any) => Number(r.anomaly_rate_pct || 0) >= 10).length
const avgAnomalyRate = cashierRows.length > 0
? cashierRows.reduce((s: number, r: any) => s + Number(r.anomaly_rate_pct || 0), 0) / cashierRows.length
: 0
const pagedAnomaly = useMemo(() => anomalyRows.slice((anomalyPage - 1) * PAGE_SIZE, anomalyPage * PAGE_SIZE), [anomalyRows, anomalyPage])
const pagedZero = useMemo(() => zeroRows.slice((zeroPage - 1) * PAGE_SIZE, zeroPage * PAGE_SIZE), [zeroRows, zeroPage])
const pagedCashier = useMemo(() => cashierRows.slice((cashierPage - 1) * PAGE_SIZE, cashierPage * PAGE_SIZE), [cashierRows, cashierPage])
const cashierChartData = [...cashierRows].sort((a: any, b: any) => Number(b.anomaly_bills || 0) - Number(a.anomaly_bills || 0)).slice(0, 15).map((r: any) => ({ ...r, anomaly_bills: Number(r.anomaly_bills || 0) }))
const cashierChartData = cashierRows.slice(0, 15)
if (pageLoading) {
if (initialLoading) {
return <LoadingSpinner text="加载风险数据..." />
}
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · 20264</p>
<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"> · · </p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 概览指标 */}
@@ -73,16 +96,20 @@ export function RiskPage() {
</CollapsibleSection>
{/* 收银员风险图 */}
<CollapsibleSection title="收银员异常TOP15" subtitle="异常操作率最高的收银员">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={cashierChartData} layout="vertical" margin={{ left: 60 }}>
<CollapsibleSection title="收银员异常账单数TOP15" subtitle="异常账单数最多的收银员(双击下方表格行可查看明细)">
{cashierFetching ? (
<SectionLoading text="加载图表数据..." />
) : (
<ResponsiveContainer width="100%" height={400}>
<BarChart data={cashierChartData} layout="vertical" margin={{ left: 100, right: 40 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" unit="%" tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="cashier" tick={{ fontSize: 10 }} width={60} />
<Tooltip formatter={(v: any) => `${Number(v).toFixed(2)}%`} />
<Bar dataKey="anomaly_rate_pct" fill="#ef4444" name="异常" />
<XAxis type="number" tick={{ fontSize: 10 }} domain={[0, 'dataMax']} />
<YAxis type="category" dataKey="cashier" tick={{ fontSize: 11 }} width={90} />
<Tooltip formatter={(v: any) => formatNumber(Number(v))} />
<Bar dataKey="anomaly_bills" fill="#ef4444" name="异常账单数" />
</BarChart>
</ResponsiveContainer>
)}
</CollapsibleSection>
{/* 风险明细表 — Tab切换 */}
@@ -100,67 +127,119 @@ export function RiskPage() {
</div>
{riskTab === 'anomaly' && (
anomalyFetching ? <SectionLoading text="加载异常账单..." /> :
<>
<Pagination page={anomalyPage} pageSize={PAGE_SIZE} total={anomalyRows.length} onPageChange={setAnomalyPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
{ key: 'anomaly_reason', label: '异常原因' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
data={pagedAnomaly}
/>
</div>
<div className="mb-2 flex gap-4 rounded-md bg-muted/50 px-3 py-2 text-xs">
<span className="text-muted-foreground"></span>
<span> <b className="text-foreground">{formatCurrency(anomalyMeta.sum_consumption)}</b></span>
<span> <b className="text-foreground">{formatCurrency(anomalyMeta.sum_discount)}</b></span>
<span> <b className="text-foreground">{formatCurrency(Number(anomalyMeta.sum_consumption || 0) - Number(anomalyMeta.sum_discount || 0))}</b></span>
<span> <b className="text-foreground">{formatCurrency(anomalyMeta.sum_received)}</b></span>
<span className="text-muted-foreground">| {totalAnomaly} </span>
</div>
<FilterableTable
data={anomalyRows}
serverSide
page={anomalyPage}
onPageChange={setAnomalyPage}
pageSize={ANOMALY_PAGE_SIZE}
total={anomalyMeta.total}
filter={anomalyStore}
onFilterChange={(v) => { setAnomalyStore(v); setAnomalyPage(1) }}
filterKey="store_name"
filterLabel="全部门店"
filterOptions={storeNames}
statusFilterKey="anomaly_reason"
statusFilterLabel="全部异常原因"
statusOptions={[
{ value: '有消费无实收', label: '有消费无实收' },
{ value: '优惠大于消费', label: '优惠大于消费' },
{ value: '消费-优惠与实收不平', label: '消费-优惠与实收不平' },
]}
statusFilterValue={anomalyReason}
onStatusFilterChange={(v) => { setAnomalyReason(v); setAnomalyPage(1) }}
searchPlaceholder="搜索收银员..."
serverSearchValue={anomalyCashier}
onServerSearchChange={(v) => { setAnomalyCashier(v); setAnomalyPage(1) }}
sortOptions={[
{ key: 'consumption', label: '消费额' },
{ key: 'discount_total', label: '优惠' },
{ key: 'received_total', label: '实收' },
]}
defaultSort="consumption"
defaultOrder="desc"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
{ key: 'anomaly_reason', label: '异常原因' },
{ key: 'cashier', label: '收银员' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
/>
</>
)}
{riskTab === 'zero' && (
<>
<Pagination page={zeroPage} pageSize={PAGE_SIZE} total={zeroRows.length} onPageChange={setZeroPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'zero_received_type', label: '零实收类型' },
{ key: 'cashier', label: '收银员' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
data={pagedZero}
/>
</div>
</>
zeroFetching ? <SectionLoading text="加载零实收数据..." /> :
<FilterableTable
data={zeroRows}
sortOptions={[
{ key: 'consumption', label: '消费额' },
{ key: 'discount_total', label: '优惠' },
{ key: 'store_name', label: '门店' },
]}
defaultSort="consumption"
defaultOrder="desc"
filterKey="store_name"
filterLabel="全部门店"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'zero_received_type', label: '零实收类型' },
{ key: 'cashier', label: '收银员' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
/>
)}
{riskTab === 'cashier' && (
<>
<Pagination page={cashierPage} pageSize={PAGE_SIZE} total={cashierRows.length} onPageChange={setCashierPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'cashier', label: '收银员' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'anomaly_bills', label: '异常账单数', align: 'right', render: (r) => formatNumber(r.anomaly_bills) },
{ key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => {
const v = Number(r.anomaly_rate_pct || 0)
return <span className={v >= 10 ? 'font-medium text-red-600' : v >= 5 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(2)}%</span>
}},
{ key: 'anomaly_consumption', label: '异常消费额', align: 'right', render: (r) => formatCurrency(r.anomaly_consumption) },
]}
data={pagedCashier}
/>
</div>
</>
cashierFetching ? <SectionLoading text="加载收银员风险..." /> :
<FilterableTable
data={cashierRows}
onRowDoubleClick={(r: any) => {
setAnomalyCashier(r.cashier)
setDebouncedCashier(r.cashier)
setAnomalyPage(1)
setRiskTab('anomaly')
}}
sortOptions={[
{ key: 'anomaly_rate_pct', label: '异常率' },
{ key: 'bill_count', label: '账单数' },
{ key: 'received', label: '实收' },
{ key: 'anomaly_bills', label: '异常账单数' },
{ key: 'cashier', label: '收银员' },
]}
defaultSort="anomaly_bills"
defaultOrder="desc"
columns={[
{ key: 'cashier', label: '收银员' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'anomaly_bills', label: '异常账单数', align: 'right', render: (r) => formatNumber(r.anomaly_bills) },
{ key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => {
const v = Number(r.anomaly_rate_pct || 0)
return <span className={v >= 10 ? 'font-medium text-red-600' : v >= 5 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(2)}%</span>
}},
{ key: 'anomaly_consumption', label: '异常消费额', align: 'right', render: (r) => formatCurrency(r.anomaly_consumption) },
]}
/>
)}
</CollapsibleSection>
</div>
+34 -43
View File
@@ -1,21 +1,17 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const ABC_COLORS = { 'A-核心': '#22c55e', 'B-成长': '#3b82f6', 'C-长尾': '#ef4444' }
const PAGE_SIZE = 15
export function SKUPage() {
const [abcFilter, setAbcFilter] = useState('')
const [skuPage, setSkuPage] = useState(1)
const [month, setMonth] = useState('2026-04')
const { data: skuData, isLoading: skuLoading } = useQuery({
@@ -28,7 +24,7 @@ export function SKUPage() {
queryFn: () => api.get('/sku/category'),
})
const skuRows = ((skuData as any)?.data || []).filter((r: any) => !abcFilter || r.abc_class === abcFilter)
const skuRows = ((skuData as any)?.data || [])
const catRows = (catData as any)?.data || []
const abcSummary = (skuData as any)?.data?.reduce((acc: any, r: any) => {
@@ -53,8 +49,6 @@ export function SKUPage() {
const lowValueSku = ((skuData as any)?.data || []).filter((r: any) => Number(r.received_amount) < 1000).length
const singleStoreSku = ((skuData as any)?.data || []).filter((r: any) => Number(r.store_count) <= 1).length
const pagedSku = useMemo(() => skuRows.slice((skuPage - 1) * PAGE_SIZE, skuPage * PAGE_SIZE), [skuRows, skuPage])
const pageLoading = skuLoading || catLoading
if (pageLoading) {
@@ -117,40 +111,37 @@ export function SKUPage() {
{/* SKU明细表 */}
<CollapsibleSection title={`SKU明细 (${skuRows.length})`} subtitle="可按ABC分类筛选,点击表头排序">
<div className="mb-3 flex gap-2">
{['', 'A-核心', 'B-成长', 'C-长尾'].map((f) => (
<button
key={f || 'all'}
onClick={() => { setAbcFilter(f); setSkuPage(1) }}
className={`rounded-md border px-3 py-1.5 text-sm ${abcFilter === f ? 'border-primary bg-primary text-primary-foreground' : ''}`}
>
{f || '全部'}
</button>
))}
</div>
<Pagination page={skuPage} pageSize={PAGE_SIZE} total={skuRows.length} onPageChange={setSkuPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'dish_name', label: '菜品名称' },
{ key: 'category_level1', label: '一级品类' },
{ key: 'abc_class', label: 'ABC', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
r.abc_class === 'A-核心' ? 'bg-green-100 text-green-700' :
r.abc_class === 'B-成长' ? 'bg-blue-100 text-blue-700' :
'bg-red-100 text-red-700'
}`}>{r.abc_class}</span>
)},
{ key: 'sales_quadrant', label: '象限' },
{ key: 'store_count', label: '覆盖门店', align: 'center' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) },
{ key: 'revenue_share_pct', label: '占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => `${Number(r.discount_rate_pct).toFixed(1)}%` },
]}
data={pagedSku}
/>
</div>
<FilterableTable
data={skuRows}
sortOptions={[
{ key: 'received_amount', label: '实收' },
{ key: 'bill_count', label: '账单数' },
{ key: 'revenue_share_pct', label: '占比' },
{ key: 'store_count', label: '覆盖门店' },
{ key: 'dish_name', label: '菜品名称' },
]}
defaultSort="received_amount"
defaultOrder="desc"
filterKey="abc_class"
filterLabel="全部ABC"
columns={[
{ key: 'dish_name', label: '菜品名称' },
{ key: 'category_level1', label: '一级品类' },
{ key: 'abc_class', label: 'ABC', render: (r) => (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
r.abc_class === 'A-核心' ? 'bg-green-100 text-green-700' :
r.abc_class === 'B-成长' ? 'bg-blue-100 text-blue-700' :
'bg-red-100 text-red-700'
}`}>{r.abc_class}</span>
)},
{ key: 'sales_quadrant', label: '象限' },
{ key: 'store_count', label: '覆盖门店', align: 'center' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) },
{ key: 'revenue_share_pct', label: '占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => `${Number(r.discount_rate_pct).toFixed(1)}%` },
]}
/>
</CollapsibleSection>
{/* 长尾治理建议 */}
+107 -88
View File
@@ -1,18 +1,16 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ZAxis, ReferenceLine, Cell } from 'recharts'
import { StoreMap } from '@/components/StoreMap'
import { MonthPicker } from '@/components/MonthPicker'
const PAGE_SIZE = 15
const SCENE_COLORS: Record<string, string> = {
'办公园区': '#3b82f6', '商场商业体': '#a855f7', '社区居民': '#22c55e', '街边综合': '#dc2626', '交通枢纽': '#f97316', '校园档口': '#06b6d4', '特殊业态': '#6b7280',
}
@@ -31,9 +29,6 @@ export function SiteSelectionPage() {
const navigate = useNavigate()
const [tab, setTab] = useState<SiteTab>('benchmark')
const [sceneFilter, setSceneFilter] = useState('')
const [replPage, setReplPage] = useState(1)
const [overlapPage, setOverlapPage] = useState(1)
const [profilePage, setProfilePage] = useState(1)
const [month, setMonth] = useState('2026-04')
const { data: profileData, isLoading: profileLoading } = useQuery({
@@ -67,10 +62,6 @@ export function SiteSelectionPage() {
const overlapRows = (overlapData as any)?.data || []
const districtRows = (districtData as any)?.data || []
const pagedRepl = useMemo(() => replRows.slice((replPage - 1) * PAGE_SIZE, replPage * PAGE_SIZE), [replRows, replPage])
const pagedOverlap = useMemo(() => overlapRows.slice((overlapPage - 1) * PAGE_SIZE, overlapPage * PAGE_SIZE), [overlapRows, overlapPage])
const pagedProfile = useMemo(() => profileRows.slice((profilePage - 1) * PAGE_SIZE, profilePage * PAGE_SIZE), [profileRows, profilePage])
const pageLoading = profileLoading || segLoading || replLoading || overlapLoading || districtLoading
if (pageLoading) {
@@ -178,7 +169,18 @@ export function SiteSelectionPage() {
{tab === 'benchmark' && (
<div className="space-y-4">
<CollapsibleSection title={`场景 × 面积分段基准 (${segRows.length})`} subtitle="各场景各面积段的经营基准,按坪效降序">
<DataTable
<FilterableTable
data={segRows}
sortOptions={[
{ key: 'avg_received_per_sqm', label: '平均坪效' },
{ key: 'median_received_per_sqm', label: '中位坪效' },
{ key: 'avg_received', label: '平均实收' },
{ key: 'site_scene', label: '场景' },
]}
defaultSort="avg_received_per_sqm"
defaultOrder="desc"
filterKey="site_scene"
filterLabel="全部场景"
columns={[
{ key: 'site_scene', label: '场景' },
{ key: 'area_band', label: '面积段' },
@@ -191,40 +193,36 @@ export function SiteSelectionPage() {
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'avg_repeat_rate_pct', label: '复购率', align: 'right', render: (r) => formatPercent(r.avg_repeat_rate_pct) },
]}
data={segRows}
/>
</CollapsibleSection>
<CollapsibleSection title="门店画像明细" subtitle="全部标准门店选址画像,可按场景筛选">
<div className="mb-3 flex gap-2">
{['', '办公园区', '商场商业体', '社区居民', '街边综合'].map((f) => (
<button
key={f || 'all'}
onClick={() => { setSceneFilter(f); setProfilePage(1) }}
className={`rounded-md border px-3 py-1.5 text-sm ${sceneFilter === f ? 'border-primary bg-primary text-primary-foreground' : ''}`}
>
{f || '全部'}
</button>
))}
</div>
<Pagination page={profilePage} pageSize={PAGE_SIZE} total={profileRows.length} onPageChange={setProfilePage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'site_scene', label: '场景', render: (r) => <span className="rounded px-2 py-0.5 text-xs" style={{ background: (SCENE_COLORS[r.site_scene] || '#999') + '20', color: SCENE_COLORS[r.site_scene] || '#999' }}>{r.site_scene}</span> },
{ key: 'area_band', label: '面积段' },
{ key: 'area_sqm', label: '面积(㎡)', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}` : '-' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => formatPercent(r.repeat_rate_pct) },
{ key: 'actual_food_cost_rate_pct', label: '成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) },
]}
data={pagedProfile}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
<FilterableTable
data={profileRows}
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'monthly_received_per_sqm', label: '坪效' },
{ key: 'area_sqm', label: '面积' },
{ key: 'avg_bill_value', label: '客单价' },
{ key: 'repeat_rate_pct', label: '复购率' },
]}
defaultSort="received"
defaultOrder="desc"
filterKey="site_scene"
filterLabel="全部场景"
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'site_scene', label: '场景', render: (r) => <span className="rounded px-2 py-0.5 text-xs" style={{ background: (SCENE_COLORS[r.site_scene] || '#999') + '20', color: SCENE_COLORS[r.site_scene] || '#999' }}>{r.site_scene}</span> },
{ key: 'area_band', label: '面积段' },
{ key: 'area_sqm', label: '面积(㎡)', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}` : '-' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => formatPercent(r.repeat_rate_pct) },
{ key: 'actual_food_cost_rate_pct', label: '成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) },
]}
/>
</CollapsibleSection>
</div>
)}
@@ -232,32 +230,39 @@ export function SiteSelectionPage() {
{/* 复制评分 */}
{tab === 'replication' && (
<CollapsibleSection title={`门店复制评分 (${replRows.length})`} subtitle="综合坪效、日均、复购、优惠纪律、成本、平台、执行七维评分">
<Pagination page={replPage} pageSize={PAGE_SIZE} total={replRows.length} onPageChange={setReplPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'site_scene', label: '场景' },
{ key: 'area_sqm', label: '面积', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}` : '-' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) },
{ key: 'nearest_store_name', label: '最近门店' },
{ key: 'nearest_distance_km', label: '距离(km)', align: 'right', render: (r) => `${r.nearest_distance_km}km` },
{ key: 'site_replication_score', label: '复制评分', align: 'right', render: (r) => {
const v = Number(r.site_replication_score || 0)
return <span className={v >= 75 ? 'font-bold text-green-600' : v >= 60 ? 'text-blue-600' : v < 40 ? 'text-red-600' : ''}>{v.toFixed(2)}</span>
} },
{ key: 'replication_recommendation', label: '复制建议', render: (r) => {
const rec = r.replication_recommendation
const cls = rec === '优先提炼选址原型' ? 'bg-green-100 text-green-700' : rec === '不宜作为选址标杆' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'
return <span className={`rounded px-2 py-0.5 text-xs ${cls}`}>{rec}</span>
} },
{ key: 'spatial_recommendation', label: '空间建议', render: (r) => <span className="text-xs text-muted-foreground">{r.spatial_recommendation}</span> },
]}
data={pagedRepl}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
<FilterableTable
data={replRows}
sortOptions={[
{ key: 'site_replication_score', label: '复制评分' },
{ key: 'received', label: '实收' },
{ key: 'monthly_received_per_sqm', label: '坪效' },
{ key: 'nearest_distance_km', label: '距离' },
]}
defaultSort="site_replication_score"
defaultOrder="desc"
filterKey="replication_recommendation"
filterLabel="全部建议"
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'site_scene', label: '场景' },
{ key: 'area_sqm', label: '面积', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}` : '-' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) },
{ key: 'nearest_store_name', label: '最近门店' },
{ key: 'nearest_distance_km', label: '距离(km)', align: 'right', render: (r) => `${r.nearest_distance_km}km` },
{ key: 'site_replication_score', label: '复制评分', align: 'right', render: (r) => {
const v = Number(r.site_replication_score || 0)
return <span className={v >= 75 ? 'font-bold text-green-600' : v >= 60 ? 'text-blue-600' : v < 40 ? 'text-red-600' : ''}>{v.toFixed(2)}</span>
} },
{ key: 'replication_recommendation', label: '复制建议', render: (r) => {
const rec = r.replication_recommendation
const cls = rec === '优先提炼选址原型' ? 'bg-green-100 text-green-700' : rec === '不宜作为选址标杆' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'
return <span className={`rounded px-2 py-0.5 text-xs ${cls}`}>{rec}</span>
} },
{ key: 'spatial_recommendation', label: '空间建议', render: (r) => <span className="text-xs text-muted-foreground">{r.spatial_recommendation}</span> },
]}
/>
</CollapsibleSection>
)}
@@ -270,24 +275,30 @@ export function SiteSelectionPage() {
<MetricCard title="观察(2-3km)" value={overlapRows.filter((r: any) => r.distance_km >= 2).length} format="number" description="不能仅用直线距离判断" />
</div>
<CollapsibleSection title={`空间重叠风险 (${overlapRows.length}对)`} subtitle="3公里内门店两两距离与重叠风险等级">
<Pagination page={overlapPage} pageSize={PAGE_SIZE} total={overlapRows.length} onPageChange={setOverlapPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name_a', label: '门店A' },
{ key: 'store_name_b', label: '门店B' },
{ key: 'distance_km', label: '距离(km)', align: 'right', render: (r) => `${Number(r.distance_km).toFixed(2)}km` },
{ key: 'received_a', label: 'A实收', align: 'right', render: (r) => formatCurrency(r.received_a) },
{ key: 'received_b', label: 'B实收', align: 'right', render: (r) => formatCurrency(r.received_b) },
{ key: 'overlap_risk', label: '风险等级', render: (r) => {
const risk = r.overlap_risk
const color = RISK_COLORS[risk] || '#999'
return <span className="rounded px-2 py-0.5 text-xs font-medium" style={{ background: color + '20', color }}>{risk}</span>
} },
]}
data={pagedOverlap}
/>
</div>
<FilterableTable
data={overlapRows}
sortOptions={[
{ key: 'distance_km', label: '距离' },
{ key: 'received_a', label: 'A实收' },
{ key: 'received_b', label: 'B实收' },
]}
defaultSort="distance_km"
defaultOrder="asc"
filterKey="overlap_risk"
filterLabel="全部风险"
columns={[
{ key: 'store_name_a', label: '门店A' },
{ key: 'store_name_b', label: '门店B' },
{ key: 'distance_km', label: '距离(km)', align: 'right', render: (r) => `${Number(r.distance_km).toFixed(2)}km` },
{ key: 'received_a', label: 'A实收', align: 'right', render: (r) => formatCurrency(r.received_a) },
{ key: 'received_b', label: 'B实收', align: 'right', render: (r) => formatCurrency(r.received_b) },
{ key: 'overlap_risk', label: '风险等级', render: (r) => {
const risk = r.overlap_risk
const color = RISK_COLORS[risk] || '#999'
return <span className="rounded px-2 py-0.5 text-xs font-medium" style={{ background: color + '20', color }}>{risk}</span>
} },
]}
/>
</CollapsibleSection>
</div>
)}
@@ -308,7 +319,16 @@ export function SiteSelectionPage() {
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title={`区域基准明细 (${districtRows.length})`} subtitle="按区域汇总的经营基准">
<DataTable
<FilterableTable
data={districtRows}
sortOptions={[
{ key: 'avg_received', label: '平均实收' },
{ key: 'avg_received_per_sqm', label: '平均坪效' },
{ key: 'total_received', label: '总实收' },
{ key: 'district', label: '区域' },
]}
defaultSort="avg_received"
defaultOrder="desc"
columns={[
{ key: 'district', label: '区域' },
{ key: 'store_count', label: '门店数', align: 'center' },
@@ -322,7 +342,6 @@ export function SiteSelectionPage() {
{ key: 'p0_count', label: 'P0', align: 'center', render: (r) => r.p0_count > 0 ? <span className="font-bold text-red-600">{r.p0_count}</span> : '0' },
{ key: 'p1_count', label: 'P1', align: 'center', render: (r) => r.p1_count > 0 ? <span className="font-bold text-yellow-600">{r.p1_count}</span> : '0' },
]}
data={districtRows}
/>
</CollapsibleSection>
</div>
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom'
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ZAxis, Legend, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis } from 'recharts'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
+16 -11
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Tabs } from '@/components/Tabs'
import { MonthPicker } from '@/components/MonthPicker'
import { OverallAnalysisTab } from '@/components/smart-scheduling/OverallAnalysisTab'
import { StaffingForecastTab } from '@/components/smart-scheduling/StaffingForecastTab'
import { TrafficHeatmapTab } from '@/components/smart-scheduling/TrafficHeatmapTab'
@@ -22,25 +23,29 @@ const TABS = [
export function SmartSchedulingPage() {
const [activeTab, setActiveTab] = useState('overall')
const [month, setMonth] = useState('2026-04')
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1">20264 · × × · 103</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1"> × × · 103</p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
<div>
{activeTab === 'overall' && <OverallAnalysisTab />}
{activeTab === 'forecast' && <StaffingForecastTab />}
{activeTab === 'traffic' && <TrafficHeatmapTab />}
{activeTab === 'match' && <StaffingMatchTab />}
{activeTab === 'efficiency' && <EfficiencyBenchmarkTab />}
{activeTab === 'suggestion' && <SchedulingSuggestionTab />}
{activeTab === 'alert' && <AttendanceAlertTab />}
{activeTab === 'employee' && <EmployeeAnalysisTab />}
{activeTab === 'overall' && <OverallAnalysisTab month={month} />}
{activeTab === 'forecast' && <StaffingForecastTab month={month} />}
{activeTab === 'traffic' && <TrafficHeatmapTab month={month} />}
{activeTab === 'match' && <StaffingMatchTab month={month} />}
{activeTab === 'efficiency' && <EfficiencyBenchmarkTab month={month} />}
{activeTab === 'suggestion' && <SchedulingSuggestionTab month={month} />}
{activeTab === 'alert' && <AttendanceAlertTab month={month} />}
{activeTab === 'employee' && <EmployeeAnalysisTab month={month} />}
</div>
</div>
)
+76 -57
View File
@@ -4,8 +4,6 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber, cn } from '@/lib/utils'
@@ -40,12 +38,11 @@ export function StoreDetailPage() {
const { code } = useParams()
const navigate = useNavigate()
const [tab, setTab] = useState<DetailTab>('overview')
const [anomalyPage, setAnomalyPage] = useState(1)
const [month, setMonth] = useState('2026-05')
const [month, setMonth] = useState('2026-04')
const { data: storeData } = useQuery({
queryKey: ['store', code],
queryFn: () => api.get(`/stores/${code}`),
queryKey: ['store', code, month],
queryFn: () => api.get(`/stores/${code}`, { params: { month } }),
})
const { data: healthData } = useQuery({
@@ -54,8 +51,8 @@ export function StoreDetailPage() {
})
const { data: dailyData } = useQuery({
queryKey: ['store', code, 'daily'],
queryFn: () => api.get(`/stores/${code}/daily`),
queryKey: ['store', code, 'daily', month],
queryFn: () => api.get(`/stores/${code}/daily`, { params: { month } }),
})
const { data: tasksData } = useQuery({
@@ -65,32 +62,32 @@ export function StoreDetailPage() {
})
const { data: mealData } = useQuery({
queryKey: ['store', code, 'meal-period'],
queryFn: () => api.get(`/stores/${code}/meal-period`),
queryKey: ['store', code, 'meal-period', month],
queryFn: () => api.get(`/stores/${code}/meal-period`, { params: { month } }),
enabled: tab === 'meal',
})
const { data: catData } = useQuery({
queryKey: ['store', code, 'category-mix'],
queryFn: () => api.get(`/stores/${code}/category-mix`),
queryKey: ['store', code, 'category-mix', month],
queryFn: () => api.get(`/stores/${code}/category-mix`, { params: { month } }),
enabled: tab === 'category',
})
const { data: costData } = useQuery({
queryKey: ['store', code, 'cost'],
queryFn: () => api.get(`/stores/${code}/cost`),
queryKey: ['store', code, 'cost', month],
queryFn: () => api.get(`/stores/${code}/cost`, { params: { month } }),
enabled: tab === 'cost',
})
const { data: memberData } = useQuery({
queryKey: ['store', code, 'member'],
queryFn: () => api.get(`/stores/${code}/member`),
queryKey: ['store', code, 'member', month],
queryFn: () => api.get(`/stores/${code}/member`, { params: { month } }),
enabled: tab === 'member',
})
const { data: anomalyData } = useQuery({
queryKey: ['store', code, 'anomalies'],
queryFn: () => api.get(`/stores/${code}/anomalies`, { params: { page: anomalyPage, page_size: 10 } }),
queryKey: ['store', code, 'anomalies', month],
queryFn: () => api.get(`/stores/${code}/anomalies`, { params: { month, page_size: 200 } }),
enabled: tab === 'anomaly',
})
@@ -104,7 +101,7 @@ export function StoreDetailPage() {
const cost = (costData as any)?.data
const member = (memberData as any)?.data
const anomalyRows = ((anomalyData as any)?.data) || []
const anomalyTotal = (anomalyData as any)?.meta?.total || 0
const anomalyTotal = (anomalyData as any)?.meta?.total || anomalyRows.length
if (!sd) return <LoadingSpinner text="加载门店详情..." />
const sc = sd.scorecard
@@ -153,22 +150,24 @@ export function StoreDetailPage() {
<ArrowLeft size={16} />
</button>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold">{sc.store_name}</h1>
{health && (
<>
<span className={cn('text-lg font-bold', Number(health.health_score) >= 75 ? 'text-green-600' : Number(health.health_score) >= 55 ? 'text-yellow-600' : 'text-red-600')}>
{health.health_score}
</span>
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
health.health_status === '健康' ? 'bg-green-100 text-green-700 border-green-300' :
health.health_status === '健康' ? 'bg-yellow-100 text-yellow-700 border-yellow-300' :
'bg-red-100 text-red-700 border-red-300')
}>{health.health_status}</span>
</>
)}
{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>}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold">{sc.store_name}</h1>
{health && (
<>
<span className={cn('text-lg font-bold', Number(health.health_score) >= 75 ? 'text-green-600' : Number(health.health_score) >= 55 ? 'text-yellow-600' : 'text-red-600')}>
{health.health_score}
</span>
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
health.health_status === '健康' ? 'bg-green-100 text-green-700 border-green-300' :
health.health_status === '亚健康' ? 'bg-yellow-100 text-yellow-700 border-yellow-300' :
'bg-red-100 text-red-700 border-red-300')
}>{health.health_status}</span>
</>
)}
{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>}
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
@@ -345,7 +344,15 @@ export function StoreDetailPage() {
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<DataTable
<FilterableTable
data={mealRows}
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'bill_count', label: '账单数' },
{ key: 'avg_bill', label: '平均单价' },
]}
defaultSort="received"
defaultOrder="desc"
columns={[
{ key: 'meal_period', label: '餐段' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
@@ -354,7 +361,6 @@ export function StoreDetailPage() {
{ key: 'network_avg_bill', label: '全网均价', align: 'right', render: (r) => formatCurrency(r.network_avg_bill) },
{ key: 'vs_network_pct', label: 'vs全网', align: 'right', render: (r) => <span className={Number(r.vs_network_pct) < 0 ? 'text-red-600' : 'text-green-600'}>{Number(r.vs_network_pct).toFixed(1)}%</span> },
]}
data={mealRows}
/>
</div>
</>
@@ -500,23 +506,27 @@ export function StoreDetailPage() {
{tab === 'anomaly' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({anomalyTotal})</h2>
{anomalyTotal > 0 && <Pagination page={anomalyPage} pageSize={10} total={anomalyTotal} onPageChange={setAnomalyPage} />}
<div className="mt-3">
{anomalyRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<DataTable
columns={[
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
{ key: 'anomaly_reason', label: '异常原因' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
data={anomalyRows}
/>
)}
</div>
{anomalyRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<FilterableTable
data={anomalyRows}
sortOptions={[
{ key: 'consumption', label: '消费额' },
{ key: 'discount_total', label: '优惠' },
{ key: 'received_total', label: '实收' },
]}
defaultSort="consumption"
defaultOrder="desc"
columns={[
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
{ key: 'anomaly_reason', label: '异常原因' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
/>
)}
</div>
)}
@@ -524,7 +534,18 @@ export function StoreDetailPage() {
{tab === 'tasks' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({tasks.length})</h2>
<DataTable
<FilterableTable
data={tasks}
sortOptions={[
{ key: 'priority', label: '优先级' },
{ key: 'deadline', label: '截止日' },
{ key: 'problem_indicator', label: '问题指标' },
]}
defaultSort="priority"
defaultOrder="asc"
filterKey="status"
filterLabel="全部状态"
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
columns={[
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'priority', label: '优先级', render: (r) => <Badge type="priority" text={r.priority} /> },
@@ -532,8 +553,6 @@ export function StoreDetailPage() {
{ key: 'status', label: '状态', render: (r) => <Badge type="status" text={r.status} /> },
{ key: 'deadline', label: '截止日', render: (r) => r.deadline?.substring(0, 10) },
]}
data={tasks}
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
/>
</div>
)}
+12 -12
View File
@@ -12,37 +12,37 @@ 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 [month, setMonth] = useState('2026-04')
const { data: storesData, isLoading: storesLoading } = useQuery({
queryKey: ['stores-list'],
queryFn: () => api.get('/stores/risk'),
queryKey: ['stores-list', month],
queryFn: () => api.get('/stores/risk', { params: { month } }),
})
const stores = ((storesData as any)?.data || []).map((s: any) => ({ code: s.store_code, name: s.store_name, risk: s.risk_level, issue: s.primary_issue }))
const { data: priorityData } = useQuery({
queryKey: ['stores-priority-detail'],
queryFn: () => api.get('/stores/priority'),
queryKey: ['stores-priority-detail', month],
queryFn: () => api.get('/stores/priority', { params: { month } }),
})
const { data: cardData, isLoading: cardLoading } = useQuery({
queryKey: ['store', storeCode, 'daily-card'],
queryFn: () => api.get(`/tasks/stores/${storeCode}/daily-card`),
queryKey: ['store', storeCode, 'daily-card', month],
queryFn: () => api.get(`/tasks/stores/${storeCode}/daily-card`, { params: { month } }),
})
const { data: tasksData, isLoading: tasksLoading } = useQuery({
queryKey: ['store', storeCode, 'tasks'],
queryKey: ['store', storeCode, 'tasks', month],
queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month, page_size: 50 } }),
})
const { data: dailyData, isLoading: dailyLoading } = useQuery({
queryKey: ['store', storeCode, 'daily'],
queryKey: ['store', storeCode, 'daily', month],
queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month } }),
})
const { data: followupData, isLoading: followupLoading } = useQuery({
queryKey: ['store', storeCode, 'followup'],
queryFn: () => api.get('/tasks/followup'),
queryKey: ['store', storeCode, 'followup', month],
queryFn: () => api.get('/tasks/followup', { params: { month } }),
})
const { data: skuData } = useQuery({
@@ -153,7 +153,7 @@ export function StorePage() {
{/* 经营概览 */}
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-bold">51</h2>
<h2 className="text-sm font-bold">{month}</h2>
{anomalyCount > 0 && (
<span className="rounded-md bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">{anomalyCount} </span>
)}
+15 -15
View File
@@ -2,20 +2,16 @@ import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { FilterableTable } from '@/components/FilterableTable'
import { formatCurrency, formatPercent } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { useState, useMemo } from 'react'
const PAGE_SIZE = 5
export function TasksPage() {
const navigate = useNavigate()
const [month, setMonth] = useState('2026-05')
const [month, setMonth] = useState('2026-04')
const [priority, setPriority] = useState('')
const [status, setStatus] = useState('')
const [page, setPage] = useState(1)
const { data } = useQuery({
queryKey: ['tasks', month, priority, status],
@@ -41,7 +37,6 @@ export function TasksPage() {
), [tasks])
const totalGroups = groups.length
const pagedGroups = groups.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
return (
<div className="space-y-4">
@@ -80,12 +75,9 @@ export function TasksPage() {
))}
</div>
{/* 分页 */}
<Pagination page={page} pageSize={PAGE_SIZE} total={totalGroups} onPageChange={setPage} />
{/* 任务列表 — 按门店分组 */}
<div className="space-y-3">
{pagedGroups.map((group: any) => (
{groups.map((group: any) => (
<div key={group.store_code} className="rounded-lg border bg-card overflow-hidden">
{/* 门店组标题 */}
<div
@@ -97,7 +89,18 @@ export function TasksPage() {
<span className="text-xs text-muted-foreground">{group.tasks.length} </span>
</div>
{/* 组内任务表 */}
<DataTable
<FilterableTable
data={group.tasks}
sortOptions={[
{ key: 'priority', label: '优先级' },
{ key: 'deadline', label: '截止日' },
{ key: 'problem_indicator', label: '问题指标' },
]}
defaultSort="priority"
defaultOrder="asc"
filterKey="status"
filterLabel="全部状态"
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
columns={[
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'action_required', label: '行动要求', render: (r) => <span className="text-xs">{r.action_required}</span> },
@@ -105,9 +108,6 @@ export function TasksPage() {
{ key: 'deadline', label: '截止日', render: (r) => r.deadline?.substring(0, 10) },
{ key: 'status', label: '状态', render: (r) => <Badge type="status" text={r.status} /> },
]}
data={group.tasks}
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
className="rounded-none border-0"
/>
</div>
))}
+20 -13
View File
@@ -5,6 +5,8 @@ import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber } from '@/lib/utils'
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Area, AreaChart } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
import { useState } from 'react'
const CHANNEL_COLORS: Record<string, string> = {
cash: '#22c55e',
@@ -38,19 +40,21 @@ const CHANNEL_LABELS: Record<string, string> = {
const channelLabel = (k: string) => CHANNEL_LABELS[k] || k
export function TimePage() {
const [month, setMonth] = useState('2026-04')
const { data: weekdayData, isLoading: weekdayLoading } = useQuery({
queryKey: ['time/weekday'],
queryFn: () => api.get('/time/weekday'),
queryKey: ['time/weekday', month],
queryFn: () => api.get('/time/weekday', { params: { month } }),
})
const { data: hourlyData, isLoading: hourlyLoading } = useQuery({
queryKey: ['time/hourly'],
queryFn: () => api.get('/time/hourly'),
queryKey: ['time/hourly', month],
queryFn: () => api.get('/time/hourly', { params: { month } }),
})
const { data: channelData, isLoading: channelLoading } = useQuery({
queryKey: ['channel'],
queryFn: () => api.get('/channel'),
queryKey: ['channel', month],
queryFn: () => api.get('/channel', { params: { month } }),
})
const weekdayRows = (weekdayData as any)?.data || []
@@ -90,17 +94,20 @@ export function TimePage() {
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · 20264</p>
<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"> · · </p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 概览指标 */}
<CollapsibleSection title="时间概览" subtitle="高峰与低谷识别">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="高峰日" value={peakWeekday?.weekday || '-'} description={`实收最高: ${formatCurrency(peakWeekday?.received)}`} />
<MetricCard title="低谷日" value={lowWeekday?.weekday || '-'} description={`实收最低: ${formatCurrency(lowWeekday?.received)}`} />
<MetricCard title="高峰时段" value={peakHour ? `${peakHour.closing_hour}:00` : '-'} description={`账单数最多: ${formatNumber(peakHour?.bill_count)}`} />
<MetricCard title="高峰日" value={peakWeekday?.weekday || '-'} format="text" description={`实收最高: ${formatCurrency(peakWeekday?.received)}`} />
<MetricCard title="低谷日" value={lowWeekday?.weekday || '-'} format="text" description={`实收最低: ${formatCurrency(lowWeekday?.received)}`} />
<MetricCard title="高峰时段" value={peakHour ? `${peakHour.closing_hour}:00` : '-'} format="text" description={`账单数最多: ${formatNumber(peakHour?.bill_count)}`} />
<MetricCard title="渠道数" value={channelTotals.length} format="number" description="有交易记录的支付渠道数量" />
</div>
</CollapsibleSection>
@@ -117,7 +124,7 @@ export function TimePage() {
<Legend />
<Bar yAxisId="left" dataKey="received" fill="#3b82f6" name="实收" />
<Bar yAxisId="right" dataKey="bill_count" fill="#22c55e" name="账单数" />
<Bar yAxisId="left" dataKey="avg_bill_value" fill="#eab308" name="客单价" />
<Line yAxisId="left" type="monotone" dataKey="avg_bill_value" stroke="#eab308" name="客单价" strokeWidth={2} />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>