Files
SBrainCO/client/src/pages/RegionalPage.tsx
T

227 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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
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 [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'),
})
const { data: weeklyData, isLoading: weeklyLoading } = useQuery({
queryKey: ['tasks/weekly-check'],
queryFn: () => api.get('/tasks/weekly-check', { params: { month } }),
})
const { data: priorityData, isLoading: priorityLoading } = useQuery({
queryKey: ['stores/priority'],
queryFn: () => api.get('/stores/priority'),
})
const { data: benchmarkData } = useQuery({
queryKey: ['benchmark/composite'],
queryFn: () => api.get('/benchmark/composite'),
})
const { data: regionData } = useQuery({
queryKey: ['region/summary'],
queryFn: () => api.get('/region/summary'),
})
const riskRows = ((riskData as any)?.data || []).filter((r: any) => !riskFilter || r.risk_level === riskFilter)
const weeklyRows = (weeklyData as any)?.data || []
const priorityRows = (priorityData as any)?.data || []
const benchmarkRows = (benchmarkData as any)?.data || []
const regionRows = (regionData as any)?.data || []
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({
mutationFn: ({ id, text }: { id: number; text: string }) => api.put(`/tasks/${id}/weekly-check`, { weekly_comment: text }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks/weekly-check'] })
setWeeklyModal(null)
setWeeklyText('')
},
})
if (pageLoading) {
return <LoadingSpinner text="加载区域经理工作台..." />
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 汇总卡片 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="红黄门店数" value={riskRows.filter((r: any) => r.risk_level !== '绿色').length} />
<MetricCard title="P0+P1门店" value={p0p1.length} />
<MetricCard title="需到店检查" value={needCheck.length} />
<MetricCard title="周检待录入" value={weeklyRows.filter((w: any) => !w.last_check_date).length} />
</div>
{/* 区域汇总 */}
{regionRows.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<DataTable
columns={[
{ key: 'region', label: '区域' },
{ key: 'store_count', label: '门店数', align: 'right' },
{ key: 'total_received', label: '实收合计', align: 'right', render: (r) => formatCurrency(r.total_received) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'avg_discount_rate', label: '优惠率', align: 'right', render: (r) => formatPercent(r.avg_discount_rate) },
{ key: 'avg_margin_rate', label: '毛利率', align: 'right', render: (r) => formatPercent(r.avg_margin_rate) },
{ key: 'avg_anomaly_rate', label: '异常率', align: 'right', render: (r) => formatPercent(r.avg_anomaly_rate) },
{ key: 'red_count', label: '红', align: 'center', render: (r) => <span className={r.red_count > 0 ? 'font-bold text-red-600' : ''}>{r.red_count}</span> },
{ 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>
)}
{/* 风险筛选 */}
<div className="flex gap-2">
{['', '红色', '黄色', '绿色'].map((r) => (
<button
key={r || 'all'}
onClick={() => setRiskFilter(r)}
className={`rounded-md border px-3 py-1.5 text-sm ${riskFilter === r ? 'border-primary bg-primary text-primary-foreground' : ''}`}
>
{r || '全部'}
</button>
))}
</div>
{/* 门店红黄绿列表 */}
<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
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'risk_level', label: '风险', render: (r) => <Badge type="risk" text={r.risk_level} /> },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ 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: '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
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'consecutive_no_improve_weeks', label: '连续未改善周数', align: 'center' },
{ key: 'status', label: '任务状态', render: (r) => <Badge type="status" text={r.status} /> },
{ key: 'action', label: '操作', render: (r) => (
<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>
)}
{/* 同类门店基准对比 */}
{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>
</div>
)}
{/* 周检录入弹窗 */}
{weeklyModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setWeeklyModal(null)}>
<div className="rounded-lg border bg-card p-4 shadow-lg w-96" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-2 text-sm font-bold"> - {weeklyModal.storeName}</h3>
<textarea
value={weeklyText}
onChange={(e) => setWeeklyText(e.target.value)}
placeholder="录入周度检查评价、指标变化情况、指导意见..."
className="mb-2 w-full rounded-md border p-2 text-sm"
rows={4}
/>
<div className="flex justify-end gap-2">
<button onClick={() => setWeeklyModal(null)} className="rounded-md border px-3 py-1 text-sm"></button>
<button
onClick={() => weeklyMutation.mutate({ id: weeklyModal.taskId, text: weeklyText })}
disabled={!weeklyText || weeklyMutation.isPending}
className="rounded-md bg-primary px-3 py-1 text-sm text-primary-foreground disabled:opacity-50"
>
{weeklyMutation.isPending ? '提交中...' : '提交'}
</button>
</div>
</div>
</div>
)}
</div>
)
}