初始化:连锁餐饮数字化运营管理平台
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { Badge } from '@/components/Badge'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function StorePage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [executeText, setExecuteText] = useState('')
|
||||
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
|
||||
const [storeCode, setStoreCode] = useState('1111')
|
||||
|
||||
const { data: storesData, isLoading: storesLoading } = useQuery({
|
||||
queryKey: ['stores-list'],
|
||||
queryFn: () => api.get('/stores/risk'),
|
||||
})
|
||||
const stores = ((storesData as any)?.data || []).map((s: any) => ({ code: s.store_code, name: s.store_name, risk: s.risk_level, issue: s.primary_issue }))
|
||||
|
||||
const { data: priorityData } = useQuery({
|
||||
queryKey: ['stores-priority-detail'],
|
||||
queryFn: () => api.get('/stores/priority'),
|
||||
})
|
||||
|
||||
const { data: cardData, isLoading: cardLoading } = useQuery({
|
||||
queryKey: ['store', storeCode, 'daily-card'],
|
||||
queryFn: () => api.get(`/tasks/stores/${storeCode}/daily-card`),
|
||||
})
|
||||
|
||||
const { data: tasksData, isLoading: tasksLoading } = useQuery({
|
||||
queryKey: ['store', storeCode, 'tasks'],
|
||||
queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month: '2026-05', page_size: 50 } }),
|
||||
})
|
||||
|
||||
const { data: dailyData, isLoading: dailyLoading } = useQuery({
|
||||
queryKey: ['store', storeCode, 'daily'],
|
||||
queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month: '2026-04' } }),
|
||||
})
|
||||
|
||||
const { data: followupData, isLoading: followupLoading } = useQuery({
|
||||
queryKey: ['store', storeCode, 'followup'],
|
||||
queryFn: () => api.get('/tasks/followup'),
|
||||
})
|
||||
|
||||
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 priorityRows = (priorityData as any)?.data || []
|
||||
const priorityInfo = priorityRows.find((p: any) => p.store_code === storeCode)
|
||||
|
||||
const storeInfo = stores.find((s: any) => s.code === storeCode)
|
||||
const storeName = storeInfo?.name || ''
|
||||
const primaryIssue = storeInfo?.issue || priorityInfo?.problem_combination || '-'
|
||||
|
||||
const todoTasks = tasks.filter((t: any) => t.status === '待启动' || t.status === '进行中')
|
||||
const doneTasks = tasks.filter((t: any) => t.status === '已验收' || t.status === '已回滚')
|
||||
const passCount = tasks.filter((t: any) => t.verification_result === '达标').length
|
||||
const improvingCount = tasks.filter((t: any) => t.verification_result === '改善中').length
|
||||
|
||||
const allAnomalyItems: { module: string; metric: string; value: number; baseline: number; is_anomaly: boolean }[] = []
|
||||
anomalies.forEach((a: any) => {
|
||||
const list = typeof a.anomalies === 'string' ? JSON.parse(a.anomalies) : a.anomalies || []
|
||||
list.forEach((item: any) => {
|
||||
allAnomalyItems.push({ module: a.module, metric: item.metric, value: item.value, baseline: item.baseline, is_anomaly: item.is_anomaly })
|
||||
})
|
||||
})
|
||||
const anomalyCount = allAnomalyItems.filter((i: any) => i.is_anomaly).length
|
||||
|
||||
const executeMutation = useMutation({
|
||||
mutationFn: ({ id, text }: { id: number; text: string }) => api.put(`/tasks/${id}/execute`, { process_evidence: text }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['store', storeCode, 'tasks'] })
|
||||
setExecuteText('')
|
||||
setActiveTaskId(null)
|
||||
},
|
||||
})
|
||||
|
||||
const riskColor = (level: string) => {
|
||||
if (level === '红色') return 'bg-red-100 text-red-700 border-red-300'
|
||||
if (level === '黄色') return 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
||||
return 'bg-green-100 text-green-700 border-green-300'
|
||||
}
|
||||
|
||||
const pageLoading = storesLoading || cardLoading || tasksLoading || dailyLoading || followupLoading
|
||||
|
||||
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>
|
||||
<select
|
||||
value={storeCode}
|
||||
onChange={(e) => setStoreCode(e.target.value)}
|
||||
className="rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
{stores.map((s: any) => (
|
||||
<option key={s.code} value={s.code}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 门店概况 */}
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">门店名称</p>
|
||||
<p className="mt-1 text-lg font-bold">{storeName}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">风险等级</p>
|
||||
<div className="mt-1">
|
||||
{storeInfo?.risk && <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-sm font-medium ${riskColor(storeInfo.risk)}`}>{storeInfo.risk}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">主要问题</p>
|
||||
<p className="mt-1 text-sm font-medium">{primaryIssue}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">任务完成率</p>
|
||||
<p className="mt-1 text-lg font-bold">{tasks.length > 0 ? Math.round(doneTasks.length / tasks.length * 100) : 0}%</p>
|
||||
<p className="text-xs text-muted-foreground">{doneTasks.length}/{tasks.length} 已完成</p>
|
||||
</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">最新经营概览(5月1日)</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 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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
{dailyRows.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={dailyRows}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="business_date" tickFormatter={(v) => v?.substring(5, 10)} tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip labelFormatter={(v) => v?.substring(0, 10)} />
|
||||
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : <p className="py-16 text-center text-sm text-muted-foreground">暂无数据</p>}
|
||||
</div>
|
||||
|
||||
{/* 月度指标对比 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">月度指标对比</h2>
|
||||
{followup ? (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground">
|
||||
<th className="py-2 text-left font-medium">指标</th>
|
||||
<th className="py-2 text-right font-medium">月基线</th>
|
||||
<th className="py-2 text-right font-medium">目标</th>
|
||||
<th className="py-2 text-right font-medium">实际</th>
|
||||
<th className="py-2 text-right font-medium">达成率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ 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 },
|
||||
{ label: '异常率(%)', baseline: Number(followup.baseline_anomaly_rate), target: Number(followup.target_anomaly_rate), actual: Number(followup.actual_anomaly_rate), lower: true, isCurrency: false },
|
||||
{ label: '会员占比(%)', baseline: Number(followup.baseline_member_share), target: Number(followup.target_member_share), actual: Number(followup.actual_member_share), lower: false, isCurrency: false },
|
||||
].map((m) => {
|
||||
const achieved = !isNaN(m.actual) && !isNaN(m.target) && (m.lower ? m.actual <= m.target : m.actual >= m.target)
|
||||
const rate = !isNaN(m.actual) && !isNaN(m.baseline) && m.baseline !== 0 ? Math.round((m.actual / m.baseline) * 100) : null
|
||||
const fmt = (v: number) => isNaN(v) ? '-' : m.isCurrency ? formatCurrency(v) : formatNumber(v)
|
||||
return (
|
||||
<tr key={m.label} className="border-b last:border-0">
|
||||
<td className="py-2 text-left">{m.label}</td>
|
||||
<td className="py-2 text-right text-muted-foreground">{fmt(m.baseline)}</td>
|
||||
<td className="py-2 text-right text-blue-600">{fmt(m.target)}</td>
|
||||
<td className={`py-2 text-right font-medium ${achieved ? 'text-green-600' : 'text-red-600'}`}>{fmt(m.actual)}</td>
|
||||
<td className={`py-2 text-right ${achieved ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{rate !== null ? `${rate}%` : '-'}
|
||||
{achieved ? ' ✓' : ' ✗'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <p className="py-16 text-center text-sm text-muted-foreground">暂无数据</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工作重点和行动计划 */}
|
||||
{followup && (followup.work_focus || followup.action_plan) && (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{followup.work_focus && (
|
||||
<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">{followup.work_focus}</p>
|
||||
</div>
|
||||
)}
|
||||
{followup.action_plan && (
|
||||
<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">{followup.action_plan}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 待办任务 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">待办任务 ({todoTasks.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{todoTasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无待办任务</p>
|
||||
) : todoTasks.map((t: any) => (
|
||||
<div key={t.task_id} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge type="priority" text={t.priority} />
|
||||
<span className="text-sm font-medium">{t.problem_indicator}</span>
|
||||
</div>
|
||||
<Badge type="status" text={t.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t.action_required}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">截止日: {t.deadline?.substring(0, 10)}</p>
|
||||
|
||||
{activeTaskId === t.task_id ? (
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
value={executeText}
|
||||
onChange={(e) => setExecuteText(e.target.value)}
|
||||
placeholder="填写执行结果和过程证据..."
|
||||
className="mb-2 w-full rounded-md border p-2 text-xs"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => executeMutation.mutate({ id: t.task_id, text: executeText })}
|
||||
disabled={!executeText || executeMutation.isPending}
|
||||
className="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{executeMutation.isPending ? '提交中...' : '提交反馈'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTaskId(null); setExecuteText('') }}
|
||||
className="rounded-md border px-3 py-1 text-xs"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
t.status === '待启动' && (
|
||||
<button
|
||||
onClick={() => setActiveTaskId(t.task_id)}
|
||||
className="mt-2 rounded-md border px-3 py-1 text-xs hover:bg-muted"
|
||||
>
|
||||
填写执行反馈
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已完成任务 */}
|
||||
{doneTasks.length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h2 className="mb-3 text-sm font-bold">已完成任务 ({doneTasks.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{doneTasks.map((t: any) => (
|
||||
<div key={t.task_id} className="flex items-center justify-between rounded-md border p-3 opacity-70">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge type="priority" text={t.priority} />
|
||||
<span className="text-sm">{t.problem_indicator}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{t.verification_result && <Badge type="review" text={t.verification_result} />}
|
||||
<Badge type="status" text={t.status} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user