feat: 店长工作台新增人力排班Tab及概览折叠功能

- 后端新增 /api/stores/:code/staffing 路由,返回人工成本概览、按星期/餐段排班建议、日营收明细、综合诊断
- 前端 StorePage 新增 staffing Tab,展示 MetricCard 概览、优化建议、排班图表、诊断算法说明
- 修复公司均值人工率被异常门店污染问题(过滤 wage_rate_pct > 100)
- 诊断逻辑改为与公司均值动态比较,建议人工率改为动态计算
- 增强任务反馈表单(执行证据、未完成原因、下一步计划)
- 点击非概览Tab时自动折叠上方概况区域
This commit is contained in:
freedakgmail
2026-08-04 08:19:20 +08:00
parent 6bc2f43da7
commit 37cd5ae7e1
2 changed files with 459 additions and 21 deletions
+264 -21
View File
@@ -22,7 +22,7 @@ const SCORE_DIMENSIONS = [
{ key: 'score_bill', label: '客单价', max: 5 },
]
type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'tasks'
type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'staffing'
const TABS: { key: DetailTab; label: string }[] = [
{ key: 'overview', label: '概览' },
@@ -31,12 +31,15 @@ const TABS: { key: DetailTab; label: string }[] = [
{ key: 'cost', label: '成本分析' },
{ key: 'member', label: '会员分析' },
{ key: 'anomaly', label: '异常账单' },
{ key: 'staffing', label: '人力排班' },
]
export function StorePage() {
const queryClient = useQueryClient()
const [executeText, setExecuteText] = useState('')
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
const [incompleteReason, setIncompleteReason] = useState('')
const [nextStep, setNextStep] = useState('')
const userStoreCode = (() => {
try {
const u = JSON.parse(localStorage.getItem('user') || '{}')
@@ -46,6 +49,7 @@ export function StorePage() {
const [storeCode, setStoreCode] = useState(userStoreCode || '1111')
const [month, setMonth] = useState('2026-04')
const [detailTab, setDetailTab] = useState<DetailTab>('overview')
const [overviewOpen, setOverviewOpen] = useState(true)
const { data: storesData, isLoading: storesLoading } = useQuery({
queryKey: ['stores-list', month],
@@ -129,6 +133,12 @@ export function StorePage() {
enabled: detailTab === 'anomaly',
})
const { data: staffingData } = useQuery({
queryKey: ['store', storeCode, 'staffing', month],
queryFn: () => api.get(`/stores/${storeCode}/staffing`, { params: { month } }),
enabled: detailTab === 'staffing',
})
const card = (cardData as any)?.data
const anomalies = card?.anomalies || []
const tasks = (tasksData as any)?.data || []
@@ -152,6 +162,7 @@ export function StorePage() {
const member = (memberData as any)?.data
const anomalyDetailRows = ((anomalyDetailData as any)?.data) || []
const anomalyDetailTotal = (anomalyDetailData as any)?.meta?.total || anomalyDetailRows.length
const staffing = (staffingData as any)?.data
const sc = sd?.scorecard
const action = sd?.action
@@ -210,7 +221,7 @@ export function StorePage() {
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 }),
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/tasks/${id}/execute`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['store', storeCode, 'tasks'] })
setExecuteText('')
@@ -246,6 +257,19 @@ export function StorePage() {
</div>
</div>
{/* 概览折叠区 */}
<div className="rounded-lg border bg-card">
<button className="flex w-full items-center justify-between p-4 text-left" onClick={() => setOverviewOpen(!overviewOpen)}>
<div className="flex items-center gap-2">
<span className={`text-sm font-bold transition-transform ${overviewOpen ? 'rotate-90' : 'rotate-0'}`}></span>
<h2 className="text-sm font-bold"></h2>
</div>
<span className="text-xs text-muted-foreground">{overviewOpen ? '点击折叠' : '点击展开'}</span>
</button>
</div>
{overviewOpen && (<>
{/* 门店概况 */}
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-lg border bg-card p-4">
@@ -466,24 +490,45 @@ export function StorePage() {
<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="mt-2 space-y-2">
<div>
<label className="text-xs font-medium text-muted-foreground"> *</label>
<textarea
value={executeText}
onChange={(e) => setExecuteText(e.target.value)}
placeholder="填写执行结果、过程证据、数据截图说明等..."
className="mt-1 w-full rounded-md border p-2 text-xs"
rows={3}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground"></label>
<input
value={incompleteReason}
onChange={(e) => setIncompleteReason(e.target.value)}
placeholder="如遇阻碍请说明原因"
className="mt-1 w-full rounded-md border p-2 text-xs"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground"></label>
<input
value={nextStep}
onChange={(e) => setNextStep(e.target.value)}
placeholder="后续改进或跟进计划"
className="mt-1 w-full rounded-md border p-2 text-xs"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => executeMutation.mutate({ id: t.task_id, text: executeText })}
onClick={() => executeMutation.mutate({ id: t.task_id, data: { process_evidence: executeText, incomplete_reason: incompleteReason, next_step: nextStep } })}
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('') }}
onClick={() => { setActiveTaskId(null); setExecuteText(''); setIncompleteReason(''); setNextStep('') }}
className="rounded-md border px-3 py-1 text-xs"
>
@@ -511,24 +556,36 @@ export function StorePage() {
<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 key={t.task_id} className="rounded-md border p-3 opacity-80">
<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>
<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>
{t.process_evidence && (
<p className="mt-1 text-xs text-muted-foreground">: {t.process_evidence}</p>
)}
{t.next_step && (
<p className="mt-0.5 text-xs text-muted-foreground">: {t.next_step}</p>
)}
</div>
))}
</div>
</div>
)}
</>)}
{/* ========== 门店详情区 ========== */}
{sc && Number(sc.received) > 0 && (
<>
{overviewOpen && (
<>
{/* 核心指标 - 月度汇总 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-8">
<MetricCard title="营业收入" value={sc.consumption} format="currency" />
@@ -611,10 +668,12 @@ export function StorePage() {
</div>
)}
</>)}
{/* Tab bar */}
<div className="flex gap-1 border-b">
{TABS.map(t => (
<button key={t.key} onClick={() => setDetailTab(t.key)} className={`rounded-t-md border-b-2 px-3 py-2 text-sm font-medium transition-colors ${detailTab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}>
<button key={t.key} onClick={() => { setDetailTab(t.key); if (t.key !== 'overview') setOverviewOpen(false); else setOverviewOpen(true) }} className={`rounded-t-md border-b-2 px-3 py-2 text-sm font-medium transition-colors ${detailTab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}>
{t.label}
</button>
))}
@@ -877,6 +936,190 @@ export function StorePage() {
</div>
)}
{/* 人力排班 Tab */}
{detailTab === 'staffing' && (
<div className="space-y-4">
{!staffing ? <p className="text-sm text-muted-foreground"></p> : (
<>
{/* 人力成本概览 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
<MetricCard title="人工成本" value={staffing.summary.wage_expense} format="currency" />
<MetricCard title="人工成本率" value={staffing.summary.wage_rate_pct} format="percent" />
<MetricCard title="公司均值人工率" value={staffing.summary.avg_wage_rate} format="percent" />
<MetricCard title="人效(元/元人工)" value={staffing.summary.revenue_per_wage} format="number" />
<MetricCard title="每单人工成本" value={staffing.summary.wage_per_bill} format="currency" />
<MetricCard title="公司均值人效" value={staffing.summary.avg_rev_per_wage} format="number" />
</div>
{/* 优化建议 */}
{staffing.summary.wage_savings > 0 && (
<div className="rounded-lg border border-blue-200 bg-blue-50/50 p-3">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-blue-700"></span>
</div>
<div className="mt-2 grid grid-cols-3 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-bold text-blue-600">{formatCurrency(staffing.summary.suggested_wage)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-bold text-green-600">{formatCurrency(staffing.summary.wage_savings)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-bold text-green-600">{staffing.summary.savings_pct}%</p>
</div>
</div>
</div>
)}
{/* 综合诊断 */}
{staffing.diagnosis?.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-2 text-sm font-bold"></h2>
<ul className="space-y-1.5">
{staffing.diagnosis.map((d: string, i: number) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-0.5 text-blue-500"></span>
<span className="text-foreground">{d}</span>
</li>
))}
</ul>
<div className="mt-3 border-t pt-3">
<p className="mb-2 text-xs font-bold text-muted-foreground"></p>
<div className="space-y-2 text-xs text-muted-foreground">
<div>
<span className="font-medium text-foreground"></span>
= / × 100 = 100%
35% +3%
</div>
<div>
<span className="font-medium text-foreground"></span>
= max(20%, - 3%) = ×
= - = / × 100
</div>
<div>
<span className="font-medium text-foreground"></span>
= / × 10040% 20% 5%
</div>
<div>
<span className="font-medium text-foreground"></span>
= / × 100110% 85%
</div>
</div>
</div>
</div>
)}
{/* 按星期排班分析 */}
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{staffing.weekdays?.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={staffing.weekdays}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="weekday" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(1)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="avg_received" name="日均实收" fill="#3b82f6" radius={[4, 4, 0, 0]} />
<Bar dataKey="max_received" name="最高实收" fill="#93c5fd" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<FilterableTable
data={staffing.weekdays}
sortOptions={[
{ key: 'avg_received', label: '日均实收' },
{ key: 'avg_bills', label: '日均账单' },
]}
defaultSort="dow"
defaultOrder="asc"
columns={[
{ key: 'weekday', label: '星期' },
{ key: 'avg_received', label: '日均实收', align: 'right', render: (r: any) => formatCurrency(r.avg_received) },
{ key: 'avg_bills', label: '日均账单', align: 'right', render: (r: any) => formatNumber(r.avg_bills) },
{ key: 'max_received', label: '最高实收', align: 'right', render: (r: any) => formatCurrency(r.max_received) },
{ key: 'min_received', label: '最低实收', align: 'right', render: (r: any) => formatCurrency(r.min_received) },
{ key: 'suggested_headcount_ratio', label: '建议排班比', align: 'right', render: (r: any) => `${r.suggested_headcount_ratio}%` },
{ key: 'staffing_level', label: '排班等级', render: (r: any) => {
const colors: Record<string, string> = { '高峰': 'text-red-600 font-medium', '低谷': 'text-green-600 font-medium', '正常': '' }
return <span className={colors[r.staffing_level]}>{r.staffing_level}</span>
} },
{ key: 'staffing_action', label: '排班建议' },
]}
/>
</div>
</>
) : <p className="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>
{staffing.meals?.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={staffing.meals}>
<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) : v} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="received" name="实收" fill="#eab308" radius={[4, 4, 0, 0]} />
<Bar dataKey="bill_count" name="账单数" fill="#a855f7" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<FilterableTable
data={staffing.meals}
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'bill_count', label: '账单数' },
{ key: 'bill_share_pct', label: '占比' },
]}
defaultSort="received"
defaultOrder="desc"
columns={[
{ key: 'meal_period', label: '餐段' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r: any) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r: any) => formatCurrency(r.received) },
{ key: 'avg_bill', label: '客单价', align: 'right', render: (r: any) => formatCurrency(r.avg_bill) },
{ key: 'bill_share_pct', label: '账单占比', align: 'right', render: (r: any) => `${r.bill_share_pct}%` },
{ key: 'staffing_level', label: '排班等级', render: (r: any) => {
const colors: Record<string, string> = { '主力时段': 'text-red-600 font-medium', '次高峰': 'text-orange-600 font-medium', '辅助时段': 'text-blue-600', '低谷时段': 'text-green-600 font-medium' }
return <span className={colors[r.staffing_level]}>{r.staffing_level}</span>
} },
{ key: 'staffing_action', label: '排班建议' },
]}
/>
</div>
</>
) : <p className="text-sm text-muted-foreground"></p>}
</div>
{/* 日营收明细 */}
{staffing.daily?.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={staffing.daily}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="business_date" tickFormatter={(v) => v?.substring(5, 10)} tick={{ fontSize: 10 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip labelFormatter={(v) => v?.substring(0, 10)} formatter={(v: any) => formatCurrency(v)} />
<Line type="monotone" dataKey="received" name="实收" stroke="#3b82f6" dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
)}
</>
)}
</div>
)}
</>
)}
</div>