初始化:连锁餐饮数字化运营管理平台

This commit is contained in:
freedakgmail
2026-07-26 22:48:08 +08:00
commit a7874d79b5
67 changed files with 14391 additions and 0 deletions
+319
View File
@@ -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">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 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>
)
}