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:
freedakgmail
2026-07-26 23:16:52 +08:00
parent a7874d79b5
commit 521ba88937
9 changed files with 1069 additions and 107 deletions
+3 -1
View File
@@ -4,7 +4,7 @@ interface MetricCardProps {
title: string
value: string | number | null | undefined
unit?: string
format?: 'number' | 'percent' | 'currency'
format?: 'number' | 'percent' | 'currency' | 'text'
trend?: number
description?: string
className?: string
@@ -15,6 +15,8 @@ export function MetricCard({ title, value, unit, format = 'number', trend, descr
? value === null || value === undefined ? '-' : `${Number(value).toFixed(2)}%`
: format === 'currency'
? value === null || value === undefined ? '-' : `¥${formatNumber(value)}`
: format === 'text'
? (value || '-')
: formatNumber(value)
return (
+95 -1
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend } from 'recharts'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend, ScatterChart, Scatter, ZAxis, ReferenceLine, ComposedChart } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
@@ -43,6 +43,16 @@ export function DashboardPage() {
queryFn: () => api.get('/tasks/loop-health'),
})
const { data: quadrantData } = useQuery({
queryKey: ['stores/quadrant'],
queryFn: () => api.get('/stores/quadrant'),
})
const { data: platformData } = useQuery({
queryKey: ['platform/economics'],
queryFn: () => api.get('/platform/economics'),
})
const pageLoading = odLoading || dailyLoading || riskLoading || priorityLoading || loopLoading
const od = (overview as any)?.data
@@ -50,6 +60,39 @@ export function DashboardPage() {
const priorityRows = (priorityData as any)?.data || []
const dailyRows = (daily as any)?.data || []
const lh = (loopHealth as any)?.data
const quadrantRows = (quadrantData as any)?.data || []
const platformRows = (platformData as any)?.data || []
const QUADRANT_COLORS: Record<string, string> = {
'明星门店': '#22c55e', '稳健经营': '#3b82f6', '规模承压': '#eab308', '重点改善': '#ef4444', '高效潜力': '#a855f7',
}
const quadrantScatterData = quadrantRows.map((r: any) => ({
name: r.store_name,
x: Number(r.avg_daily_received || 0),
y: Number(r.theoretical_margin_pct || 0),
quadrant: r.management_quadrant,
fill: QUADRANT_COLORS[r.management_quadrant] || '#999',
}))
const platformCostData = platformRows.slice(0, 15).map((r: any) => ({
name: r.store_name?.substring(0, 6) || '',
meituan: Number(r.meituan_cost_rate_pct || 0),
eleme: Number(r.eleme_cost_rate_pct || 0),
douyin: Number(r.douyin_cost_rate_pct || 0),
}))
const p0Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0'))
const p1Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P1'))
const p0Received = p0Stores.reduce((s: number, r: any) => s + Number(r.received || 0), 0)
const p1Received = p1Stores.reduce((s: number, r: any) => s + Number(r.received || 0), 0)
const otherReceived = od?.received ? Number(od.received) - p0Received - p1Received : 0
const waterfallData = [
{ name: 'P0门店', value: p0Received, fill: '#ef4444' },
{ name: 'P1门店', value: p1Received, fill: '#eab308' },
{ name: '其他门店', value: otherReceived, fill: '#22c55e' },
{ name: '合计', value: Number(od?.received || 0), fill: '#3b82f6' },
]
const riskSummary = riskRows.reduce((acc: any, r: any) => {
acc[r.risk_level] = (acc[r.risk_level] || 0) + 1
@@ -214,6 +257,57 @@ export function DashboardPage() {
</CollapsibleSection>
</div>
{/* 第二行图表 */}
<div className="grid gap-4 lg:grid-cols-3">
{/* 象限散点图 */}
<CollapsibleSection title="门店经营象限" subtitle="日均实收 × 毛利率">
<ResponsiveContainer width="100%" height={250}>
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" dataKey="x" name="日均实收" tickFormatter={(v) => `${(v / 10000).toFixed(0)}`} tick={{ fontSize: 10 }} />
<YAxis type="number" dataKey="y" name="毛利率" unit="%" tick={{ fontSize: 10 }} />
<ZAxis range={[40, 40]} />
<Tooltip cursor={{ strokeDasharray: '3 3' }} formatter={(v: any, n: string) => n === '毛利率' ? `${Number(v).toFixed(1)}%` : n === '日均实收' ? `¥${Number(v).toFixed(0)}` : v} />
{Object.entries(QUADRANT_COLORS).map(([name, color]) => (
<Scatter key={name} name={name} data={quadrantScatterData.filter((d: any) => d.quadrant === name)} fill={color} />
))}
<Legend wrapperStyle={{ fontSize: 10 }} />
</ScatterChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* P0/P1实收覆盖瀑布图 */}
<CollapsibleSection title="P0/P1实收覆盖" subtitle="按优先级拆分实收">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={waterfallData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{waterfallData.map((entry, idx) => <Cell key={idx} fill={entry.fill} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* 平台成本率柱状图 */}
<CollapsibleSection title="平台成本率TOP15" subtitle="三平台成本率对比">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={platformCostData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 9 }} angle={-30} textAnchor="middle" height={50} />
<YAxis unit="%" tick={{ fontSize: 10 }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 10 }} />
<Bar dataKey="meituan" name="美团" fill="#f97316" />
<Bar dataKey="eleme" name="饿了么" fill="#3b82f6" />
<Bar dataKey="douyin" name="抖音" fill="#ec4899" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* P0/P1 门店列表 */}
<CollapsibleSection
title={`P0/P1 重点整改门店 (${p0p1Stores.length})`}
+282 -45
View File
@@ -1,29 +1,79 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, Bar, CartesianGrid, XAxis, YAxis, Legend } from 'recharts'
import { DataTable } from '@/components/DataTable'
import { Badge } from '@/components/Badge'
import { MetricCard } from '@/components/MetricCard'
import { Pagination } from '@/components/Pagination'
import { useState, useMemo } from 'react'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
const REVIEW_COLORS = { '达标': '#22c55e', '改善中': '#eab308', '未改善': '#ef4444' }
const PAGE_SIZE = 10
type ReviewTab = 'summary' | 'grade-change' | 'completion' | 'activity' | 'sku' | 'practice' | 'effectiveness'
const REVIEW_TABS: { key: ReviewTab; label: string }[] = [
{ key: 'summary', label: '验收汇总' },
{ key: 'grade-change', label: '升降级面板' },
{ key: 'completion', label: '任务完成率' },
{ key: 'activity', label: '活动清单' },
{ key: 'sku', label: 'SKU治理' },
{ key: 'practice', label: '经验标准化' },
{ key: 'effectiveness', label: '复盘优化' },
]
export function MonthlyReviewPage() {
const [month, setMonth] = useState('2026-05')
const [page, setPage] = useState(1)
const [tab, setTab] = useState<ReviewTab>('summary')
const { data } = useQuery({
queryKey: ['monthly-review', month],
queryFn: () => api.get('/tasks/monthly-review', { params: { month } }),
})
const { data: gradeData } = useQuery({
queryKey: ['grade-change'],
queryFn: () => api.get('/tasks/grade-change'),
enabled: tab === 'grade-change',
})
const { data: completionData } = useQuery({
queryKey: ['monthly-review/completion', month],
queryFn: () => api.get('/tasks/monthly-review/completion', { params: { month } }),
enabled: tab === 'completion',
})
const { data: activityData } = useQuery({
queryKey: ['monthly-review/activity-list'],
queryFn: () => api.get('/tasks/monthly-review/activity-list'),
enabled: tab === 'activity',
})
const { data: skuGovData } = useQuery({
queryKey: ['monthly-review/sku-governance'],
queryFn: () => api.get('/tasks/monthly-review/sku-governance'),
enabled: tab === 'sku',
})
const { data: effectivenessData } = useQuery({
queryKey: ['monthly-review/indicator-effectiveness', month],
queryFn: () => api.get('/tasks/monthly-review/indicator-effectiveness', { params: { month } }),
enabled: tab === 'effectiveness',
})
const rd = (data as any)?.data
const summary = rd?.summary || { total: 0, passed: 0, improving: 0, failed: 0 }
const details = rd?.details || []
const pagedDetails = useMemo(() => details.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [details, page])
const gradeRows = (gradeData as any)?.data || []
const completionRows = (completionData as any)?.data || []
const activityRows = (activityData as any)?.data || []
const skuGov = (skuGovData as any)?.data
const effectivenessRows = (effectivenessData as any)?.data || []
const pieData = [
{ name: '达标', value: summary.passed },
{ name: '改善中', value: summary.improving },
@@ -33,7 +83,7 @@ export function MonthlyReviewPage() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<h1 className="text-xl font-bold"></h1>
<input
type="month"
value={month}
@@ -42,62 +92,226 @@ export function MonthlyReviewPage() {
/>
</div>
{/* 汇总卡片 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总任务数" value={summary.total} />
<MetricCard title="达标" value={summary.passed} />
<MetricCard title="改善中" value={summary.improving} />
<MetricCard title="未改善" value={summary.failed} />
{/* Tab bar */}
<div className="flex gap-1 border-b">
{REVIEW_TABS.map(t => (
<button key={t.key} onClick={() => setTab(t.key)} className={`rounded-t-md border-b-2 px-3 py-2 text-sm font-medium transition-colors ${tab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}>
{t.label}
</button>
))}
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* 饼图 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{pieData.length > 0 ? (
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
label={({ name, value }: any) => `${name}: ${value}`}>
{pieData.map((d) => <Cell key={d.name} fill={REVIEW_COLORS[d.name as keyof typeof REVIEW_COLORS]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
) : <div className="py-20 text-center text-muted-foreground"></div>}
</div>
{/* 验收汇总 Tab */}
{tab === 'summary' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总任务数" value={summary.total} />
<MetricCard title="达标" value={summary.passed} />
<MetricCard title="改善中" value={summary.improving} />
<MetricCard title="未改善" value={summary.failed} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{pieData.length > 0 ? (
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
label={({ name, value }: any) => `${name}: ${value}`}>
{pieData.map((d) => <Cell key={d.name} fill={REVIEW_COLORS[d.name as keyof typeof REVIEW_COLORS]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
) : <div className="py-20 text-center text-muted-foreground"></div>}
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<Pagination page={page} pageSize={PAGE_SIZE} total={details.length} onPageChange={setPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'baseline_value', label: '基线', align: 'right' },
{ key: 'target_value', label: '目标', align: 'right' },
{ key: 'actual_value', label: '实际', align: 'right' },
{ key: 'review_result', label: '结果', render: (r) => <Badge type="review" text={r.review_result} /> },
]}
data={pagedDetails}
/>
</div>
</div>
</>
)}
{/* 经验标准化 */}
{/* 升降级面板 Tab */}
{tab === 'grade-change' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold">P0/P1/P2升降级面板</h2>
{gradeRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<DataTable
columns={[
{ key: 'change_month', label: '月份', render: (r) => r.change_month?.substring(0, 7) },
{ key: 'store_name', label: '门店' },
{ key: 'old_priority', label: '原级别', render: (r) => <Badge type="priority" text={r.old_priority} /> },
{ key: 'new_priority', label: '新级别', render: (r) => <Badge type="priority" text={r.new_priority} /> },
{ key: 'change_type', label: '变化', render: (r) => {
const isUpgrade = ['P0→P1', 'P1→P2', 'P0→P2'].includes(r.change_type)
return <span className={isUpgrade ? 'text-green-600' : 'text-red-600'}>{isUpgrade ? '↑ 升级' : '↓ 降级'}</span>
} },
{ key: 'reason', label: '原因', render: (r) => <span className="text-xs">{r.reason}</span> },
]}
data={gradeRows}
/>
)}
</div>
)}
{/* 任务完成率 Tab */}
{tab === 'completion' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{completionRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<>
<div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">
{completionRows.map((c: any) => (
<div key={c.priority} className="rounded-md border p-3">
<p className="text-xs text-muted-foreground">{c.priority}</p>
<p className="mt-1 text-xl font-bold">{c.completion_rate}%</p>
<p className="text-xs text-muted-foreground">{c.completed}/{c.total} </p>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div className={`h-full rounded-full ${Number(c.completion_rate) >= 80 ? 'bg-green-500' : Number(c.completion_rate) >= 60 ? 'bg-yellow-500' : 'bg-red-500'}`} style={{ width: `${Math.min(Number(c.completion_rate), 100)}%` }} />
</div>
</div>
))}
</div>
<DataTable
columns={[
{ key: 'priority', label: '优先级', render: (r) => <Badge type="priority" text={r.priority} /> },
{ key: 'total', label: '总数', align: 'right' },
{ key: 'completed', label: '已完成', align: 'right' },
{ key: 'passed', label: '达标', align: 'right', render: (r) => <span className="text-green-600">{r.passed}</span> },
{ key: 'improving', label: '改善中', align: 'right', render: (r) => <span className="text-yellow-600">{r.improving}</span> },
{ key: 'failed', label: '未改善', align: 'right', render: (r) => <span className="text-red-600">{r.failed}</span> },
{ key: 'completion_rate', label: '完成率', align: 'right', render: (r) => `${r.completion_rate}%` },
]}
data={completionRows}
/>
</>
)}
</div>
)}
{/* 活动清单 Tab */}
{tab === 'activity' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold">//</h2>
<p className="mb-3 text-xs text-muted-foreground"></p>
{activityRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<DataTable
columns={[
{ key: 'marketing_plan', label: '活动方案' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discounts', label: '优惠额', align: 'right', render: (r) => formatCurrency(r.discounts) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'discount_cost_rate', label: '优惠成本率', align: 'right', render: (r) => formatPercent(r.discount_cost_rate) },
{ key: 'net_contribution', label: '净贡献', align: 'right', render: (r) => formatCurrency(r.net_contribution) },
{ key: 'action', label: '建议', render: (r) => {
const rate = Number(r.discount_cost_rate || 0)
const net = Number(r.net_contribution || 0)
if (net > 0 && rate < 15) return <span className="text-green-600"></span>
if (rate > 30) return <span className="text-red-600"></span>
return <span className="text-yellow-600"></span>
} },
]}
data={activityRows}
/>
)}
</div>
)}
{/* SKU治理 Tab */}
{tab === 'sku' && (
<div className="space-y-4">
{skuGov?.summary && (
<div className="grid grid-cols-3 gap-3">
{skuGov.summary.map((s: any) => (
<div key={s.abc_class} className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground">{s.abc_class}</p>
<p className="mt-1 text-2xl font-bold">{s.sku_count}</p>
<p className="text-xs text-muted-foreground">: {s.total_revenue_share}%</p>
</div>
))}
</div>
)}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold">SKU治理清单 (C类)</h2>
{skuGov?.longtail?.length > 0 ? (
<DataTable
columns={[
{ key: 'dish_name', label: '菜品名' },
{ key: 'category_level1', label: '一级分类' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) },
{ key: 'revenue_share_pct', label: '营收占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` },
]}
data={skuGov.longtail.slice(0, 20)}
/>
) : <p className="text-sm text-muted-foreground">SKU数据</p>}
</div>
</div>
)}
{/* 经验标准化 Tab */}
{tab === 'practice' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<p className="text-sm text-muted-foreground mb-3">广</p>
<PracticeSection />
</div>
</div>
)}
{/* 明细表 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<Pagination page={page} pageSize={PAGE_SIZE} total={details.length} onPageChange={setPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'baseline_value', label: '基线', align: 'right' },
{ key: 'target_value', label: '标', align: 'right' },
{ key: 'actual_value', label: '实际', align: 'right' },
{ key: 'review_result', label: '结果', render: (r) => <Badge type="review" text={r.review_result} /> },
]}
data={pagedDetails}
/>
{/* 复盘优化 Tab */}
{tab === 'effectiveness' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<p className="mb-3 text-xs text-muted-foreground"></p>
{effectivenessRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<DataTable
columns={[
{ key: 'problem_indicator', label: '指标' },
{ key: 'task_count', label: '任务数', align: 'right' },
{ key: 'passed', label: '标', align: 'right', render: (r) => <span className="text-green-600">{r.passed}</span> },
{ key: 'improving', label: '改善中', align: 'right', render: (r) => <span className="text-yellow-600">{r.improving}</span> },
{ key: 'failed', label: '未改善', align: 'right', render: (r) => <span className="text-red-600">{r.failed}</span> },
{ key: 'pass_rate', label: '达标率', align: 'right', render: (r) => {
const v = Number(r.pass_rate || 0)
return <span className={v >= 50 ? 'text-green-600' : v >= 30 ? 'text-yellow-600' : 'text-red-600'}>{v}%</span>
} },
{ key: 'assessment', label: '评价', render: (r) => {
const v = Number(r.pass_rate || 0)
if (v >= 50) return <Badge type="review" text="有效" />
if (v >= 30) return <Badge type="review" text="待观察" />
return <Badge type="review" text="低效" />
} },
]}
data={effectivenessRows}
/>
)}
</div>
</div>
)}
</div>
)
}
function PracticeSection() {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ practice_module: '', benchmark_store_name: '', key_actions: '', success_metrics: '' })
const { data } = useQuery({
queryKey: ['practices'],
queryFn: () => api.get('/tasks/practices'),
@@ -105,8 +319,31 @@ function PracticeSection() {
const practices = (data as any)?.data || []
const createMutation = useMutation({
mutationFn: (data: any) => api.post('/tasks/practices', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['practices'] })
setShowForm(false)
setForm({ practice_module: '', benchmark_store_name: '', key_actions: '', success_metrics: '' })
},
})
return (
<div className="space-y-2">
<div className="space-y-3">
<button onClick={() => setShowForm(!showForm)} className="rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground">
{showForm ? '取消' : '+ 新增经验'}
</button>
{showForm && (
<div className="rounded-md border p-3 space-y-2">
<input placeholder="经验模块(如:收入提升/成本控制/会员运营)" value={form.practice_module} onChange={(e) => setForm({ ...form, practice_module: e.target.value })} className="w-full rounded-md border p-2 text-sm" />
<input placeholder="标杆门店名称" value={form.benchmark_store_name} onChange={(e) => setForm({ ...form, benchmark_store_name: e.target.value })} className="w-full rounded-md border p-2 text-sm" />
<textarea placeholder="关键动作描述" value={form.key_actions} onChange={(e) => setForm({ ...form, key_actions: e.target.value })} className="w-full rounded-md border p-2 text-sm" rows={2} />
<input placeholder="验收指标(如:实收增长15%" value={form.success_metrics} onChange={(e) => setForm({ ...form, success_metrics: e.target.value })} className="w-full rounded-md border p-2 text-sm" />
<button onClick={() => createMutation.mutate(form)} disabled={!form.practice_module || !form.key_actions || createMutation.isPending} className="rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50">
{createMutation.isPending ? '提交中...' : '提交'}
</button>
</div>
)}
{practices.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : practices.map((p: any) => (
+106 -2
View File
@@ -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>
)
}
+288 -37
View File
@@ -1,16 +1,32 @@
import { useQuery } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, PieChart, Pie, Legend } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { Badge } from '@/components/Badge'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { ArrowLeft } from 'lucide-react'
import { useState } from 'react'
type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'tasks'
const TABS: { key: DetailTab; label: string }[] = [
{ key: 'overview', label: '概览' },
{ key: 'meal', label: '餐段分析' },
{ key: 'category', label: '品类结构' },
{ key: 'cost', label: '成本分析' },
{ key: 'member', label: '会员分析' },
{ key: 'anomaly', label: '异常账单' },
{ key: 'tasks', label: '任务列表' },
]
export function StoreDetailPage() {
const { code } = useParams()
const navigate = useNavigate()
const [tab, setTab] = useState<DetailTab>('overview')
const [anomalyPage, setAnomalyPage] = useState(1)
const { data: storeData } = useQuery({
queryKey: ['store', code],
@@ -27,9 +43,45 @@ export function StoreDetailPage() {
queryFn: () => api.get('/tasks', { params: { store_code: code, month: '2026-05', page_size: 50 } }),
})
const { data: mealData } = useQuery({
queryKey: ['store', code, 'meal-period'],
queryFn: () => api.get(`/stores/${code}/meal-period`),
enabled: tab === 'meal',
})
const { data: catData } = useQuery({
queryKey: ['store', code, 'category-mix'],
queryFn: () => api.get(`/stores/${code}/category-mix`),
enabled: tab === 'category',
})
const { data: costData } = useQuery({
queryKey: ['store', code, 'cost'],
queryFn: () => api.get(`/stores/${code}/cost`),
enabled: tab === 'cost',
})
const { data: memberData } = useQuery({
queryKey: ['store', code, 'member'],
queryFn: () => api.get(`/stores/${code}/member`),
enabled: tab === 'member',
})
const { data: anomalyData } = useQuery({
queryKey: ['store', code, 'anomalies'],
queryFn: () => api.get(`/stores/${code}/anomalies`, { params: { page: anomalyPage, page_size: 10 } }),
enabled: tab === 'anomaly',
})
const sd = (storeData as any)?.data
const daily = ((dailyData as any)?.data) || []
const tasks = ((tasksData as any)?.data) || []
const mealRows = ((mealData as any)?.data) || []
const cat = (catData as any)?.data
const cost = (costData as any)?.data
const member = (memberData as any)?.data
const anomalyRows = ((anomalyData as any)?.data) || []
const anomalyTotal = (anomalyData as any)?.meta?.total || 0
if (!sd) return <div>...</div>
const sc = sd.scorecard
@@ -43,6 +95,7 @@ export function StoreDetailPage() {
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold">{sc.store_name}</h1>
{sd.risk && <Badge type="risk" text={sd.risk.risk_level} />}
{sd.benchmark && <span className="rounded-md bg-muted px-2 py-0.5 text-xs">{sd.benchmark.management_quadrant}</span>}
</div>
{/* 核心指标 */}
@@ -55,49 +108,247 @@ export function StoreDetailPage() {
<MetricCard title="会员占比" value={sc.member_bill_share_pct} format="percent" />
</div>
{/* 日度趋势 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={daily}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="business_date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" />
<Line type="monotone" dataKey="avg_bill_value" stroke="#eab308" name="客单价" />
</LineChart>
</ResponsiveContainer>
{/* Tab bar */}
<div className="flex gap-1 border-b">
{TABS.map(t => (
<button key={t.key} onClick={() => setTab(t.key)} className={`rounded-t-md border-b-2 px-3 py-2 text-sm font-medium transition-colors ${tab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}>
{t.label}
</button>
))}
</div>
{/* 平台经济性 */}
{sd.platform && (
{/* 概览 Tab */}
{tab === 'overview' && (
<div className="space-y-4">
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={daily}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="business_date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" />
<Line type="monotone" dataKey="avg_bill_value" stroke="#eab308" name="客单价" />
</LineChart>
</ResponsiveContainer>
</div>
{sd.platform && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="美团实收" value={sd.platform.meituan_received} format="currency" />
<MetricCard title="饿了么实收" value={sd.platform.eleme_received} format="currency" />
<MetricCard title="抖音实收" value={sd.platform.douyin_received} format="currency" />
<MetricCard title="平台加权成本率" value={sd.platform.weighted_cost_rate_pct} format="percent" />
</div>
</div>
)}
</div>
)}
{/* 餐段分析 Tab */}
{tab === 'meal' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="美团实收" value={sd.platform.meituan_received} format="currency" />
<MetricCard title="饿了么实收" value={sd.platform.eleme_received} format="currency" />
<MetricCard title="抖音实收" value={sd.platform.douyin_received} format="currency" />
<MetricCard title="平台加权成本率" value={sd.platform.weighted_cost_rate_pct} format="percent" />
<h2 className="mb-3 text-sm font-bold"></h2>
{mealRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={mealRows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="meal_period" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, n: string) => n === '实收' ? formatCurrency(v) : n === '平均单价' ? formatCurrency(v) : v} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="received" name="实收" fill="#3b82f6" radius={[4, 4, 0, 0]} />
<Bar dataKey="avg_bill" name="平均单价" fill="#eab308" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<DataTable
columns={[
{ key: 'meal_period', label: '餐段' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'avg_bill', label: '平均单价', align: 'right', render: (r) => formatCurrency(r.avg_bill) },
{ key: 'network_avg_bill', label: '全网均价', align: 'right', render: (r) => formatCurrency(r.network_avg_bill) },
{ key: 'vs_network_pct', label: 'vs全网', align: 'right', render: (r) => <span className={Number(r.vs_network_pct) < 0 ? 'text-red-600' : 'text-green-600'}>{Number(r.vs_network_pct).toFixed(1)}%</span> },
]}
data={mealRows}
/>
</div>
</>
)}
</div>
)}
{/* 品类结构 Tab */}
{tab === 'category' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{!cat?.store ? <p className="text-sm text-muted-foreground"></p> : (
(() => {
const s = cat.store
const c = cat.company
const catData = [
{ name: '兰州拉面', store: Number(s.lanzhou_noodle || 0), company: Number(c.total_noodle || 0) },
{ name: '西式简餐', store: Number(s.western_staple || 0), company: Number(c.total_western || 0) },
{ name: '外卖套餐', store: Number(s.delivery_package || 0), company: Number(c.total_delivery || 0) },
{ name: '冷菜', store: Number(s.cold_dishes || 0), company: Number(c.total_cold || 0) },
{ name: '丝路美食', store: Number(s.silk_road_food || 0), company: Number(c.total_silk || 0) },
]
const totalStore = catData.reduce((sum, d) => sum + d.store, 0)
const totalCompany = catData.reduce((sum, d) => sum + d.company, 0)
const pieData = catData.map(d => ({ name: d.name, value: totalStore > 0 ? Math.round(d.store / totalStore * 1000) / 10 : 0, fill: ['#3b82f6', '#eab308', '#22c55e', '#a855f7', '#f97316'][catData.indexOf(d)] }))
return (
<>
<div className="grid gap-4 lg:grid-cols-2">
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground"></p>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" outerRadius={70} dataKey="value" label={({ name, value }: any) => `${name} ${value}%`}>
{pieData.map((d, i) => <Cell key={i} fill={d.fill} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">vs </p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={catData.map(d => ({ name: d.name, '门店占比': totalStore > 0 ? Math.round(d.store / totalStore * 1000) / 10 : 0, '公司占比': totalCompany > 0 ? Math.round(d.company / totalCompany * 1000) / 10 : 0 }))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
<YAxis unit="%" tick={{ fontSize: 10 }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 10 }} />
<Bar dataKey="门店占比" fill="#3b82f6" />
<Bar dataKey="公司占比" fill="#e2e8f0" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="主力品类" value={s.top_category || '-'} format="text" />
<MetricCard title="主力品类占比" value={s.top_category_share_pct} format="percent" />
<MetricCard title="面食占比" value={s.noodle_share_pct} format="percent" />
<MetricCard title="外卖占比" value={s.delivery_package_share_pct} format="percent" />
</div>
</>
)
})()
)}
</div>
)}
{/* 成本分析 Tab */}
{tab === 'cost' && (
<div className="space-y-4">
{cost?.cost ? (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="理论成本率" value={cost.cost.theoretical_cost_rate_pct} format="percent" />
<MetricCard title="实际成本率" value={cost.cost.actual_food_cost_rate_pct} format="percent" />
<MetricCard title="差异率" value={cost.cost.variance_to_theoretical_pct} format="percent" />
<MetricCard title="差异等级" value={cost.cost.variance_level} format="text" />
</div>
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{cost.categories?.length > 0 ? (
<DataTable
columns={[
{ key: 'category_name', label: '原料分类' },
{ key: 'theoretical_cost_rate_pct', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theoretical_cost_rate_pct) },
{ key: 'actual_cost_rate_pct', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_cost_rate_pct) },
{ key: 'variance_pct', label: '差异', align: 'right', render: (r) => <span className={Number(r.variance_pct) > 20 ? 'text-red-600' : 'text-green-600'}>{Number(r.variance_pct).toFixed(1)}%</span> },
{ key: 'benchmark_rate_pct', label: '同类基准', align: 'right', render: (r) => formatPercent(r.benchmark_rate_pct) },
]}
data={cost.categories}
/>
) : <p className="text-sm text-muted-foreground"></p>}
</div>
</>
) : <p className="text-sm text-muted-foreground"></p>}
</div>
)}
{/* 会员分析 Tab */}
{tab === 'member' && (
<div className="space-y-4">
{member?.opportunity ? (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="会员占比" value={member.opportunity.member_share_pct} format="percent" />
<MetricCard title="公司均值" value={member.opportunity.company_member_share_pct} format="percent" />
<MetricCard title="复购率" value={member.repeat?.repeat_rate_pct} format="percent" />
<MetricCard title="复购收入占比" value={member.repeat?.repeat_revenue_share_pct} format="percent" />
</div>
{member.monthly && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="活跃会员数" value={member.monthly.member_count} format="number" />
<MetricCard title="总订单数" value={member.monthly.total_orders} format="number" />
<MetricCard title="人均订单" value={member.monthly.avg_orders ? Number(member.monthly.avg_orders).toFixed(1) : '-'} format="text" />
<MetricCard title="人均消费" value={member.monthly.avg_received} format="currency" />
</div>
</div>
)}
{member.opportunity.conversion_bill_scenario && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-2 text-sm font-bold"></h2>
<p className="text-sm text-muted-foreground">{member.opportunity.conversion_bill_scenario}</p>
<p className="mt-1 text-sm text-muted-foreground">{member.opportunity.revenue_uplift_scenario}</p>
</div>
)}
</>
) : <p className="text-sm text-muted-foreground"></p>}
</div>
)}
{/* 异常账单 Tab */}
{tab === 'anomaly' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({anomalyTotal})</h2>
{anomalyTotal > 0 && <Pagination page={anomalyPage} pageSize={10} total={anomalyTotal} onPageChange={setAnomalyPage} />}
<div className="mt-3">
{anomalyRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<DataTable
columns={[
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
{ key: 'anomaly_reason', label: '异常原因' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
data={anomalyRows}
/>
)}
</div>
</div>
)}
{/* 任务列表 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({tasks.length})</h2>
<DataTable
columns={[
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'priority', label: '优先级', render: (r) => <Badge type="priority" text={r.priority} /> },
{ key: 'action_required', label: '行动要求', render: (r) => <span className="text-xs">{r.action_required}</span> },
{ key: 'status', label: '状态', render: (r) => <Badge type="status" text={r.status} /> },
{ key: 'deadline', label: '截止日', render: (r) => r.deadline?.substring(0, 10) },
]}
data={tasks}
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
/>
</div>
{/* 任务列表 Tab */}
{tab === 'tasks' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({tasks.length})</h2>
<DataTable
columns={[
{ key: 'problem_indicator', label: '问题指标' },
{ key: 'priority', label: '优先级', render: (r) => <Badge type="priority" text={r.priority} /> },
{ key: 'action_required', label: '行动要求', render: (r) => <span className="text-xs">{r.action_required}</span> },
{ key: 'status', label: '状态', render: (r) => <Badge type="status" text={r.status} /> },
{ key: 'deadline', label: '截止日', render: (r) => r.deadline?.substring(0, 10) },
]}
data={tasks}
onRowClick={(r) => navigate(`/tasks/${r.task_id}`)}
/>
</div>
)}
</div>
)
}
+92 -19
View File
@@ -43,12 +43,25 @@ export function StorePage() {
queryFn: () => api.get('/tasks/followup'),
})
const { data: skuData } = useQuery({
queryKey: ['sku-attach', storeCode],
queryFn: () => api.get('/sku/attach'),
})
const { data: abcData } = useQuery({
queryKey: ['sku-abc'],
queryFn: () => api.get('/sku/abc'),
})
const card = (cardData as any)?.data
const anomalies = card?.anomalies || []
const tasks = (tasksData as any)?.data || []
const dailyRows = (dailyData as any)?.data || []
const followupRows = (followupData as any)?.data || []
const followup = followupRows.find((f: any) => f.store_code === storeCode)
const skuRows = (skuData as any)?.data || []
const abcRows = (abcData as any)?.data || []
const coreSKUs = abcRows.filter((r: any) => r.abc_class === 'A').slice(0, 10)
const priorityRows = (priorityData as any)?.data || []
const priorityInfo = priorityRows.find((p: any) => p.store_code === storeCode)
@@ -130,27 +143,66 @@ export function StorePage() {
</div>
</div>
{/* 经营概览 */}
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-bold">51</h2>
{anomalyCount > 0 && (
<span className="rounded-md bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">{anomalyCount} </span>
{/* 最新经营概览 + 本周指标进度 一行 */}
<div className="grid gap-4 lg:grid-cols-2">
{/* 经营概览 */}
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-bold">51</h2>
{anomalyCount > 0 && (
<span className="rounded-md bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">{anomalyCount} </span>
)}
</div>
{allAnomalyItems.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="grid gap-2 md:grid-cols-3">
{allAnomalyItems.map((item, idx) => (
<div key={idx} className={`rounded-md border p-3 ${item.is_anomaly ? 'border-red-200 bg-red-50/50' : ''}`}>
<p className="text-xs text-muted-foreground">{item.metric}</p>
<p className={`mt-1 text-lg font-bold ${item.is_anomaly ? 'text-red-600' : ''}`}>
{item.metric === '实收' ? formatCurrency(item.value) : formatNumber(item.value)}
</p>
<p className="text-xs text-muted-foreground">: {item.metric === '实收' ? formatCurrency(item.baseline) : formatNumber(item.baseline)}{item.is_anomaly && <span className="text-red-500"> </span>}</p>
</div>
))}
</div>
)}
</div>
{allAnomalyItems.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="grid gap-2 md:grid-cols-3 lg:grid-cols-6">
{allAnomalyItems.map((item, idx) => (
<div key={idx} className={`rounded-md border p-3 ${item.is_anomaly ? 'border-red-200 bg-red-50/50' : ''}`}>
<p className="text-xs text-muted-foreground">{item.metric}</p>
<p className={`mt-1 text-lg font-bold ${item.is_anomaly ? 'text-red-600' : ''}`}>
{item.metric === '实收' ? formatCurrency(item.value) : formatNumber(item.value)}
</p>
<p className="text-xs text-muted-foreground">: {item.metric === '实收' ? formatCurrency(item.baseline) : formatNumber(item.baseline)}{item.is_anomaly && <span className="text-red-500"> </span>}</p>
</div>
))}
{/* 本周指标进度条 */}
{followup && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="space-y-3">
{[
{ label: '实收', baseline: Number(followup.baseline_received), target: Number(followup.target_received), actual: Number(followup.actual_received), lower: false, isCurrency: true },
{ label: '客单价', baseline: Number(followup.baseline_avg_bill), target: Number(followup.target_avg_bill), actual: Number(followup.actual_avg_bill), lower: false, isCurrency: true },
{ label: '优惠率', baseline: Number(followup.baseline_discount_rate), target: Number(followup.target_discount_rate), actual: Number(followup.actual_discount_rate), lower: true, isCurrency: false },
{ label: '毛利率', baseline: Number(followup.baseline_margin_rate), target: Number(followup.target_margin_rate), actual: Number(followup.actual_margin_rate), lower: false, isCurrency: false },
].map((m) => {
const pct = !isNaN(m.target) && m.target !== 0 && !isNaN(m.actual)
? Math.min(Math.round((m.actual / m.target) * 100), 200) : 0
const achieved = !isNaN(m.actual) && !isNaN(m.target) && (m.lower ? m.actual <= m.target : m.actual >= m.target)
const fmt = (v: number) => isNaN(v) ? '-' : m.isCurrency ? formatCurrency(v) : `${formatNumber(v)}%`
return (
<div key={m.label}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="font-medium">{m.label}</span>
<span className="text-muted-foreground">
: {fmt(m.target)} | : {fmt(m.actual)} | {pct}% {achieved ? '✓' : ''}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full transition-all ${achieved ? 'bg-green-500' : pct >= 80 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
@@ -236,6 +288,27 @@ export function StorePage() {
</div>
)}
{/* 核心SKU备货提醒 */}
{coreSKUs.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold">SKU备货提醒 (A级)</h2>
<div className="space-y-2">
{coreSKUs.map((s: any, idx: number) => (
<div key={idx} className="flex items-center justify-between rounded-md border p-2">
<div className="flex items-center gap-2">
<Badge type="priority" text="A" />
<span className="text-sm font-medium">{s.dish_name || s.sku_name || '-'}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>: {Number(s.revenue_share_pct || 0).toFixed(1)}%</span>
<span>: {Number(s.cumulative_revenue_share_pct || 0).toFixed(1)}%</span>
</div>
</div>
))}
</div>
</div>
)}
{/* 待办任务 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({todoTasks.length})</h2>
+69
View File
@@ -373,4 +373,73 @@ router.get('/data-quality', async (req: AuthRequest, res) => {
}
})
// ============================================================
// 门店详情页扩展 API (Phase 9)
// ============================================================
router.get('/stores/:code/meal-period', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_meal_opportunity WHERE store_code = $1 ORDER BY bill_count DESC`, [req.params.code])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.get('/stores/:code/category-mix', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_category_mix WHERE store_code = $1`, [req.params.code])
const companyResult = await query(`
SELECT
SUM(consumption) as total_consumption,
SUM(lanzhou_noodle) as total_noodle,
SUM(western_staple) as total_western,
SUM(delivery_package) as total_delivery,
SUM(cold_dishes) as total_cold,
SUM(silk_road_food) as total_silk
FROM analytics.v_store_category_mix
`)
sendSuccess(res, { store: result.rows[0], company: companyResult.rows[0] })
} catch (err: any) { sendError(res, err.message) }
})
router.get('/stores/:code/cost', async (req: AuthRequest, res) => {
try {
const costResult = await query(`SELECT * FROM analytics.v_store_theoretical_actual_cost_april WHERE store_code = $1`, [req.params.code])
const catResult = await query(`SELECT * FROM analytics.v_store_category_cost_benchmark_april WHERE store_code = $1`, [req.params.code])
sendSuccess(res, { cost: costResult.rows[0], categories: catResult.rows })
} catch (err: any) { sendError(res, err.message) }
})
router.get('/stores/:code/member', async (req: AuthRequest, res) => {
try {
const oppResult = await query(`SELECT * FROM analytics.v_store_member_opportunity WHERE store_code = $1`, [req.params.code])
const repeatResult = await query(`SELECT * FROM analytics.v_store_repeat_summary_monthly WHERE store_code = $1`, [req.params.code])
const monthlyResult = await query(`
SELECT store_code, store_name, count(*) as member_count,
sum(orders) as total_orders, sum(received) as total_received,
avg(orders) as avg_orders, avg(received) as avg_received
FROM analytics.v_store_member_monthly_activity
WHERE store_code = $1
GROUP BY store_code, store_name
`, [req.params.code])
sendSuccess(res, { opportunity: oppResult.rows[0], repeat: repeatResult.rows[0], monthly: monthlyResult.rows[0] })
} catch (err: any) { sendError(res, err.message) }
})
router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const countResult = await query(`SELECT count(*) AS total FROM analytics.v_anomaly_bills WHERE store_code = $1`, [req.params.code])
const result = await query(`SELECT * FROM analytics.v_anomaly_bills WHERE store_code = $1 ORDER BY closed_at DESC LIMIT $2 OFFSET $3`, [req.params.code, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) { sendError(res, err.message) }
})
// 区域汇总
router.get('/region/summary', async (req, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_region_summary ORDER BY total_received DESC`)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
export default router
+103
View File
@@ -568,4 +568,107 @@ router.get('/ontology/metrics', async (req: AuthRequest, res) => {
}
})
// ============================================================
// 月度复盘 API (Phase 10)
// ============================================================
router.get('/monthly-review/completion', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT
priority,
count(*) as total,
count(*) FILTER (WHERE status = '已验收' OR status = '已回滚') as completed,
count(*) FILTER (WHERE verification_result = '达标') as passed,
count(*) FILTER (WHERE verification_result = '改善中') as improving,
count(*) FILTER (WHERE verification_result = '未改善') as failed,
ROUND(count(*) FILTER (WHERE status = '已验收' OR status = '已回滚') * 100.0 / NULLIF(count(*), 0), 1) as completion_rate
FROM analytics.store_task
WHERE plan_month = $1
GROUP BY priority
ORDER BY priority
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.get('/monthly-review/activity-list', async (req: AuthRequest, res) => {
try {
const result = await query(`
SELECT marketing_plan, bill_count, consumption, discounts, received,
avg_bill_value, discount_rate_pct, theoretical_margin_pct,
ROUND(discounts * 100.0 / NULLIF(consumption, 0), 2) as discount_cost_rate,
ROUND(received - discounts, 2) as net_contribution
FROM analytics.v_marketing_plan_summary
ORDER BY received DESC
`)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.get('/monthly-review/sku-governance', async (req: AuthRequest, res) => {
try {
const abcResult = await query(`
SELECT abc_class, count(*) as sku_count,
ROUND(sum(revenue_share_pct), 1) as total_revenue_share,
ROUND(avg(revenue_share_pct), 2) as avg_revenue_share
FROM analytics.v_dish_sku_abc_april
GROUP BY abc_class
ORDER BY abc_class
`)
const longtailResult = await query(`
SELECT dish_name, category_level1, bill_count, sales_quantity,
received_amount, revenue_share_pct, cumulative_revenue_share
FROM analytics.v_dish_sku_abc_april
WHERE abc_class = 'C-长尾'
ORDER BY received_amount ASC
LIMIT 50
`)
sendSuccess(res, { summary: abcResult.rows, longtail: longtailResult.rows })
} catch (err: any) { sendError(res, err.message) }
})
router.get('/monthly-review/indicator-effectiveness', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT t.problem_indicator,
count(*) as task_count,
count(*) FILTER (WHERE t.verification_result = '达标') as passed,
count(*) FILTER (WHERE t.verification_result = '改善中') as improving,
count(*) FILTER (WHERE t.verification_result = '未改善') as failed,
ROUND(count(*) FILTER (WHERE t.verification_result = '达标') * 100.0 / NULLIF(count(*), 0), 1) as pass_rate,
bool_and(t.current_value IS NOT NULL) as has_actual
FROM analytics.store_task t
WHERE t.plan_month = $1::date
GROUP BY t.problem_indicator
ORDER BY pass_rate DESC NULLS LAST
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.put('/indicators/:id/threshold', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
const { yellow_threshold, red_threshold } = req.body
const result = await query(`
UPDATE analytics.indicator_dictionary
SET yellow_threshold = $1, red_threshold = $2, version_date = CURRENT_DATE
WHERE id = $3 RETURNING *
`, [yellow_threshold, red_threshold, id])
if (result.rows.length === 0) return sendError(res, 'Indicator not found', 404)
await query(`
INSERT INTO analytics.metric_version_log (indicator_id, indicator_name, change_type, old_value, new_value, change_reason)
SELECT $1, indicator_name, 'threshold_adjust',
concat('yellow=', COALESCE(yellow_threshold::text, 'NULL'), ', red=', COALESCE(red_threshold::text, 'NULL')),
concat('yellow=', COALESCE($4::text, 'NULL'), ', red=', COALESCE($5::text, 'NULL')),
'月度复盘阈值调整'
FROM analytics.indicator_dictionary WHERE id = $1
`, [id, yellow_threshold, red_threshold, yellow_threshold, red_threshold])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
export default router
+31 -2
View File
@@ -223,6 +223,24 @@
- [x] 10.13 闭环健康度仪表盘(集成在驾驶舱)(任务生成覆盖率/店长执行率/区域周检完成率/月度验收完成率/经验推广转化率,任一环节低于80%标红预警)
- [ ] 10.14 数据导入与刷新操作面板(导入新月数据→校验→刷新物化视图→触发自动分级→生成新月任务,一键完成下月迭代)
## Phase 10.5:本体标准(Ontology Standard
- [x] 10.5.1 创建维度主数据表(9张):dim_sku / dim_material / dim_member / dim_employee / dim_channel / dim_promotion / dim_calendar / dim_supplier / dim_store扩展
- [x] 10.5.2 创建事实表(13张):fact_bill / fact_bill_item / fact_payment / fact_promotion_usage / fact_platform_order / fact_inventory_snapshot / fact_purchase_receipt / fact_transfer / fact_waste / fact_recipe_bom / fact_employee_shift / fact_customer_contact / fact_store_task
- [x] 10.5.3 创建枚举标准表(12张):enum_risk_level / enum_priority / enum_task_status / enum_verification_result / enum_sku_type / enum_sku_status / enum_business_type / enum_channel_group / enum_metric_direction / enum_member_status / enum_waste_reason / enum_management_quadrant
- [x] 10.5.4 扩展指标字典:补充metric_code/metric_category/direction/unit/granularity/version/is_active字段,upsert 22条指标标准(7个分类:收入/成本/优惠/会员/商品/库存/风险)
- [x] 10.5.5 创建口径变更日志表 metric_version_log
- [x] 10.5.6 创建数据质量检查日志表 data_quality_log
- [x] 10.5.7 初始化基础数据:渠道10条、日历365天、枚举48条
- [x] 10.5.8 创建26个索引(事实表关键字段)
- [x] 10.5.9 后端APIGET /ontology/overview + /ontology/dim/:table + /ontology/fact/:table + /ontology/enum/:table + /ontology/metrics
- [x] 10.5.10 前端OntologyPage5个Tab(概览/维度主数据/事实表/枚举标准/指标标准),指标分类筛选功能
- [ ] 10.5.11 维度主数据填充:dim_sku / dim_material / dim_member / dim_employee / dim_promotion / dim_supplier 从业务系统同步
- [ ] 10.5.12 事实表ETL:从bill_fact等现有视图向fact_bill / fact_bill_item / fact_payment等标准事实表迁移数据
- [ ] 10.5.13 前端维度表编辑功能:支持新增/修改维度主数据记录
- [ ] 10.5.14 前端枚举标准管理:支持新增/修改枚举值
- [ ] 10.5.15 前端指标标准管理:支持新增/修改指标定义(复用现有IndicatorsPage或集成到OntologyPage
## Phase 11:集成测试与验证
### 11.1 数据层集成测试
@@ -284,7 +302,7 @@
|---|---|---|
| 18个视图带 `_april` 后缀 | 换月后闭环断裂 | 创建通用月度查询函数 `f_store_summary(p_month)`API层动态适配 |
| `store_monthly_action_plan` 缺P0门店 | 10家P0门店无月度计划 | 运行任务生成函数补充P0门店任务 |
| 无门店区域归属 | 区域经理页面无法按区域汇总 | 从现有视图提取生成 `dim_store`,需业务方确认区域划分 |
| ~~无门店区域归属~~ | ~~区域经理页面无法按区域汇总~~ | ✅ 已从 `store_location_master.district` 匹配填充 `dim_store.region`87家门店),创建 `v_region_summary` 视图,前端区域经理页面已展示区域汇总表 |
| 无指标字典 | 指标口径不统一 | 新建 `indicator_dictionary` 表并初始化核心指标 |
| 无文件存储 | 店长无法上传过程证据 | 第一阶段用文本URL字段记录证据链接,后期接入文件上传 |
| 成本数据仅1个月 | 无法做成本趋势验证 | 月度验收时标注"仅单月,趋势待验证",需5月起持续导入 |
@@ -293,7 +311,7 @@
| 数据 | 用途 | 优先级 |
|---|---|---|
| 门店区域划分 | 区域经理页面汇总、区域利润桥 | P0 |
| ~~门店区域划分~~ | ~~区域经理页面汇总、区域利润桥~~ | ~~P0~~ ✅ 已完成:从 `store_location_master` 提取district字段,87家门店区域已填充,`v_region_summary` 视图+API+前端表格已上线 |
| BOM和版本 | SKU标准成本、用量差异、真实损耗 | P1 |
| 采购明细 | 采购价格差异、供应商表现 | P1 |
| 报损/调拨/领用明细 | 实际耗用偏高归因 | P1 |
@@ -315,3 +333,14 @@
| 2026-07-26 | 新增数据刷新机制、复盘优化拆分3步、闭环健康度监控、数据导入操作面板 |
| 2026-07-26 | Phase 0-5 全部完成,Phase 6/9/10 部分完成,前后端联调验证通过 |
| 2026-07-26 | Phase 7/8 区域经理+店长页面完成,README编写,11.5启动部署完成 |
| 2026-07-26 | 优化/stores/priority API(缓存表加速)、时间分析页渠道名中文化 |
| 2026-07-26 | 建立本体标准:36张表(9维度+13事实+12枚举+指标字典+数据质量+口径变更),初始化渠道10条/日历365天/指标22条/枚举48条,前端OntologyPage 5个Tab(概览/维度/事实/枚举/指标筛选) |
| 2026-07-26 | 推送至 Gitea git.all8ai.top/freedak/SBrainCO |
| 2026-07-27 | Phase 6 完成:驾驶舱新增象限散点图、P0/P1实收覆盖瀑布图、平台成本率TOP15柱状图 |
| 2026-07-27 | Phase 7 完成:区域经理页面新增同类门店基准对比表、周检录入弹窗(useMutation提交) |
| 2026-07-27 | Phase 8 完成:店长页面新增核心SKU备货提醒(A级)、本周指标进度条(4指标含达标判定) |
| 2026-07-27 | Phase 9 完成:门店详情页7Tab(概览/餐段/品类/成本/会员/异常/任务),5个新APImeal-period/category-mix/cost/member/anomalies |
| 2026-07-27 | Phase 10 完成:月度验收7Tab(验收汇总/升降级/完成率/活动清单/SKU治理/经验标准化/复盘优化),4个新APIcompletion/activity-list/sku-governance/indicator-effectiveness+阈值调整API |
| 2026-07-27 | Phase 11 完成:前后端编译通过,所有API接口测试通过,浏览器验证Dashboard/StoreDetail/MonthlyReview页面正常渲染,0 console error |
| 2026-07-27 | 门店区域划分完成:从 `store_location_master.district` 匹配填充 `dim_store.region`87家门店),创建 `v_region_summary` 视图 + `GET /region/summary` API,区域经理页面新增区域经营汇总表(12区域,含门店数/实收/客单价/优惠率/毛利率/异常率/红黄绿计数) |
| 2026-07-27 | 店长页面布局优化:最新经营概览与本周指标进度并排显示(lg:grid-cols-2),MetricCard 增加 text 格式支持 |