风险与内控管理优化: 异常账单服务端分页/筛选/收银员搜索/合计, 收银员TOP15按异常账单数排序, 双击跳转明细, MonthPicker靠右, 侧边栏底部间距
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user