import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { useAuth } from '../App' interface SalesGPItem { menu_item_name: string portion_name: string category: string total_qty: number total_revenue_net: number dbb_qty: number recipe_id: number | null recipe_name: string | null dish_course: string | null cost_per_portion: number | null total_cost: number | null item_gp_percent: number | null } interface SalesGPCourseGroup { course_name: string items: SalesGPItem[] course_revenue: number course_cost: number course_gp_percent: number | null } interface SalesGPResponse { from_date: string to_date: string courses: SalesGPCourseGroup[] unmapped_items: SalesGPItem[] mapped_revenue_net: number mapped_total_cost: number mapped_gp_percent: number | null total_all_revenue_net: number unmapped_revenue_net: number mapped_revenue_percent: number mapped_item_count: number unmapped_item_count: number } interface DishRecipe { id: number name: string menu_section_name: string | null cost_per_portion: number | null kds_menu_item_name: string | null sambapos_portion_name: string | null } export default function SalesGPReport() { const { token } = useAuth() const navigate = useNavigate() const queryClient = useQueryClient() // Date range const today = new Date().toISOString().slice(0, 10) const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10) const [fromDate, setFromDate] = useState(weekAgo) const [toDate, setToDate] = useState(today) const [submitted, setSubmitted] = useState(false) const [submittedFrom, setSubmittedFrom] = useState(weekAgo) const [submittedTo, setSubmittedTo] = useState(today) // Collapsed courses const [collapsedCourses, setCollapsedCourses] = useState>(new Set()) // Mapping modal const [mappingItem, setMappingItem] = useState(null) const [recipeSearch, setRecipeSearch] = useState('') // Fetch report data const { data: report, isLoading, error } = useQuery({ queryKey: ['sales-gp', submittedFrom, submittedTo], queryFn: async () => { const res = await fetch(`/kitchen/api/reports/sales-gp?from_date=${submittedFrom}&to_date=${submittedTo}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) { const err = await res.json().catch(() => ({ detail: 'Request failed' })) throw new Error(err.detail || 'Request failed') } return res.json() }, enabled: !!token && submitted, }) // Fetch dish recipes for mapping modal const { data: dishRecipes } = useQuery({ queryKey: ['recipes-for-mapping', recipeSearch], queryFn: async () => { const url = `/kitchen/api/recipes?recipe_type=dish${recipeSearch ? `&search=${encodeURIComponent(recipeSearch)}` : ''}` const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) return res.json() }, enabled: !!token && !!mappingItem, }) // Map unmapped item to recipe const mapMutation = useMutation({ mutationFn: async ({ recipeId, menuItemName, portionName }: { recipeId: number; menuItemName: string; portionName: string }) => { const res = await fetch(`/kitchen/api/recipes/${recipeId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ kds_menu_item_name: menuItemName, sambapos_portion_name: portionName === 'Normal' ? null : portionName, }), }) if (!res.ok) throw new Error('Failed to update recipe') }, onSuccess: () => { setMappingItem(null) setRecipeSearch('') queryClient.invalidateQueries({ queryKey: ['sales-gp'] }) }, }) const handleGenerate = () => { setSubmittedFrom(fromDate) setSubmittedTo(toDate) setSubmitted(true) } const toggleCourse = (name: string) => { setCollapsedCourses(prev => { const next = new Set(prev) if (next.has(name)) next.delete(name) else next.add(name) return next }) } const fmt = (n: number) => Number(n).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) const fmtPct = (n: number | null) => n != null ? `${Number(n).toFixed(1)}%` : '—' const gpColor = (pct: number | null) => { if (pct == null) return '#888' if (pct >= 70) return '#16a34a' if (pct >= 60) return '#ca8a04' return '#dc2626' } return (

Sales GP% Report

{/* Date range selector */}
setFromDate(e.target.value)} style={styles.dateInput} />
setToDate(e.target.value)} style={styles.dateInput} />
{isLoading &&
Loading sales data from SambaPOS...
} {error &&
{(error as Error).message}
} {report && ( <> {/* Summary banner */}
Estimated Sales GP%
{fmtPct(report.mapped_gp_percent)}
mapped items only
Mapped Revenue
£{fmt(report.mapped_revenue_net)}
Mapped Cost
£{fmt(report.mapped_total_cost)}
Total Revenue
£{fmt(report.total_all_revenue_net)}
{/* Coverage bar */}
Coverage: {Number(report.mapped_revenue_percent).toFixed(1)}% of food sales revenue is costed ({report.mapped_item_count} mapped, {report.unmapped_item_count} unmapped)
{/* Course sections */} {report.courses.map(course => (
toggleCourse(course.course_name)}>
{collapsedCourses.has(course.course_name) ? '▸' : '▾'} {course.course_name}
Revenue: £{fmt(course.course_revenue)} Cost: £{fmt(course.course_cost)} GP: {fmtPct(course.course_gp_percent)}
{!collapsedCourses.has(course.course_name) && ( {course.items.map((item, idx) => ( ))}
Item Portion Qty Net Revenue Cost/Portion Total Cost GP%
{item.recipe_id ? ( navigate(`/dishes/${item.recipe_id}`)} style={{ cursor: 'pointer', color: '#3b82f6', textDecoration: 'underline dotted', textUnderlineOffset: '3px' }} >{item.menu_item_name} ) : item.menu_item_name} {item.portion_name === 'Normal' ? '—' : item.portion_name} {item.total_qty} {item.dbb_qty > 0 && ( {item.dbb_qty} DBB )} £{fmt(item.total_revenue_net)} {item.cost_per_portion != null ? `\u00A3${fmt(item.cost_per_portion)}` : '—'} {item.total_cost != null ? `\u00A3${fmt(item.total_cost)}` : '—'} {fmtPct(item.item_gp_percent)}
)}
))} {/* Unmapped items section */} {report.unmapped_items.length > 0 && (
Unmapped Items £{fmt(report.unmapped_revenue_net)} unmapped ({(100 - Number(report.mapped_revenue_percent)).toFixed(1)}% of sales)
{report.unmapped_items.map((item, idx) => ( ))}
Item Portion Category Qty Net Revenue Action
{item.menu_item_name} {item.portion_name === 'Normal' ? '—' : item.portion_name} {item.category} {item.total_qty} {item.dbb_qty > 0 && ( {item.dbb_qty} DBB )} £{fmt(item.total_revenue_net)}
)} )} {/* Recipe mapping modal */} {mappingItem && (

Map: {mappingItem.menu_item_name} {mappingItem.portion_name !== 'Normal' && ` (${mappingItem.portion_name})`}

setRecipeSearch(e.target.value)} style={{ ...styles.searchInput, marginBottom: '0.75rem' }} placeholder="Search dish recipes..." autoFocus /> {!dishRecipes ? (
Loading recipes...
) : dishRecipes.length === 0 ? (
No dish recipes found.
) : (
{dishRecipes.map(r => (
mapMutation.mutate({ recipeId: r.id, menuItemName: mappingItem.menu_item_name, portionName: mappingItem.portion_name, })} style={styles.recipeRow} onMouseEnter={(e) => (e.currentTarget.style.background = '#f0f7ff')} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} >
{r.name}
{r.menu_section_name || 'No course'} {r.cost_per_portion != null && ` \u2022 Cost: \u00A3${Number(r.cost_per_portion).toFixed(2)}`}
{r.kds_menu_item_name && (
Already mapped: {r.kds_menu_item_name}
)}
))}
)}
)}
) } const styles: Record = { container: { maxWidth: '1100px', margin: '0 auto', padding: '1.5rem' }, pageTitle: { fontSize: '1.4rem', fontWeight: 700, marginBottom: '1rem' }, dateBar: { display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1.5rem', flexWrap: 'wrap' }, dateGroup: { display: 'flex', flexDirection: 'column', gap: '0.2rem' }, dateLabel: { fontSize: '0.75rem', fontWeight: 600, color: '#666' }, dateInput: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' }, generateBtn: { padding: '0.5rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' }, loading: { padding: '2rem', textAlign: 'center', color: '#888' }, error: { padding: '1rem', color: '#dc3545', background: '#fde8e8', borderRadius: '6px', marginBottom: '1rem' }, // Summary summaryBanner: { background: '#f8f9fa', padding: '1.25rem', borderRadius: '8px', border: '2px solid #e0e0e0', marginBottom: '1.5rem' }, summaryMain: { textAlign: 'center', marginBottom: '1rem' }, summaryLabel: { fontSize: '0.85rem', fontWeight: 600, color: '#666', textTransform: 'uppercase' }, summaryValue: { fontSize: '2.5rem', fontWeight: 800, lineHeight: 1.2 }, summarySubtext: { fontSize: '0.75rem', color: '#999' }, summaryStats: { display: 'flex', justifyContent: 'center', gap: '2rem', marginBottom: '1rem', flexWrap: 'wrap' }, statBox: { textAlign: 'center' }, statLabel: { fontSize: '0.75rem', color: '#666', fontWeight: 600 }, statValue: { fontSize: '1.1rem', fontWeight: 700 }, coverageSection: { borderTop: '1px solid #e0e0e0', paddingTop: '0.75rem' }, coverageLabel: { fontSize: '0.8rem', color: '#555', marginBottom: '0.4rem' }, coverageBarBg: { height: '8px', background: '#e0e0e0', borderRadius: '4px', overflow: 'hidden' }, coverageBarFill: { height: '100%', background: '#16a34a', borderRadius: '4px', transition: 'width 0.3s' }, // Course sections courseSection: { marginBottom: '1rem', border: '1px solid #e0e0e0', borderRadius: '8px', overflow: 'hidden' }, courseHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 1rem', background: '#f8f9fa', cursor: 'pointer', flexWrap: 'wrap', gap: '0.5rem' }, courseTitle: { fontWeight: 700, fontSize: '1rem' }, collapseIcon: { marginRight: '0.5rem', fontSize: '0.85rem' }, courseStats: { fontSize: '0.85rem', color: '#555' }, table: { width: '100%', borderCollapse: 'collapse' }, th: { padding: '0.5rem 0.75rem', textAlign: 'left', borderBottom: '2px solid #e0e0e0', fontSize: '0.75rem', fontWeight: 600, color: '#666', background: '#fafafa' }, tr: { borderBottom: '1px solid #f0f0f0' }, td: { padding: '0.4rem 0.75rem', fontSize: '0.85rem' }, // Unmapped unmappedSection: { marginTop: '1.5rem', border: '1px solid #f0c040', borderRadius: '8px', overflow: 'hidden' }, unmappedHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 1rem', background: '#fffbeb', fontWeight: 700, fontSize: '1rem', flexWrap: 'wrap', gap: '0.5rem' }, unmappedSubtext: { fontSize: '0.85rem', fontWeight: 400, color: '#92400e' }, mapBtn: { padding: '0.25rem 0.75rem', background: '#3b82f6', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 }, // DBB badge dbbBadge: { display: 'inline-block', marginLeft: '0.35rem', padding: '0.1rem 0.35rem', background: '#ede9fe', color: '#6d28d9', borderRadius: '4px', fontSize: '0.7rem', fontWeight: 700, cursor: 'default', whiteSpace: 'nowrap' as const }, // Modal overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }, modal: { background: 'white', borderRadius: '10px', width: '500px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 4px 20px rgba(0,0,0,0.2)' }, modalHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' }, modalBody: { padding: '1.25rem' }, closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' }, searchInput: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' }, recipeRow: { padding: '0.6rem 0.75rem', cursor: 'pointer', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, }