Phase 6-11: 驾驶舱图表/区域经理基准对比+周检录入/店长SKU备货+指标进度/门店详情7Tab/月度验收7Tab/区域汇总
- Phase 6: 驾驶舱新增象限散点图、P0/P1实收覆盖瀑布图、平台成本率TOP15柱状图 - Phase 7: 区域经理页面新增同类门店基准对比表、周检录入弹窗 - Phase 8: 店长页面新增核心SKU备货提醒、本周指标进度条,经营概览与进度并排 - Phase 9: 门店详情页7Tab(概览/餐段/品类/成本/会员/异常/任务),5个新API - Phase 10: 月度验收7Tab(验收汇总/升降级/完成率/活动清单/SKU治理/经验标准化/复盘优化),4个新API+阈值调整API - Phase 11: 前后端编译通过,API测试通过,浏览器验证0 error - 门店区域划分:从store_location_master填充dim_store.region(87家),v_region_summary视图+API+前端表格 - MetricCard增加text格式支持
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import { Badge } from '@/components/Badge'
|
||||
@@ -7,15 +7,20 @@ import { MetricCard } from '@/components/MetricCard'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { formatCurrency, formatPercent, riskColor } from '@/lib/utils'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
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 [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'],
|
||||
@@ -32,18 +37,40 @@ export function RegionalPage() {
|
||||
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="加载区域经理工作台..." />
|
||||
}
|
||||
@@ -60,6 +87,28 @@ export function RegionalPage() {
|
||||
<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) => (
|
||||
@@ -93,7 +142,7 @@ export function RegionalPage() {
|
||||
</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>
|
||||
@@ -105,6 +154,9 @@ export function RegionalPage() {
|
||||
{ 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}`)}
|
||||
@@ -112,6 +164,58 @@ export function RegionalPage() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user