163 lines
8.5 KiB
TypeScript
163 lines
8.5 KiB
TypeScript
import { useState } from 'react'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import api from '@/lib/api'
|
||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||
import { FilterableTable } from '@/components/FilterableTable'
|
||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||
import { formatNumber } from '@/lib/utils'
|
||
|
||
const ACTION_COLORS: Record<string, string> = {
|
||
'需增配': 'text-red-600 font-medium',
|
||
'可减配': 'text-blue-500 font-medium',
|
||
'配置合理': 'text-green-600',
|
||
}
|
||
|
||
const ROW_BG: Record<string, string> = {
|
||
'需增配': 'bg-red-50',
|
||
'可减配': 'bg-blue-50',
|
||
'配置合理': 'bg-green-50',
|
||
}
|
||
|
||
function gapLabel(suggested: any, current: any): string {
|
||
const s = parseFloat(suggested) || 0
|
||
const c = parseFloat(current) || 0
|
||
const gap = s - c
|
||
if (gap > 0) return `+${gap.toFixed(1)}`
|
||
if (gap < 0) return gap.toFixed(1)
|
||
return '0'
|
||
}
|
||
|
||
function gapColor(suggested: any, current: any): string {
|
||
const s = parseFloat(suggested) || 0
|
||
const c = parseFloat(current) || 0
|
||
const gap = s - c
|
||
if (gap > 0) return 'text-red-600 font-medium'
|
||
if (gap < 0) return 'text-blue-500'
|
||
return 'text-muted-foreground'
|
||
}
|
||
|
||
const tableColumns = [
|
||
{ key: 'hour', label: '时段', render: (r: any) => `${r.hour}:00` },
|
||
{ key: 'avg_daily_bills', label: '日均账单', align: 'right' as const, render: (r: any) => formatNumber(r.avg_daily_bills) },
|
||
{ key: 'p85_bills', label: '高峰账单', align: 'right' as const, render: (r: any) => <span className="text-orange-600">{formatNumber(r.p85_bills)}</span> },
|
||
{ key: 'volatility', label: '波动系数', align: 'right' as const, render: (r: any) => <span className={parseFloat(r.volatility) > 1 ? 'text-red-600 font-medium' : ''}>{r.volatility}</span> },
|
||
{ key: 'suggested_front', label: '建议前厅', align: 'right' as const, render: (r: any) => r.suggested_front },
|
||
{ key: 'current_front', label: '当前前厅', align: 'right' as const, render: (r: any) => r.current_front },
|
||
{ key: 'gap_front', label: '前厅缺口', align: 'right' as const, render: (r: any) => <span className={gapColor(r.suggested_front, r.current_front)}>{gapLabel(r.suggested_front, r.current_front)}</span> },
|
||
{ key: 'suggested_kitchen', label: '建议后厨', align: 'right' as const, render: (r: any) => r.suggested_kitchen },
|
||
{ key: 'current_kitchen', label: '当前后厨', align: 'right' as const, render: (r: any) => r.current_kitchen },
|
||
{ key: 'gap_kitchen', label: '后厨缺口', align: 'right' as const, render: (r: any) => <span className={gapColor(r.suggested_kitchen, r.current_kitchen)}>{gapLabel(r.suggested_kitchen, r.current_kitchen)}</span> },
|
||
{ key: 'suggested_manager', label: '建议管理', align: 'right' as const, render: (r: any) => r.suggested_manager },
|
||
{ key: 'current_other', label: '当前管理', align: 'right' as const, render: (r: any) => r.current_other },
|
||
{ key: 'gap_manager', label: '管理缺口', align: 'right' as const, render: (r: any) => <span className={gapColor(r.suggested_manager, r.current_other)}>{gapLabel(r.suggested_manager, r.current_other)}</span> },
|
||
{ key: 'suggested_total', label: '建议合计', align: 'right' as const, render: (r: any) => <span className="font-medium">{r.suggested_total}</span> },
|
||
{ key: 'current_total', label: '当前合计', align: 'right' as const, render: (r: any) => r.current_total },
|
||
{ key: 'staff_gap', label: '总缺口', align: 'right' as const, render: (r: any) => <span className={parseFloat(r.staff_gap) > 0 ? 'text-red-600 font-bold' : parseFloat(r.staff_gap) < 0 ? 'text-blue-500 font-bold' : ''}>{parseFloat(r.staff_gap) > 0 ? '+' : ''}{r.staff_gap}</span> },
|
||
{ key: 'action', label: '建议', render: (r: any) => <span className={ACTION_COLORS[r.action] || ''}>{r.action}</span> },
|
||
]
|
||
|
||
export function SchedulingSuggestionTab({ month }: { month: string }) {
|
||
const { data: storesData } = useQuery({
|
||
queryKey: ['ss/stores'],
|
||
queryFn: () => api.get('/smart-scheduling/stores'),
|
||
})
|
||
const stores = (storesData as any)?.data || []
|
||
const [storeName, setStoreName] = useState('七里庄店')
|
||
const [frontTarget, setFrontTarget] = useState(15)
|
||
const [kitchenTarget, setKitchenTarget] = useState(25)
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['ss/suggestion', storeName, frontTarget, kitchenTarget, month],
|
||
queryFn: () => api.get(`/smart-scheduling/scheduling-suggestion?store=${storeName}&front_target=${frontTarget}&kitchen_target=${kitchenTarget}&month=${month}`),
|
||
enabled: !!storeName,
|
||
})
|
||
|
||
const rows = (data as any)?.data || []
|
||
|
||
const workdayRows = rows.filter((r: any) => r.day_type === '工作日')
|
||
const weekendRows = rows.filter((r: any) => r.day_type === '周末')
|
||
|
||
const increaseCount = rows.filter((r: any) => r.action === '需增配').length
|
||
const decreaseCount = rows.filter((r: any) => r.action === '可减配').length
|
||
const okCount = rows.filter((r: any) => r.action === '配置合理').length
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<CollapsibleSection title="排班建议" subtitle="基于历史客流规律,分岗位推荐各时段在岗人数">
|
||
<div className="mb-3 flex gap-3 items-center flex-wrap">
|
||
<div className="flex gap-2 items-center">
|
||
<label className="text-sm text-muted-foreground">门店:</label>
|
||
<select value={storeName} onChange={(e) => setStoreName(e.target.value)} className="border rounded px-2 py-1 text-sm">
|
||
{stores.map((s: any) => <option key={s.store_name} value={s.store_name}>{s.store_name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="flex gap-2 items-center">
|
||
<label className="text-sm text-muted-foreground">前厅人均:</label>
|
||
<input type="number" value={frontTarget} onChange={(e) => setFrontTarget(parseInt(e.target.value) || 15)} className="border rounded px-2 py-1 text-sm w-16" />
|
||
<span className="text-xs text-muted-foreground">单/人/时</span>
|
||
</div>
|
||
<div className="flex gap-2 items-center">
|
||
<label className="text-sm text-muted-foreground">后厨人均:</label>
|
||
<input type="number" value={kitchenTarget} onChange={(e) => setKitchenTarget(parseInt(e.target.value) || 25)} className="border rounded px-2 py-1 text-sm w-16" />
|
||
<span className="text-xs text-muted-foreground">单/人/时</span>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? <LoadingSpinner text="生成排班建议..." /> : (
|
||
<>
|
||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||
<div className="rounded-lg border p-3">
|
||
<p className="text-xs text-muted-foreground">需增配时段</p>
|
||
<p className="text-xl font-bold text-red-600">{increaseCount}</p>
|
||
</div>
|
||
<div className="rounded-lg border p-3">
|
||
<p className="text-xs text-muted-foreground">可减配时段</p>
|
||
<p className="text-xl font-bold text-blue-500">{decreaseCount}</p>
|
||
</div>
|
||
<div className="rounded-lg border p-3">
|
||
<p className="text-xs text-muted-foreground">配置合理时段</p>
|
||
<p className="text-xl font-bold text-green-600">{okCount}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<h3 className="text-sm font-bold mb-2">工作日排班建议</h3>
|
||
<FilterableTable
|
||
data={workdayRows}
|
||
sortOptions={[
|
||
{ key: 'hour', label: '时段' },
|
||
{ key: 'avg_daily_bills', label: '日均账单' },
|
||
{ key: 'staff_gap', label: '总缺口' },
|
||
]}
|
||
defaultSort="hour"
|
||
defaultOrder="asc"
|
||
filterKey="action"
|
||
filterLabel="全部建议"
|
||
columns={tableColumns}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<h3 className="text-sm font-bold mb-2">周末排班建议</h3>
|
||
<FilterableTable
|
||
data={weekendRows}
|
||
sortOptions={[
|
||
{ key: 'hour', label: '时段' },
|
||
{ key: 'avg_daily_bills', label: '日均账单' },
|
||
{ key: 'staff_gap', label: '总缺口' },
|
||
]}
|
||
defaultSort="hour"
|
||
defaultOrder="asc"
|
||
filterKey="action"
|
||
filterLabel="全部建议"
|
||
columns={tableColumns}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</CollapsibleSection>
|
||
</div>
|
||
)
|
||
}
|