Files
SBrainCO/client/src/components/cost-analysis/AdjustmentTab.tsx
T

317 lines
18 KiB
TypeScript

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatCurrency, formatPercent, formatNumber, priorityColor } from '@/lib/utils'
import { useState } from 'react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend } from 'recharts'
const PAGE_SIZE = 15
const ACTION_LABELS: Record<string, string> = {
'price_up': '涨价',
'price_down': '降价',
'recipe_optimize': '优化配方',
'portion_reduce': '减份量',
'delist': '下架',
'evaluate_delist': '评估下架',
'fix_data': '修复数据',
'monitor': '监控',
'keep': '保持',
}
const STATUS_LABELS: Record<string, string> = {
'planned': '待执行',
'executing': '执行中',
'completed': '已完成',
'cancelled': '已取消',
}
export function AdjustmentTab({ month }: { month: string }) {
const queryClient = useQueryClient()
const [subTab, setSubTab] = useState<'diagnosis' | 'adjustment' | 'verify'>('diagnosis')
const [diagPage, setDiagPage] = useState(1)
const [adjPage, setAdjPage] = useState(1)
const [diagPriority, setDiagPriority] = useState('')
const [adjStatus, setAdjStatus] = useState('')
const [diagSort, setDiagSort] = useState('priority')
const [diagOrder, setDiagOrder] = useState<'asc' | 'desc'>('asc')
const [adjSort, setAdjSort] = useState('effective_date')
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', 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({
mutationFn: () => api.post('/cost-analysis/diagnosis/generate'),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ca/diagnosis'] }) },
})
const createAdjustment = useMutation({
mutationFn: (data: any) => api.post('/cost-analysis/adjustment', data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ca/adjustment'] }); setShowForm(false); setFormData({}) },
})
const updateStatus = useMutation({
mutationFn: ({ id, status }: { id: number; status: string }) => api.put(`/cost-analysis/adjustment/${id}`, { status }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ca/adjustment'] }),
})
const diagData = (diag as any)?.data || []
const diagMeta = (diag as any)?.meta || { total: 0 }
const adjData = (adj as any)?.data || []
const adjMeta = (adj as any)?.meta || { total: 0 }
const verifyData = (verify as any)?.data || null
const p0Count = diagData.filter((d: any) => d.priority === 'P0').length
const p1Count = diagData.filter((d: any) => d.priority === 'P1').length
const statusColor = (status: string) => {
if (status === 'executing') return 'bg-blue-100 text-blue-700'
if (status === 'completed') return 'bg-green-100 text-green-700'
if (status === 'cancelled') return 'bg-red-100 text-red-700'
return 'bg-gray-100 text-gray-700'
}
const handleCreateAdjustment = (diag: any) => {
setFormData({
dish_code: diag.dish_code,
dish_name: diag.dish_name,
adjustment_type: diag.suggested_action === 'price_up' ? 'price' : diag.suggested_action === 'portion_reduce' ? 'portion' : diag.suggested_action === 'delist' ? 'delisting' : 'recipe',
before_theoretical_margin: diag.theoretical_margin_pct,
before_cost_variance: diag.cost_variance_amount,
reason: diag.diagnosis_detail,
diagnosis_id: diag.id,
effective_date: new Date().toISOString().slice(0, 10),
})
setShowForm(true)
}
const handleSubmitForm = () => {
createAdjustment.mutate(formData)
}
return (
<div className="space-y-4">
<div className="flex gap-2">
<button onClick={() => setSubTab('diagnosis')} className={`rounded px-4 py-2 text-sm font-medium ${subTab === 'diagnosis' ? 'bg-primary text-primary-foreground' : 'border'}`}></button>
<button onClick={() => setSubTab('adjustment')} className={`rounded px-4 py-2 text-sm font-medium ${subTab === 'adjustment' ? 'bg-primary text-primary-foreground' : 'border'}`}></button>
<button onClick={() => setSubTab('verify')} className={`rounded px-4 py-2 text-sm font-medium ${subTab === 'verify' ? 'bg-primary text-primary-foreground' : 'border'}`}></button>
</div>
{subTab === 'diagnosis' && (
<>
<div className="flex items-center gap-3">
<button
onClick={() => generateDiagnosis.mutate()}
disabled={generateDiagnosis.isPending}
className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
>
{generateDiagnosis.isPending ? '生成中...' : '生成诊断快照'}
</button>
{generateDiagnosis.data && <span className="text-sm text-green-600"> {(generateDiagnosis.data as any)?.data?.generated} </span>}
<select value={diagPriority} onChange={(e) => { setDiagPriority(e.target.value); setDiagPage(1) }} className="rounded border px-2 py-1.5 text-sm">
<option value=""></option>
<option value="P0">P0</option>
<option value="P1">P1</option>
<option value="P2">P2</option>
<option value="P3">P3</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="P0紧急" value={p0Count} format="number" />
<MetricCard title="P1重点" value={p1Count} format="number" />
<MetricCard title="诊断总数" value={diagMeta.total} format="number" />
</div>
{ld ? <LoadingSpinner text="加载诊断..." /> : (
<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 && (
<CollapsibleSection title="创建调整记录" defaultOpen={true}>
<div className="grid grid-cols-2 gap-3">
<div><label className="text-xs text-muted-foreground"></label><input value={formData.dish_code || ''} disabled className="mt-1 w-full rounded border px-3 py-1.5 text-sm bg-muted" /></div>
<div><label className="text-xs text-muted-foreground"></label><input value={formData.dish_name || ''} disabled className="mt-1 w-full rounded border px-3 py-1.5 text-sm bg-muted" /></div>
<div>
<label className="text-xs text-muted-foreground"></label>
<select value={formData.adjustment_type || ''} onChange={(e) => setFormData({ ...formData, adjustment_type: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm">
<option value="price"></option>
<option value="recipe"></option>
<option value="portion"></option>
<option value="delisting"></option>
<option value="relaunch"></option>
</select>
</div>
<div><label className="text-xs text-muted-foreground"></label><input type="date" value={formData.effective_date || ''} onChange={(e) => setFormData({ ...formData, effective_date: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" /></div>
<div><label className="text-xs text-muted-foreground">(%)</label><input type="number" value={formData.before_theoretical_margin || ''} disabled className="mt-1 w-full rounded border px-3 py-1.5 text-sm bg-muted" /></div>
<div><label className="text-xs text-muted-foreground">(%)</label><input type="number" value={formData.after_target_margin || ''} onChange={(e) => setFormData({ ...formData, after_target_margin: Number(e.target.value) })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" /></div>
<div><label className="text-xs text-muted-foreground"></label><input value={formData.decided_by || ''} onChange={(e) => setFormData({ ...formData, decided_by: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" /></div>
<div><label className="text-xs text-muted-foreground"></label><input value={formData.reason || ''} onChange={(e) => setFormData({ ...formData, reason: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" /></div>
</div>
<div className="mt-3 flex gap-2">
<button onClick={handleSubmitForm} disabled={createAdjustment.isPending} className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50">
{createAdjustment.isPending ? '提交中...' : '提交'}
</button>
<button onClick={() => setShowForm(false)} className="rounded border px-4 py-2 text-sm"></button>
</div>
</CollapsibleSection>
)}
</>
)}
{subTab === 'adjustment' && (
<>
<div className="flex flex-wrap items-center gap-2">
<select value={adjStatus} onChange={(e) => { setAdjStatus(e.target.value); setAdjPage(1) }} className="rounded border px-2 py-1.5 text-sm">
<option value=""></option>
<option value="planned"></option>
<option value="executing"></option>
<option value="completed"></option>
<option value="cancelled"></option>
</select>
</div>
{la ? <LoadingSpinner text="加载调整记录..." /> : (
<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>
)},
]}
/>
)}
</>
)}
{subTab === 'verify' && (
<>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">ID:</label>
<input type="number" value={verifyId || ''} onChange={(e) => setVerifyId(e.target.value ? Number(e.target.value) : null)} placeholder="输入ID" className="rounded border px-3 py-1.5 text-sm w-32" />
</div>
{verifyId && (
lv ? <LoadingSpinner text="加载验证数据..." /> : verifyData ? (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="调整前日均销量" value={verifyData.before_avg_daily} format="number" />
<MetricCard title="调整后日均销量" value={verifyData.after_avg_daily} format="number" />
<MetricCard title="销量变化率" value={verifyData.sales_change_pct} format="percent" description="(后-前)/前" />
<MetricCard title="执行日期" value={verifyData.adjustment?.effective_date} format="text" />
</div>
<CollapsibleSection title="销量趋势图" subtitle="调整前后每日销量对比">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={[
...verifyData.before.map((d: any) => ({ ...d, period: '调整前' })),
...verifyData.after.map((d: any) => ({ ...d, period: '调整后' })),
]}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="day" tick={{ fontSize: 9 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Legend />
<Line dataKey="bills" name="日销量(账单数)" stroke="#3b82f6" />
</LineChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="调整详情" defaultOpen={false}>
<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 },
{ key: 'effective_date', label: '执行日期' },
{ key: 'reason', 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>
)},
]}
/>
</CollapsibleSection>
</>
) : <p className="text-sm text-muted-foreground">ID查看验证数据</p>
)}
</>
)}
</div>
)
}