feat: 店长工作台新增人力排班Tab及概览折叠功能
- 后端新增 /api/stores/:code/staffing 路由,返回人工成本概览、按星期/餐段排班建议、日营收明细、综合诊断 - 前端 StorePage 新增 staffing Tab,展示 MetricCard 概览、优化建议、排班图表、诊断算法说明 - 修复公司均值人工率被异常门店污染问题(过滤 wage_rate_pct > 100) - 诊断逻辑改为与公司均值动态比较,建议人工率改为动态计算 - 增强任务反馈表单(执行证据、未完成原因、下一步计划) - 点击非概览Tab时自动折叠上方概况区域
This commit is contained in:
+264
-21
@@ -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>
|
||||
各餐段账单占比 = 该餐段账单数 / 全餐段总账单数 × 100。占比超过40% 为绝对主力,超过20% 次高峰,超过5% 辅助,其余低谷。
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">按星期排班等级:</span>
|
||||
建议排班比 = 该星期日均实收 / 全周日均实收 × 100。超过110% 高峰(满编),低于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>
|
||||
|
||||
Reference in New Issue
Block a user