317 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|