diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index ad1c5e3..1015758 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -37,8 +37,8 @@ const menuGroups: MenuGroup[] = [ items: [ { path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] }, { path: '/cost', label: '成本库存', icon: DollarSign, roles: ['hq', 'dept'] }, - { path: '/cost-analysis', label: '菜品成本分析', icon: PieChart, roles: ['hq', 'dept'] }, - { path: '/store-expense', label: '门店费用分析', icon: Wallet, roles: ['hq', 'dept'] }, + { path: '/cost-analysis', label: '菜品成本', icon: PieChart, roles: ['hq', 'dept'] }, + { path: '/store-expense', label: '门店费用', icon: Wallet, roles: ['hq', 'dept'] }, { path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional'] }, { path: '/platform', label: '平台优惠', icon: ShoppingBag, roles: ['hq', 'dept'] }, { path: '/member', label: '会员复购', icon: Users, roles: ['hq', 'dept'] }, diff --git a/client/src/components/smart-scheduling/TrafficHeatmapTab.tsx b/client/src/components/smart-scheduling/TrafficHeatmapTab.tsx index e659fcc..46c59fd 100644 --- a/client/src/components/smart-scheduling/TrafficHeatmapTab.tsx +++ b/client/src/components/smart-scheduling/TrafficHeatmapTab.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, Fragment } from 'react' import { useQuery } from '@tanstack/react-query' import api from '@/lib/api' import { CollapsibleSection } from '@/components/CollapsibleSection' @@ -30,6 +30,11 @@ export function TrafficHeatmapTab() { queryFn: () => api.get(`/smart-scheduling/traffic-heatmap${storeName ? `?store=${storeName}` : ''}`), }) + const { data: staffing } = useQuery({ + queryKey: ['ss/traffic-heatmap-staffing', storeName], + queryFn: () => api.get(`/smart-scheduling/traffic-heatmap-staffing${storeName ? `?store=${storeName}` : ''}`), + }) + const { data: mealPeriod } = useQuery({ queryKey: ['ss/meal-period-traffic', storeName], queryFn: () => api.get(`/smart-scheduling/meal-period-traffic${storeName ? `?store=${storeName}` : ''}`), @@ -38,6 +43,7 @@ export function TrafficHeatmapTab() { const stores = (overview as any)?.data || [] const heatRows = (heatmap as any)?.data || [] const mealRows = (mealPeriod as any)?.data || [] + const staffingRows = (staffing as any)?.data || [] const storeNames = Array.from(new Set(stores.map((s: any) => s.store_name as string))) as string[] @@ -48,6 +54,13 @@ export function TrafficHeatmapTab() { heatMap[r.store_name][r.hour] = r.bills }) + // 人员分布: store -> hour -> { front, kitchen, management, other, total } + const staffingMap: Record> = {} + staffingRows.forEach((r: any) => { + if (!staffingMap[r.store_name]) staffingMap[r.store_name] = {} + staffingMap[r.store_name][r.hour] = { front: r.front, kitchen: r.kitchen, management: r.management, other: r.other, total: r.total } + }) + function heatColor(bills: number, max: number): string { if (!bills) return '' const ratio = bills / max @@ -127,25 +140,40 @@ export function TrafficHeatmapTab() { {storeNames.map((s) => )} 颜色越深客流越大 + 在岗: 前厅/后厨/管理/其他 {isLoading ? : ( -
- +
+
- {hours.map(h => )} + {hours.map(h => )} {Object.keys(heatMap).map(store => ( - - - {hours.map(h => { - const bills = heatMap[store]?.[h] || 0 - return - })} - + + + + {hours.map(h => { + const bills = heatMap[store]?.[h] || 0 + return + })} + + + + {hours.map(h => { + const s = staffingMap[store]?.[h] + if (!s || s.total === 0) return + return ( + + ) + })} + + ))}
门店{h}{h}
{store}{bills > 0 ? bills : ''}
{store}{bills > 0 ? bills : ''}
在岗- + {s.front}/{s.kitchen}/{s.management}/{s.other} +
diff --git a/client/src/index.css b/client/src/index.css index 60eba4c..ba3c586 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -54,3 +54,21 @@ body { color: hsl(var(--foreground)); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; } + +.heatmap-scroll::-webkit-scrollbar { + height: 10px; + -webkit-appearance: none; +} +.heatmap-scroll::-webkit-scrollbar-track { + background: #f0f0f0; + border-radius: 5px; +} +.heatmap-scroll::-webkit-scrollbar-thumb { + background-color: #999; + border-radius: 5px; + border: 2px solid #f0f0f0; +} +.heatmap-scroll { + scrollbar-width: auto; + scrollbar-color: #999 #f0f0f0; +} diff --git a/client/src/pages/CostAnalysisPage.tsx b/client/src/pages/CostAnalysisPage.tsx index 89fc901..7259eda 100644 --- a/client/src/pages/CostAnalysisPage.tsx +++ b/client/src/pages/CostAnalysisPage.tsx @@ -30,7 +30,7 @@ export function CostAnalysisPage() { return (
-

菜品成本分析

+

菜品成本

基于BOM与成本报表的多维度运营分析

diff --git a/server/src/routes/smart-scheduling.ts b/server/src/routes/smart-scheduling.ts index 660a0b0..3beabbe 100644 --- a/server/src/routes/smart-scheduling.ts +++ b/server/src/routes/smart-scheduling.ts @@ -60,6 +60,92 @@ router.get('/meal-period-traffic', async (req: AuthRequest, res) => { } }) +// 门店×小时在岗人员分布(从考勤打卡记录解析) +router.get('/traffic-heatmap-staffing', async (req: AuthRequest, res) => { + try { + const storeName = req.query.store as string + // 从考勤表取所有员工的打卡记录,解析上下班时间 + // department格式: 北京西部马华餐饮有限公司/西部马华品牌门店/.../七里庄店/前厅/管理组 + const result = await query(` + SELECT department, position, day_15 + FROM attendance_records + WHERE department LIKE '%西部马华品牌门店%' + AND day_15 IS NOT NULL AND day_15 != '' + `) + + // 从department提取门店名(以"店"结尾的层级) + function extractStore(dept: string): string { + const parts = dept.split('/') + for (let i = parts.length - 1; i >= 0; i--) { + if (parts[i].endsWith('店')) return parts[i] + } + return '' + } + + // 从打卡记录解析上下班时间,格式: 08:30(考勤机:指纹)+20:28(考勤机:指纹) + function parseClockTimes(raw: string): { start: number; end: number } | null { + const times = raw.match(/(\d{1,2}):(\d{2})/g) + if (!times || times.length < 2) return null + const startParts = times[0].match(/(\d{1,2}):(\d{2})/) + const endParts = times[times.length - 1].match(/(\d{1,2}):(\d{2})/) + if (!startParts || !endParts) return null + const startHour = parseInt(startParts[1]) + let endHour = parseInt(endParts[1]) + // 如果下班时间小于上班时间,说明跨天,按23点算 + if (endHour < startHour) endHour = 23 + return { start: startHour, end: endHour } + } + + // 汇总:store -> hour -> { 前厅, 后厨, 管理, 其他 } + const staffing: Record> = {} + + for (const row of result.rows) { + const store = extractStore(row.department as string) + if (!store) continue + if (storeName && store !== storeName) continue + + const clock = parseClockTimes(row.day_15 as string) + if (!clock) continue + + // 用position做SQL LIKE风格的判断 + const pos = row.position as string + const dept = row.department as string + let role = '其他' + if (pos.includes('店长') || pos.includes('经理') || pos.startsWith('储备') || pos.includes('副店')) role = '管理' + else if (dept.includes('/前厅/') || pos.includes('服务员') || pos.includes('训练员') || pos.includes('迎宾') || pos.includes('传菜') || pos.includes('主管')) role = '前厅' + else if (dept.includes('/后厨') || pos.includes('厨') || pos.includes('拉面') || pos.includes('配菜') || pos.includes('凉菜') || pos.includes('烧烤') || pos.includes('面点') || pos.includes('面工') || pos.includes('锅底') || pos.includes('切肉') || pos.includes('切菜') || pos.includes('炒锅') || pos.includes('砧板') || pos.includes('打荷') || pos.includes('洗碗') || pos.includes('上什') || pos.includes('打馕')) role = '后厨' + else if (pos.includes('兼职') || pos.includes('小时工')) role = '兼职' + + if (!staffing[store]) staffing[store] = {} + for (let h = clock.start; h <= clock.end; h++) { + if (!staffing[store][h]) staffing[store][h] = { 前厅: 0, 后厨: 0, 管理: 0, 其他: 0 } + staffing[store][h][role as '前厅' | '后厨' | '管理' | '其他']++ + } + } + + // 转为数组输出 + const output: any[] = [] + for (const [store, hours] of Object.entries(staffing)) { + for (let h = 0; h < 24; h++) { + const s = hours[h] || { 前厅: 0, 后厨: 0, 管理: 0, 其他: 0 } + output.push({ + store_name: store, + hour: h, + front: s.前厅, + kitchen: s.后厨, + management: s.管理, + other: s.其他, + total: s.前厅 + s.后厨 + s.管理 + s.其他, + }) + } + } + + sendSuccess(res, output) + } catch (err: any) { + sendError(res, err.message) + } +}) + // 工作日vs周末客流 router.get('/dow-traffic', async (req: AuthRequest, res) => { try {