Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)

FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

107
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,107 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import AuthGate from './components/AuthGate'
import Layout from './components/Layout'
// Re-export so existing components can keep `import { useAuth } from '../App'`
export { useAuth } from './components/AuthGate'
// Pages
import Settings from './pages/Settings'
import NewbookData from './pages/NewbookData'
import ResosData from './pages/ResosData'
import ResidentsTableChart from './pages/ResidentsTableChart'
import BookingsStats from './pages/BookingsStats'
import WastageLogbook from './pages/WastageLogbook'
import UploadApp from './pages/UploadApp'
// Components used as full pages
import Dashboard from './components/Dashboard'
import Upload from './components/Upload'
import InvoiceList from './components/InvoiceList'
import Review from './components/Review'
import Disputes from './components/Disputes'
import Purchases from './components/Purchases'
import Budget from './components/Budget'
import GPReport from './components/GPReport'
import PurchasesReport from './components/PurchasesReport'
import AllowancesReport from './components/AllowancesReport'
import SalesGPReport from './components/SalesGPReport'
import ReconcilePurchases from './components/ReconcilePurchases'
import UsageVarianceReport from './components/UsageVarianceReport'
import SearchInvoices from './components/SearchInvoices'
import SearchLineItems from './components/SearchLineItems'
import PurchaseOrderList from './components/PurchaseOrderList'
import Ingredients from './components/Ingredients'
import RecipeList from './components/RecipeList'
import RecipeEditor from './components/RecipeEditor'
import EventOrders from './components/EventOrders'
import EventOrderEditor from './components/EventOrderEditor'
import DishList from './components/DishList'
import DishEditor from './components/DishEditor'
import MenuList from './components/MenuList'
import MenuEditor from './components/MenuEditor'
import BulkAllergens from './components/BulkAllergens'
import PriceImpact from './components/PriceImpact'
export default function App() {
return (
<AuthGate>
<Routes>
{/* Fullscreen PWA routes — no sidebar layout */}
<Route path="/upload-app" element={<UploadApp />} />
{/* All other routes get the sidebar Layout via Outlet */}
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
{/* Invoices */}
<Route path="/upload" element={<Upload />} />
<Route path="/invoices" element={<InvoiceList />} />
<Route path="/invoice/:id" element={<Review />} />
<Route path="/disputes" element={<Disputes />} />
<Route path="/search/invoices" element={<SearchInvoices />} />
<Route path="/search/line-items" element={<SearchLineItems />} />
<Route path="/purchase-orders" element={<PurchaseOrderList />} />
{/* Reports */}
<Route path="/gp" element={<GPReport />} />
<Route path="/purchases-report" element={<PurchasesReport />} />
<Route path="/purchases" element={<Purchases />} />
<Route path="/purchases/reconcile" element={<ReconcilePurchases />} />
<Route path="/allowances-report" element={<AllowancesReport />} />
<Route path="/sales-gp" element={<SalesGPReport />} />
<Route path="/usage-variance" element={<UsageVarianceReport />} />
{/* Kitchen */}
<Route path="/logbook" element={<WastageLogbook />} />
<Route path="/budget" element={<Budget />} />
<Route path="/event-orders" element={<EventOrders />} />
<Route path="/event-orders/:id" element={<EventOrderEditor />} />
{/* Bookings */}
<Route path="/resos" element={<ResosData />} />
<Route path="/resos-stats" element={<BookingsStats />} />
<Route path="/residents-table-chart" element={<ResidentsTableChart />} />
<Route path="/newbook" element={<NewbookData />} />
{/* Recipes */}
<Route path="/ingredients" element={<Ingredients />} />
<Route path="/recipes" element={<RecipeList />} />
<Route path="/recipes/:id" element={<RecipeEditor />} />
<Route path="/dishes" element={<DishList />} />
<Route path="/dishes/:id" element={<DishEditor />} />
<Route path="/menus" element={<MenuList />} />
<Route path="/menus/:id" element={<MenuEditor />} />
<Route path="/allergens" element={<BulkAllergens />} />
<Route path="/price-impact" element={<PriceImpact />} />
{/* Settings */}
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route>
</Routes>
</AuthGate>
)
}

21
frontend/src/api.ts Normal file
View file

@ -0,0 +1,21 @@
import axios from 'axios'
// Central axios instance for new/migrated code.
// Existing archive components still call fetch('/api/...') directly —
// see port log B5 for the migration tracker.
const api = axios.create({
baseURL: '/kitchen/api',
withCredentials: true,
})
api.interceptors.response.use(
(r) => r,
(error) => {
if (error.response?.status === 401) {
;(window.top ?? window).location.href = '/login'
}
return Promise.reject(error)
},
)
export default api

View file

@ -0,0 +1,833 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../App'
interface AllowancesSummaryResponse {
from_date: string
to_date: string
period_label: string
wastage_total: number
wastage_count: number
transfer_total: number
transfer_count: number
staff_food_total: number
staff_food_count: number
manual_adjustment_total: number
manual_adjustment_count: number
total_allowances: number
}
interface DailyAllowanceDataPoint {
date: string
wastage: number
transfer: number
staff_food: number
manual_adjustment: number
}
interface DailyAllowanceChartResponse {
from_date: string
to_date: string
data: DailyAllowanceDataPoint[]
}
interface DisputeTallyRow {
label: string
count: number
difference_value: number
}
interface DisputesSummaryResponse {
from_date: string
to_date: string
period_label: string
rows: DisputeTallyRow[]
}
// Helper to format date as YYYY-MM-DD for input fields and API
const formatDate = (d: Date): string => {
return d.toISOString().split('T')[0]
}
// Get the start and end of a given month
const getMonthBounds = (year: number, month: number): { start: string; end: string } => {
const start = new Date(year, month, 1)
const end = new Date(year, month + 1, 0)
return { start: formatDate(start), end: formatDate(end) }
}
// Generate list of months for the picker
const getMonthOptions = (): { label: string; year: number; month: number }[] => {
const options: { label: string; year: number; month: number }[] = []
const today = new Date()
for (let i = -12; i <= 2; i++) {
const d = new Date(today.getFullYear(), today.getMonth() + i, 1)
const label = d.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
options.push({ label, year: d.getFullYear(), month: d.getMonth() })
}
return options.reverse()
}
// Session storage keys
const STORAGE_KEY_FROM = 'allowances-report-from-date'
const STORAGE_KEY_TO = 'allowances-report-to-date'
// Get initial dates
const getInitialDates = () => {
const storedFrom = sessionStorage.getItem(STORAGE_KEY_FROM)
const storedTo = sessionStorage.getItem(STORAGE_KEY_TO)
if (storedFrom && storedTo) {
return { from: storedFrom, to: storedTo }
}
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const thirtyDaysAgo = new Date(yesterday)
thirtyDaysAgo.setDate(yesterday.getDate() - 30)
return { from: formatDate(thirtyDaysAgo), to: formatDate(yesterday) }
}
// Colors for allowance types
const ALLOWANCE_COLORS = {
wastage: '#e74c3c', // Red
transfer: '#3498db', // Blue
staff_food: '#9b59b6', // Purple
manual_adjustment: '#34495e', // Dark gray
}
export default function AllowancesReport() {
const { token } = useAuth()
const initialDates = getInitialDates()
const [fromDate, setFromDate] = useState(initialDates.from)
const [toDate, setToDate] = useState(initialDates.to)
const [selectedMonth, setSelectedMonth] = useState<string>('')
const [selectionMode, setSelectionMode] = useState<'last30' | 'week' | 'month' | 'custom'>('custom')
const [submittedFromDate, setSubmittedFromDate] = useState(initialDates.from)
const [submittedToDate, setSubmittedToDate] = useState(initialDates.to)
useEffect(() => {
sessionStorage.setItem(STORAGE_KEY_FROM, submittedFromDate)
sessionStorage.setItem(STORAGE_KEY_TO, submittedToDate)
}, [submittedFromDate, submittedToDate])
const monthOptions = getMonthOptions()
const hasUnsavedChanges = fromDate !== submittedFromDate || toDate !== submittedToDate
const handleGenerate = () => {
setSubmittedFromDate(fromDate)
setSubmittedToDate(toDate)
}
useEffect(() => {
if (selectedMonth) {
const [year, month] = selectedMonth.split('-').map(Number)
const bounds = getMonthBounds(year, month)
setFromDate(bounds.start)
setToDate(bounds.end)
setSubmittedFromDate(bounds.start)
setSubmittedToDate(bounds.end)
}
}, [selectedMonth])
const handleFromDateChange = (value: string) => {
setFromDate(value)
if (value > toDate) {
setToDate(value)
}
setSelectedMonth('')
setSelectionMode('custom')
}
const handleToDateChange = (value: string) => {
setToDate(value)
if (value < fromDate) {
setFromDate(value)
}
setSelectedMonth('')
setSelectionMode('custom')
}
const handleMonthChange = (value: string) => {
setSelectedMonth(value)
if (value) {
setSelectionMode('month')
} else {
setSelectionMode('custom')
}
}
const setLast30Days = () => {
const end = new Date()
end.setDate(end.getDate() - 1)
const start = new Date(end)
start.setDate(end.getDate() - 30)
const startStr = formatDate(start)
const endStr = formatDate(end)
setFromDate(startStr)
setToDate(endStr)
setSubmittedFromDate(startStr)
setSubmittedToDate(endStr)
setSelectedMonth('')
setSelectionMode('last30')
}
const setThisMonth = () => {
const now = new Date()
const monthKey = `${now.getFullYear()}-${now.getMonth()}`
const bounds = getMonthBounds(now.getFullYear(), now.getMonth())
setFromDate(bounds.start)
setToDate(bounds.end)
setSubmittedFromDate(bounds.start)
setSubmittedToDate(bounds.end)
setSelectedMonth(monthKey)
setSelectionMode('month')
}
const setLastMonth = () => {
const now = new Date()
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const monthKey = `${lastMonth.getFullYear()}-${lastMonth.getMonth()}`
const bounds = getMonthBounds(lastMonth.getFullYear(), lastMonth.getMonth())
setFromDate(bounds.start)
setToDate(bounds.end)
setSubmittedFromDate(bounds.start)
setSubmittedToDate(bounds.end)
setSelectedMonth(monthKey)
setSelectionMode('month')
}
const setThisWeek = () => {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
const end = new Date()
end.setDate(end.getDate() - 1)
const startStr = formatDate(monday)
const endStr = formatDate(end)
setFromDate(startStr)
setToDate(endStr)
setSubmittedFromDate(startStr)
setSubmittedToDate(endStr)
setSelectedMonth('')
setSelectionMode('week')
}
const setLastWeek = () => {
const today = new Date()
const dayOfWeek = today.getDay()
const lastMonday = new Date(today)
lastMonday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1) - 7)
const lastSunday = new Date(lastMonday)
lastSunday.setDate(lastMonday.getDate() + 6)
const startStr = formatDate(lastMonday)
const endStr = formatDate(lastSunday)
setFromDate(startStr)
setToDate(endStr)
setSubmittedFromDate(startStr)
setSubmittedToDate(endStr)
setSelectedMonth('')
setSelectionMode('week')
}
const getPeriodPrefix = (): string => {
if (selectionMode === 'last30') {
return 'Last 30 Days: '
} else if (selectionMode === 'week') {
return 'Week: '
} else if (selectionMode === 'month' && selectedMonth) {
const [year, month] = selectedMonth.split('-').map(Number)
const monthName = new Date(year, month, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
return `Month of ${monthName}: `
} else {
return 'Custom Dates: '
}
}
// Fetch allowances summary
const { data: summary, isLoading, error } = useQuery<AllowancesSummaryResponse>({
queryKey: ['allowances-summary', submittedFromDate, submittedToDate],
queryFn: async () => {
const res = await fetch(`/api/reports/allowances/summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch allowances summary')
return res.json()
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
})
// Fetch daily allowances for chart
const { data: chartData } = useQuery<DailyAllowanceChartResponse>({
queryKey: ['allowances-daily', submittedFromDate, submittedToDate],
queryFn: async () => {
const res = await fetch(`/api/reports/allowances/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch chart data')
return res.json()
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
})
// Fetch disputes summary
const { data: disputes } = useQuery<DisputesSummaryResponse>({
queryKey: ['disputes-period-summary', submittedFromDate, submittedToDate],
queryFn: async () => {
const res = await fetch(`/api/reports/disputes/period-summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch disputes summary')
return res.json()
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
})
const formatCurrency = (value: number) => {
return `£${Number(value).toFixed(2)}`
}
// Helper to get Monday of a given week
const getWeekStart = (dateStr: string): string => {
const d = new Date(dateStr)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1) // Adjust for Monday
const monday = new Date(d.setDate(diff))
return monday.toISOString().split('T')[0]
}
// Weekly bar chart for allowances
const renderChart = () => {
if (!chartData?.data?.length) {
return <div style={styles.noData}>No chart data available</div>
}
const width = 550
const height = 250
const padding = { top: 20, right: 20, bottom: 40, left: 60 }
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
const types = [
{ key: 'wastage', color: ALLOWANCE_COLORS.wastage, label: 'Wastage' },
{ key: 'transfer', color: ALLOWANCE_COLORS.transfer, label: 'Transfers' },
{ key: 'staff_food', color: ALLOWANCE_COLORS.staff_food, label: 'Staff Food' },
{ key: 'manual_adjustment', color: ALLOWANCE_COLORS.manual_adjustment, label: 'Manual Adj.' },
]
// Group data by week (Mon-Sun)
const weeklyData: Record<string, Record<string, number>> = {}
chartData.data.forEach(point => {
const weekStart = getWeekStart(point.date)
if (!weeklyData[weekStart]) {
weeklyData[weekStart] = { wastage: 0, transfer: 0, staff_food: 0, manual_adjustment: 0 }
}
weeklyData[weekStart].wastage += Number(point.wastage) || 0
weeklyData[weekStart].transfer += Number(point.transfer) || 0
weeklyData[weekStart].staff_food += Number(point.staff_food) || 0
weeklyData[weekStart].manual_adjustment += Number(point.manual_adjustment) || 0
})
const weeks = Object.keys(weeklyData).sort()
if (weeks.length === 0) {
return <div style={styles.noData}>No chart data available</div>
}
// Find max value across all weeks and types
let maxValue = 1
weeks.forEach(week => {
types.forEach(t => {
const val = weeklyData[week][t.key] || 0
if (val > maxValue) maxValue = val
})
})
// Scale functions
const weekWidth = chartWidth / weeks.length
const barGroupWidth = weekWidth * 0.8
const barWidth = Math.min(barGroupWidth / types.length, 25)
const yScale = (value: number) => padding.top + chartHeight - (value / maxValue) * chartHeight
// Y-axis labels
const yLabels = [0, maxValue / 2, maxValue].map(v => ({
value: v,
y: yScale(v),
label: `£${Number(v).toFixed(0)}`
}))
return (
<div style={styles.chartContainer}>
<h4 style={styles.chartTitle}>Weekly Allowances</h4>
<svg width={width} height={height} style={styles.chartSvg}>
{/* Horizontal grid lines */}
{yLabels.map((l, i) => (
<g key={i}>
<line
x1={padding.left}
y1={l.y}
x2={width - padding.right}
y2={l.y}
stroke="#eee"
strokeDasharray="4,4"
/>
<text x={padding.left - 5} y={l.y + 4} textAnchor="end" fontSize="10" fill="#666">
{l.label}
</text>
</g>
))}
{/* Bars for each week */}
{weeks.map((week, weekIndex) => {
const weekCenterX = padding.left + (weekIndex + 0.5) * weekWidth
const groupStartX = weekCenterX - (types.length * barWidth) / 2
return (
<g key={week}>
{/* X-axis label for week */}
<text
x={weekCenterX}
y={height - 8}
textAnchor="middle"
fontSize="9"
fill="#666"
>
{new Date(week).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
</text>
{/* Bars for each type */}
{types.map((type, typeIndex) => {
const value = weeklyData[week][type.key] || 0
if (value <= 0) return null
const barHeight = (value / maxValue) * chartHeight
const barX = groupStartX + typeIndex * barWidth
return (
<rect
key={`${week}-${type.key}`}
x={barX}
y={padding.top + chartHeight - barHeight}
width={barWidth - 1}
height={barHeight}
fill={type.color}
/>
)
})}
</g>
)
})}
</svg>
<div style={styles.chartLegend}>
{types.map(({ key, color, label }) => (
<span key={key} style={styles.legendItem}>
<span style={{ ...styles.legendColor, background: color }} />
{label}
</span>
))}
</div>
</div>
)
}
if (isLoading) {
return <div style={styles.loading}>Loading allowances data...</div>
}
if (error) {
return <div style={styles.error}>Error loading allowances data: {(error as Error).message}</div>
}
return (
<div>
{/* Header */}
<div style={styles.header}>
<h2 style={styles.title}>Allowances Report</h2>
</div>
{/* Date Selection Controls */}
<div style={styles.dateControls}>
<div style={styles.dateRow}>
<div style={styles.dateField}>
<label style={styles.dateLabel}>From</label>
<input
type="date"
value={fromDate}
onChange={(e) => handleFromDateChange(e.target.value)}
style={styles.dateInput}
/>
</div>
<div style={styles.dateField}>
<label style={styles.dateLabel}>To</label>
<input
type="date"
value={toDate}
onChange={(e) => handleToDateChange(e.target.value)}
style={styles.dateInput}
/>
</div>
<div style={styles.dateField}>
<label style={styles.dateLabel}>Month</label>
<select
value={selectedMonth}
onChange={(e) => handleMonthChange(e.target.value)}
style={styles.monthSelect}
>
<option value="">Custom Range</option>
{monthOptions.map((opt) => (
<option key={`${opt.year}-${opt.month}`} value={`${opt.year}-${opt.month}`}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div style={styles.presetRow}>
<button onClick={setLast30Days} style={styles.presetBtn}>Last 30 Days</button>
<button onClick={setThisWeek} style={styles.presetBtn}>This Week</button>
<button onClick={setLastWeek} style={styles.presetBtn}>Last Week</button>
<button onClick={setThisMonth} style={styles.presetBtn}>This Month</button>
<button onClick={setLastMonth} style={styles.presetBtn}>Last Month</button>
<button
onClick={handleGenerate}
disabled={!hasUnsavedChanges}
style={{
...styles.generateBtn,
...(hasUnsavedChanges ? {} : styles.generateBtnDisabled),
}}
>
{hasUnsavedChanges ? 'Generate Report' : 'Report Generated'}
</button>
</div>
</div>
{/* Period Label */}
<div style={styles.periodLabel}>{getPeriodPrefix()}{summary?.period_label}</div>
{/* Main Content */}
<div style={styles.mainContent}>
{/* Allowances Breakdown */}
<div style={styles.sectionContainer}>
<h3 style={styles.sectionTitle}>Allowances Breakdown</h3>
<table style={styles.breakdownTable}>
<thead>
<tr>
<th style={styles.tableHeader}>Type</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Entries</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Total</th>
</tr>
</thead>
<tbody>
<tr>
<td style={styles.tableCell}>
<span style={{ ...styles.typeDot, background: ALLOWANCE_COLORS.wastage }} />
Wastage
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace' }}>
{summary?.wastage_count || 0}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', color: ALLOWANCE_COLORS.wastage }}>
{formatCurrency(summary?.wastage_total || 0)}
</td>
</tr>
<tr>
<td style={styles.tableCell}>
<span style={{ ...styles.typeDot, background: ALLOWANCE_COLORS.transfer }} />
Transfers
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace' }}>
{summary?.transfer_count || 0}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', color: ALLOWANCE_COLORS.transfer }}>
{formatCurrency(summary?.transfer_total || 0)}
</td>
</tr>
<tr>
<td style={styles.tableCell}>
<span style={{ ...styles.typeDot, background: ALLOWANCE_COLORS.staff_food }} />
Staff Food
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace' }}>
{summary?.staff_food_count || 0}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', color: ALLOWANCE_COLORS.staff_food }}>
{formatCurrency(summary?.staff_food_total || 0)}
</td>
</tr>
<tr>
<td style={styles.tableCell}>
<span style={{ ...styles.typeDot, background: ALLOWANCE_COLORS.manual_adjustment }} />
Manual Adjustments
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace' }}>
{summary?.manual_adjustment_count || 0}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', color: ALLOWANCE_COLORS.manual_adjustment }}>
{formatCurrency(summary?.manual_adjustment_total || 0)}
</td>
</tr>
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid #dee2e6' }}>
<td style={{ ...styles.tableCell, fontWeight: 'bold' }}>Total</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', fontWeight: 'bold' }}>
{(summary?.wastage_count || 0) + (summary?.transfer_count || 0) + (summary?.staff_food_count || 0) + (summary?.manual_adjustment_count || 0)}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', fontWeight: 'bold', color: '#27ae60' }}>
{formatCurrency(summary?.total_allowances || 0)}
</td>
</tr>
</tfoot>
</table>
</div>
{/* Chart */}
<div style={styles.sectionContainer}>
{renderChart()}
</div>
</div>
{/* Disputes Summary */}
<div style={styles.disputesSection}>
<h3 style={styles.sectionTitle}>Disputes Summary</h3>
<p style={styles.disputesSubtitle}>Cases opened in selected period (by invoice date)</p>
<table style={styles.breakdownTable}>
<thead>
<tr>
<th style={styles.tableHeader}>Status</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Count</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Difference Value</th>
</tr>
</thead>
<tbody>
{disputes?.rows?.map((row, index) => (
<tr key={index} style={row.label === 'Total Cases' ? { background: '#f8f9fa' } : {}}>
<td style={{ ...styles.tableCell, fontWeight: row.label === 'Total Cases' ? 'bold' : 'normal' }}>
{row.label}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', fontWeight: row.label === 'Total Cases' ? 'bold' : 'normal' }}>
{row.count}
</td>
<td style={{
...styles.tableCell,
textAlign: 'right',
fontFamily: 'monospace',
fontWeight: row.label === 'Total Cases' ? 'bold' : 'normal',
color: row.label === 'Resolved' ? '#27ae60' : row.label === 'Still Open' ? '#e67e22' : undefined
}}>
{formatCurrency(row.difference_value)}
</td>
</tr>
)) || (
<tr>
<td colSpan={3} style={{ ...styles.tableCell, textAlign: 'center', color: '#999' }}>
No disputes data
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
loading: {
padding: '2rem',
textAlign: 'center',
color: '#666',
},
error: {
padding: '2rem',
textAlign: 'center',
color: '#c00',
background: '#fee',
borderRadius: '8px',
},
noData: {
padding: '2rem',
textAlign: 'center',
color: '#999',
},
header: {
marginBottom: '1.5rem',
},
title: {
color: '#1a1a2e',
margin: 0,
},
dateControls: {
background: 'white',
borderRadius: '12px',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
marginBottom: '1rem',
},
dateRow: {
display: 'flex',
gap: '1.5rem',
flexWrap: 'wrap',
marginBottom: '1rem',
},
dateField: {
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
},
dateLabel: {
fontSize: '0.85rem',
color: '#666',
fontWeight: 500,
},
dateInput: {
padding: '0.5rem 0.75rem',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '1rem',
minWidth: '150px',
},
monthSelect: {
padding: '0.5rem 0.75rem',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '1rem',
minWidth: '180px',
background: 'white',
},
presetRow: {
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
},
presetBtn: {
padding: '0.4rem 0.75rem',
background: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.85rem',
},
generateBtn: {
padding: '0.4rem 1rem',
background: '#e94560',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 'bold',
marginLeft: 'auto',
},
generateBtnDisabled: {
background: '#ccc',
cursor: 'default',
},
periodLabel: {
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#1a1a2e',
marginBottom: '1rem',
textAlign: 'center',
},
mainContent: {
display: 'flex',
gap: '1.5rem',
flexWrap: 'wrap',
alignItems: 'stretch',
},
sectionContainer: {
background: 'white',
borderRadius: '12px',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
marginBottom: '2rem',
flex: '1 1 auto',
minWidth: '300px',
},
sectionTitle: {
margin: '0 0 1rem 0',
color: '#1a1a2e',
},
breakdownTable: {
width: '100%',
borderCollapse: 'collapse',
},
tableHeader: {
textAlign: 'left',
padding: '0.75rem 0.5rem',
borderBottom: '2px solid #dee2e6',
color: '#1a1a2e',
fontWeight: 600,
fontSize: '0.85rem',
},
tableCell: {
padding: '0.5rem',
borderBottom: '1px solid #f0f0f0',
fontSize: '0.9rem',
},
typeDot: {
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: '50%',
marginRight: '8px',
},
chartContainer: {
width: '100%',
},
chartTitle: {
margin: '0 0 1rem 0',
color: '#1a1a2e',
fontSize: '1rem',
fontWeight: 600,
},
chartSvg: {
display: 'block',
maxWidth: '100%',
},
chartLegend: {
display: 'flex',
gap: '0.5rem 0.75rem',
marginTop: '0.5rem',
flexWrap: 'wrap',
justifyContent: 'center',
maxHeight: '60px',
overflowY: 'auto',
padding: '0.25rem',
},
legendItem: {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
fontSize: '0.7rem',
color: '#555',
whiteSpace: 'nowrap',
},
legendColor: {
width: '10px',
height: '10px',
borderRadius: '2px',
display: 'inline-block',
flexShrink: 0,
},
disputesSection: {
background: 'white',
borderRadius: '12px',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
marginBottom: '2rem',
},
disputesSubtitle: {
color: '#666',
fontSize: '0.85rem',
margin: '0 0 1rem 0',
},
}

View file

@ -0,0 +1,70 @@
import { createContext, useContext, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import type { User } from '../types'
// Compatibility shim: archive components destructure token/login/logout/restrictedPages
// from useAuth(). These stubs keep the TypeScript build clean. Runtime behaviour:
// token = "__session__" (truthy so component guards pass; backends ignore the
// Authorization header and use the hnf_session cookie — see log B5b for full migration).
// restrictedPages = [] (replaced by cap-based access control).
// logout = redirect to /login portal.
// login = no-op (cookie auth, no local token).
interface AuthCtx {
user: User
token: string
restrictedPages: string[]
login: (t: string) => void
logout: () => void
}
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth outside AuthGate')
return ctx
}
export default function AuthGate({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [checking, setChecking] = useState(true)
useEffect(() => {
fetch('/kitchen/api/auth/verify?app=kitchen', { credentials: 'include' })
.then((r) => {
if (!r.ok) throw new Error('unauth')
return r.json()
})
.then((data) =>
setUser({
email: data.email || data.sub || '',
name: data.name || data.display_name || '',
is_admin: data.is_admin ?? false,
caps: data.caps ?? [],
})
)
.catch(() => {
;(window.top ?? window).location.href = '/login'
})
.finally(() => setChecking(false))
}, [])
if (checking) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--navy-dark)' }}>
<div className="spinner" style={{ width: 32, height: 32 }} />
</div>
)
}
if (!user) return null
const ctx: AuthCtx = {
user,
token: '__session__',
restrictedPages: [],
login: () => {},
logout: () => { (window.top ?? window).location.href = '/login' },
}
return <Ctx.Provider value={ctx}>{children}</Ctx.Provider>
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,438 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
interface FlagInfo {
id: number
food_flag_id: number
flag_name: string
flag_code: string | null
category_name: string
propagation_type: string
source: string
}
interface AllergenSuggestion {
flag_id: number
flag_name: string
flag_code: string | null
category_name: string
matched_keywords: string[]
}
interface IngredientItem {
id: number
name: string
category_id: number | null
category_name: string | null
standard_unit: string
notes: string | null
is_prepackaged: boolean
product_ingredients: string | null
flags: FlagInfo[]
}
interface FoodFlagItem {
id: number
name: string
code: string | null
propagation_type: string
}
interface FoodFlagCategoryItem {
id: number
name: string
propagation_type: string
required: boolean
flags: FoodFlagItem[]
}
interface IngredientCategory {
id: number
name: string
}
export default function BulkAllergens() {
const { token } = useAuth()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('')
const [showUnassessedOnly, setShowUnassessedOnly] = useState(false)
const [expandedId, setExpandedId] = useState<number | null>(null)
// Fetch all non-archived ingredients
const { data: ingredients } = useQuery<IngredientItem[]>({
queryKey: ['ingredients-bulk'],
queryFn: async () => {
const res = await fetch('/api/ingredients?limit=9999', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch ingredients')
return res.json()
},
enabled: !!token,
})
// Fetch ingredient categories
const { data: categories } = useQuery<IngredientCategory[]>({
queryKey: ['ingredient-categories'],
queryFn: async () => {
const res = await fetch('/api/ingredients/categories', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return []
return res.json()
},
enabled: !!token,
})
// Fetch flag categories (only required ones shown as columns)
const { data: flagCategories } = useQuery<FoodFlagCategoryItem[]>({
queryKey: ['food-flag-categories-full'],
queryFn: async () => {
const res = await fetch('/api/food-flags/categories', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch flag categories')
return res.json()
},
enabled: !!token,
})
// Fetch bulk nones (ingredient_id -> category_ids where None is set)
const { data: bulkNones } = useQuery<Record<number, number[]>>({
queryKey: ['bulk-nones'],
queryFn: async () => {
const res = await fetch('/api/ingredients/bulk-nones', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return {}
return res.json()
},
enabled: !!token,
})
// Fetch suggestions for ALL ingredients in bulk (single request)
const { data: allSuggestions } = useQuery<Record<number, AllergenSuggestion[]>>({
queryKey: ['bulk-suggestions'],
queryFn: async () => {
const res = await fetch('/api/food-flags/suggest/bulk', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return {}
return res.json()
},
enabled: !!token,
})
// Toggle a flag on an ingredient
const toggleFlagMutation = useMutation({
mutationFn: async ({ ingredientId, flagId, action }: { ingredientId: number; flagId: number; action: 'add' | 'remove' }) => {
// Get current flags for this ingredient
const ing = ingredients?.find(i => i.id === ingredientId)
const currentFlagIds = ing?.flags.map(f => f.food_flag_id) || []
let newFlagIds: number[]
if (action === 'add') {
newFlagIds = [...currentFlagIds, flagId]
} else {
newFlagIds = currentFlagIds.filter(id => id !== flagId)
}
const res = await fetch(`/api/ingredients/${ingredientId}/flags`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ food_flag_ids: newFlagIds }),
})
if (!res.ok) throw new Error('Failed to update flags')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] })
queryClient.invalidateQueries({ queryKey: ['bulk-nones'] })
},
})
// Toggle None for a category on an ingredient
const toggleNoneMutation = useMutation({
mutationFn: async ({ ingredientId, categoryId }: { ingredientId: number; categoryId: number }) => {
const res = await fetch(`/api/ingredients/${ingredientId}/flags/none`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ category_id: categoryId }),
})
if (!res.ok) throw new Error('Failed to toggle none')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ingredients-bulk'] })
queryClient.invalidateQueries({ queryKey: ['bulk-nones'] })
},
})
const toggleExpanded = (ing: IngredientItem) => {
setExpandedId(expandedId === ing.id ? null : ing.id)
}
// Only show required categories as column groups
const requiredCategories = flagCategories?.filter(c => c.required) || []
// Build a flat list of flag columns
const flagColumns: Array<{ flagId: number; flagName: string; flagCode: string | null; categoryId: number; categoryName: string; propagation: string }> = []
for (const cat of requiredCategories) {
for (const f of cat.flags) {
flagColumns.push({
flagId: f.id,
flagName: f.name,
flagCode: f.code,
categoryId: cat.id,
categoryName: cat.name,
propagation: cat.propagation_type,
})
}
}
// Filter ingredients
const filtered = (ingredients || []).filter(ing => {
if (search && !ing.name.toLowerCase().includes(search.toLowerCase())) return false
if (categoryFilter && ing.category_id !== parseInt(categoryFilter)) return false
if (showUnassessedOnly) {
// Check if ingredient is unassessed for any required category
const nones = bulkNones?.[ing.id] || []
for (const cat of requiredCategories) {
if (nones.includes(cat.id)) continue // None set for this category
const hasFlagInCat = ing.flags.some(f => cat.flags.some(cf => cf.id === f.food_flag_id))
if (!hasFlagInCat) return true // Unassessed for this category
}
return false
}
return true
})
return (
<div style={styles.page}>
<h2 style={{ margin: '0 0 0.75rem 0' }}>Bulk Allergen Assessment</h2>
{/* Filters */}
<div style={styles.filterBar}>
<input
type="text"
placeholder="Search ingredients..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={styles.searchInput}
/>
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={styles.select}>
<option value="">All Categories</option>
{categories?.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.85rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={showUnassessedOnly}
onChange={(e) => setShowUnassessedOnly(e.target.checked)}
/>
Unassessed only
</label>
<span style={{ fontSize: '0.85rem', color: '#666', marginLeft: 'auto' }}>
{filtered.length} ingredients
</span>
</div>
{flagColumns.length === 0 ? (
<div style={styles.emptyState}>
No required flag categories found. Go to Settings &gt; Food Flags and mark allergen categories as "Required".
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={styles.table}>
<thead style={{ position: 'sticky', top: 0, zIndex: 3 }}>
<tr>
<th style={{ ...styles.th, position: 'sticky', left: 0, background: '#fafafa', zIndex: 4, minWidth: '200px' }}>
Ingredient
</th>
{requiredCategories.map(cat => (
<th
key={`cat-none-${cat.id}`}
style={{ ...styles.th, textAlign: 'center', fontSize: '0.7rem', background: '#f0fdf4', minWidth: '50px' }}
title={`None apply for ${cat.name}`}
>
None
</th>
))}
{flagColumns.map(col => (
<th
key={col.flagId}
style={{ ...styles.th, textAlign: 'center' as const, minWidth: '45px', writingMode: 'vertical-lr' as const, fontSize: '0.7rem' }}
title={`${col.flagName} (${col.categoryName})`}
>
{col.flagCode || col.flagName}
</th>
))}
</tr>
</thead>
{filtered.map(ing => {
const ingFlagIds = new Set(ing.flags.map(f => f.food_flag_id))
const nones = bulkNones?.[ing.id] || []
const isExpanded = expandedId === ing.id
const ingSuggestions = allSuggestions?.[ing.id]
const pendingSuggestions = ingSuggestions?.filter(s => !ingFlagIds.has(s.flag_id))
const hasPendingSuggestions = !!pendingSuggestions?.length
const totalCols = 1 + requiredCategories.length + flagColumns.length
return (
<tbody key={ing.id}>
<tr style={styles.tr}>
<td
style={{
...styles.td, position: 'sticky', left: 0, zIndex: 1, fontWeight: 500, cursor: 'pointer',
background: isExpanded ? '#f0f7ff' : hasPendingSuggestions ? '#fffbeb' : 'white',
borderLeft: hasPendingSuggestions ? '3px solid #f59e0b' : undefined,
}}
onClick={() => toggleExpanded(ing)}
title={hasPendingSuggestions ? `${pendingSuggestions!.length} suggested allergen(s) — click to review` : 'Click to show details'}
>
<span style={{ fontSize: '0.65rem', color: '#888', marginRight: '4px' }}>{isExpanded ? '\u25BC' : '\u25B6'}</span>
{ing.name}
{ing.category_name && (
<span style={{ fontSize: '0.7rem', color: '#999', marginLeft: '6px' }}>{ing.category_name}</span>
)}
{ing.is_prepackaged && (
<span style={{ fontSize: '0.6rem', color: '#6366f1', marginLeft: '6px', fontWeight: 600 }}>PKG</span>
)}
</td>
{/* None columns per required category */}
{requiredCategories.map(cat => {
const isNone = nones.includes(cat.id)
return (
<td key={`none-${cat.id}`} style={{ ...styles.td, textAlign: 'center', background: isNone ? '#dcfce7' : undefined }}>
<input
type="checkbox"
checked={isNone}
onChange={() => toggleNoneMutation.mutate({ ingredientId: ing.id, categoryId: cat.id })}
disabled={toggleNoneMutation.isPending}
style={{ cursor: 'pointer' }}
title={`None apply for ${cat.name}`}
/>
</td>
)
})}
{/* Flag columns */}
{flagColumns.map(col => {
const isChecked = ingFlagIds.has(col.flagId)
const isNoneForCategory = nones.includes(col.categoryId)
const isSuggested = pendingSuggestions?.some(s => s.flag_id === col.flagId)
return (
<td
key={col.flagId}
style={{
...styles.td,
textAlign: 'center',
background: isChecked
? (col.propagation === 'contains' ? '#fef2f2' : '#dcfce7')
: isSuggested ? '#fffbeb'
: isNoneForCategory ? '#f8f8f8' : undefined,
opacity: isNoneForCategory ? 0.4 : 1,
}}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleFlagMutation.mutate({
ingredientId: ing.id,
flagId: col.flagId,
action: isChecked ? 'remove' : 'add',
})}
disabled={isNoneForCategory || toggleFlagMutation.isPending}
style={{ cursor: isNoneForCategory ? 'not-allowed' : 'pointer' }}
title={col.flagName + (isSuggested ? ' (suggested)' : '')}
/>
</td>
)
})}
</tr>
{/* Expanded detail row */}
{isExpanded && (
<tr>
<td colSpan={totalCols} style={{ padding: '0.5rem 0.75rem', background: '#f8fafc', borderBottom: '2px solid #e0e0e0' }}>
<div style={{ display: 'flex', gap: '1.5rem', fontSize: '0.8rem' }}>
{/* Notes */}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>Notes</div>
<div style={{ color: ing.notes ? '#333' : '#bbb', whiteSpace: 'pre-wrap' }}>
{ing.notes || 'No notes'}
</div>
</div>
{/* Product ingredients */}
<div style={{ flex: 2 }}>
<div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>
Label Ingredients {ing.is_prepackaged && <span style={{ color: '#6366f1' }}>(prepackaged)</span>}
</div>
<div style={{ color: ing.product_ingredients ? '#333' : '#bbb', whiteSpace: 'pre-wrap', maxHeight: '80px', overflow: 'auto' }}>
{ing.product_ingredients || 'Not available'}
</div>
</div>
{/* Allergen suggestions */}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: '#555', marginBottom: '0.25rem', fontSize: '0.7rem', textTransform: 'uppercase' as const }}>Keyword Suggestions</div>
{!pendingSuggestions?.length ? (
<div style={{ color: '#bbb' }}>No suggestions</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
{pendingSuggestions.map(s => (
<div
key={s.flag_id}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.15rem 0.3rem', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: '4px' }}
>
<span>
<strong style={{ fontSize: '0.75rem' }}>{s.flag_name}</strong>
<span style={{ color: '#999', fontSize: '0.65rem', marginLeft: '0.25rem' }}>
{s.matched_keywords.join(', ')}
</span>
</span>
<button
onClick={() => {
const cat = requiredCategories.find(c => c.flags.some(f => f.id === s.flag_id))
if (cat) toggleFlagMutation.mutate({ ingredientId: ing.id, flagId: s.flag_id, action: 'add' })
}}
disabled={toggleFlagMutation.isPending}
style={{ background: '#f59e0b', color: 'white', border: 'none', borderRadius: '3px', padding: '0.1rem 0.3rem', fontSize: '0.65rem', fontWeight: 600, cursor: 'pointer' }}
>
Apply
</button>
</div>
))}
</div>
)}
</div>
</div>
</td>
</tr>
)}
</tbody>
)
})}
</table>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1600px', margin: '0 auto' },
filterBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' as const },
searchInput: { padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', width: '250px' },
select: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' },
emptyState: { padding: '3rem', textAlign: 'center' as const, color: '#888', background: '#fafafa', borderRadius: '8px' },
table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
th: { padding: '0.5rem 0.4rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.75rem', fontWeight: 600, color: '#555' },
tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.35rem 0.4rem', fontSize: '0.85rem' },
}

View file

@ -0,0 +1,240 @@
import { useState } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import { useAuth } from '../App'
interface DishItem {
id: number
name: string
description: string | null
gross_sell_price: number | null
flag_summary: Array<{ name: string; code: string | null; active: boolean }>
}
interface Props {
menuId: number
divisionId: number
divisionName: string
onClose: () => void
onPublished: () => void
}
export default function BulkPublishModal({ menuId, divisionId, divisionName, onClose, onPublished }: Props) {
const { token, user } = useAuth()
const [step, setStep] = useState<'select' | 'confirm'>('select')
const [selected, setSelected] = useState<Set<number>>(new Set())
const [search, setSearch] = useState('')
const [confirmedBy, setConfirmedBy] = useState(user?.name || '')
const [editedNames, setEditedNames] = useState<Record<number, string>>({})
const [editedPrices, setEditedPrices] = useState<Record<number, string>>({})
const { data: dishes } = useQuery<DishItem[]>({
queryKey: ['dishes-for-bulk'],
queryFn: async () => {
const res = await fetch('/api/recipes?recipe_type=dish', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed')
return res.json()
},
enabled: !!token,
})
// Check flags for selected dishes
const { data: flagChecks } = useQuery<Record<number, { ok: boolean; unassessed?: Array<{ name: string }> }>>({
queryKey: ['bulk-flag-check', Array.from(selected)],
queryFn: async () => {
const results: Record<number, { ok: boolean; unassessed?: Array<{ name: string }> }> = {}
for (const rid of selected) {
const res = await fetch(`/api/food-flags/recipes/${rid}/flags`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
results[rid] = {
ok: !data.unassessed_ingredients || data.unassessed_ingredients.length === 0,
unassessed: data.unassessed_ingredients,
}
} else {
results[rid] = { ok: false }
}
}
return results
},
enabled: !!token && step === 'confirm' && selected.size > 0,
})
const bulkMutation = useMutation({
mutationFn: async () => {
const validIds = Array.from(selected).filter(id => flagChecks?.[id]?.ok)
const items = validIds.map(id => {
const dish = dishes?.find(d => d.id === id)
return {
recipe_id: id,
display_name: editedNames[id] || dish?.name || '',
description: dish?.description || null,
price: editedPrices[id] ? parseFloat(editedPrices[id]) : (dish?.gross_sell_price || null),
}
})
const res = await fetch(`/api/menus/${menuId}/items/bulk`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
division_id: divisionId,
confirmed_by_name: confirmedBy.trim(),
items,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail?.message || 'Failed to bulk publish')
}
return res.json()
},
onSuccess: () => onPublished(),
})
const filtered = (dishes || []).filter(d =>
!search || d.name.toLowerCase().includes(search.toLowerCase())
)
const toggleSelect = (id: number) => {
const next = new Set(selected)
if (next.has(id)) next.delete(id)
else next.add(id)
setSelected(next)
}
const validCount = flagChecks ? Array.from(selected).filter(id => flagChecks[id]?.ok).length : 0
const blockedCount = flagChecks ? Array.from(selected).filter(id => flagChecks[id] && !flagChecks[id].ok).length : 0
return (
<div style={styles.overlay} onClick={onClose}>
<div style={{ ...styles.modal, maxWidth: '600px' }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 0.5rem' }}>Bulk Add Dishes to "{divisionName}"</h3>
{step === 'select' && (
<>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={styles.input}
placeholder="Search dishes..."
/>
<div style={{ maxHeight: '350px', overflow: 'auto', border: '1px solid #eee', borderRadius: '4px', marginTop: '0.5rem' }}>
{filtered.map(dish => (
<label
key={dish.id}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.5rem', cursor: 'pointer',
background: selected.has(dish.id) ? '#eff6ff' : '#fff',
borderBottom: '1px solid #f3f4f6',
}}
>
<input
type="checkbox"
checked={selected.has(dish.id)}
onChange={() => toggleSelect(dish.id)}
/>
<span style={{ flex: 1 }}>{dish.name}</span>
{dish.flag_summary?.filter(f => f.active).map((f, i) => (
<span key={i} style={styles.flagBadge}>{f.code || f.name}</span>
))}
</label>
))}
{filtered.length === 0 && <p style={{ padding: '0.5rem', color: '#999' }}>No dishes found</p>}
</div>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>{selected.size} selected</p>
<div style={styles.modalActions}>
<button onClick={onClose} style={styles.btn}>Cancel</button>
<button onClick={() => setStep('confirm')} style={styles.btnPrimary} disabled={selected.size === 0}>
Next: Review
</button>
</div>
</>
)}
{step === 'confirm' && (
<>
{!flagChecks && <p>Checking allergens...</p>}
{flagChecks && (
<>
{blockedCount > 0 && (
<div style={{ background: '#fee2e2', padding: '0.5rem', borderRadius: '6px', marginBottom: '0.75rem', fontSize: '0.875rem' }}>
<strong style={{ color: '#dc2626' }}>{blockedCount} dish{blockedCount !== 1 ? 'es' : ''} blocked</strong> — unassessed ingredients
</div>
)}
<div style={{ maxHeight: '300px', overflow: 'auto' }}>
{Array.from(selected).map(id => {
const dish = dishes?.find(d => d.id === id)
const check = flagChecks[id]
if (!dish) return null
return (
<div key={id} style={{
padding: '0.5rem', borderBottom: '1px solid #eee',
opacity: check?.ok ? 1 : 0.5,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontWeight: 500 }}>{dish.name}</span>
{!check?.ok && <span style={{ color: '#dc2626', fontSize: '0.75rem' }}>BLOCKED</span>}
</div>
{check?.ok && (
<div style={{ display: 'flex', gap: '0.25rem', marginTop: '0.25rem' }}>
<input
value={editedNames[id] ?? dish.name}
onChange={(e) => setEditedNames(prev => ({ ...prev, [id]: e.target.value }))}
style={{ ...styles.inputSm, flex: 1 }}
placeholder="Display name"
/>
<input
value={editedPrices[id] ?? (dish.gross_sell_price ? String(dish.gross_sell_price) : '')}
onChange={(e) => setEditedPrices(prev => ({ ...prev, [id]: e.target.value }))}
style={{ ...styles.inputSm, width: '80px' }}
placeholder="Price"
/>
</div>
)}
</div>
)
})}
</div>
<label style={styles.label}>Confirmed by *</label>
<input value={confirmedBy} onChange={(e) => setConfirmedBy(e.target.value)} style={styles.input} />
<div style={styles.modalActions}>
<button onClick={() => setStep('select')} style={styles.btn}>Back</button>
<button onClick={onClose} style={styles.btn}>Cancel</button>
<button
onClick={() => bulkMutation.mutate()}
style={styles.btnPrimary}
disabled={validCount === 0 || !confirmedBy.trim() || bulkMutation.isPending}
>
{bulkMutation.isPending ? 'Publishing...' : `Publish ${validCount} Dishes`}
</button>
</div>
{bulkMutation.isError && (
<p style={{ color: '#dc2626', marginTop: '0.5rem', fontSize: '0.875rem' }}>
{(bulkMutation.error as Error).message}
</p>
)}
</>
)}
</>
)}
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
modal: { background: '#fff', borderRadius: '8px', padding: '1.5rem', width: '90%', maxHeight: '90vh', overflow: 'auto' },
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '1rem' },
label: { display: 'block', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.25rem', marginTop: '0.75rem' },
input: { width: '100%', padding: '0.4rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.875rem', boxSizing: 'border-box' },
inputSm: { padding: '0.25rem 0.4rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.8rem' },
btn: { padding: '0.4rem 0.75rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
btnPrimary: { padding: '0.4rem 0.75rem', border: 'none', borderRadius: '4px', background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
flagBadge: { padding: '1px 6px', background: '#f3f4f6', borderRadius: '4px', fontSize: '0.7rem', color: '#374151' },
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,538 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
interface LineItem {
id: number
product_code: string | null
description: string | null
quantity: number | null
unit_price: number | null
amount: number | null
}
interface CreateDisputeModalProps {
invoiceId: number
invoiceNumber: string | null
supplierId: number | null
supplierName: string | null
lineItems?: LineItem[]
onClose: () => void
onSuccess?: () => void
}
interface DisputeLineItemInput {
invoice_line_item_id?: number
product_name: string
product_code?: string
quantity_ordered?: number
quantity_received?: number
unit_price_quoted?: number
unit_price_charged?: number
total_charged: number
total_expected?: number
notes?: string
}
interface CreateDisputeRequest {
invoice_id: number
dispute_type: string
priority: string
title: string
description?: string
disputed_amount: number
expected_amount?: number
line_items?: DisputeLineItemInput[]
tags?: string[]
}
export default function CreateDisputeModal({
invoiceId,
invoiceNumber,
supplierId: _supplierId,
supplierName,
lineItems = [],
onClose,
onSuccess,
}: CreateDisputeModalProps) {
const { token } = useAuth()
const queryClient = useQueryClient()
const [disputeType, setDisputeType] = useState<string>('price_discrepancy')
const [priority, setPriority] = useState<string>('medium')
const [title, setTitle] = useState<string>('')
const [description, setDescription] = useState<string>('')
const [disputedAmount, setDisputedAmount] = useState<string>('')
const [expectedAmount, setExpectedAmount] = useState<string>('')
const [selectedLineItems, setSelectedLineItems] = useState<Set<number>>(new Set())
const [lineItemSearch, setLineItemSearch] = useState<string>('')
const createMutation = useMutation({
mutationFn: async (data: CreateDisputeRequest) => {
const res = await fetch('/api/disputes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.detail || 'Failed to create dispute')
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['disputes'] })
queryClient.invalidateQueries({ queryKey: ['dispute-stats'] })
queryClient.invalidateQueries({ queryKey: ['invoice', invoiceId] })
if (onSuccess) onSuccess()
onClose()
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) {
alert('Please enter a dispute title')
return
}
if (!disputedAmount) {
alert('Please enter a disputed amount')
return
}
const data: CreateDisputeRequest = {
invoice_id: invoiceId,
dispute_type: disputeType,
priority,
title: title.trim(),
disputed_amount: parseFloat(disputedAmount),
}
if (description.trim()) data.description = description.trim()
if (expectedAmount) data.expected_amount = parseFloat(expectedAmount)
// Convert selected line items to DisputeLineItemInput format
if (selectedLineItems.size > 0) {
data.line_items = Array.from(selectedLineItems)
.map(itemId => {
const item = lineItems.find(li => li.id === itemId)
if (!item) return null
const lineItem: DisputeLineItemInput = {
invoice_line_item_id: item.id,
product_name: item.description || item.product_code || 'Unnamed item',
product_code: item.product_code || undefined,
quantity_ordered: item.quantity || undefined,
unit_price_charged: item.unit_price || undefined,
total_charged: item.amount || 0,
}
return lineItem
})
.filter((item): item is DisputeLineItemInput => item !== null)
}
createMutation.mutate(data)
}
const toggleLineItem = (lineItemId: number) => {
const newSet = new Set(selectedLineItems)
if (newSet.has(lineItemId)) {
newSet.delete(lineItemId)
} else {
newSet.add(lineItemId)
}
setSelectedLineItems(newSet)
// Auto-calculate disputed amount from selected items
const selectedItems = lineItems.filter(item => newSet.has(item.id))
const total = selectedItems.reduce((sum, item) => sum + (item.amount || 0), 0)
if (total > 0) {
setDisputedAmount(total.toFixed(2))
} else if (newSet.size === 0) {
// Clear disputed amount if no items selected
setDisputedAmount('')
}
}
// Filter line items based on search
const filteredLineItems = lineItems.filter(item => {
if (!lineItemSearch) return true
const searchLower = lineItemSearch.toLowerCase()
const description = (item.description || '').toLowerCase()
const productCode = (item.product_code || '').toLowerCase()
return description.includes(searchLower) || productCode.includes(searchLower)
})
return (
<div style={styles.modalOverlay} onClick={onClose}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<h2 style={styles.modalTitle}>Create Dispute</h2>
<button style={styles.closeBtn} onClick={onClose} type="button">
✕
</button>
</div>
<form onSubmit={handleSubmit} style={styles.form}>
<div style={styles.formRow}>
<div style={styles.formGroup}>
<label style={styles.label}>Invoice</label>
<div style={styles.staticValue}>
#{invoiceNumber || invoiceId}
{supplierName && <span style={styles.supplier}> - {supplierName}</span>}
</div>
</div>
</div>
<div style={styles.formRow}>
<div style={styles.formGroup}>
<label style={styles.label}>Dispute Type *</label>
<select
value={disputeType}
onChange={(e) => setDisputeType(e.target.value)}
style={styles.select}
required
>
<option value="price_discrepancy">Price Discrepancy</option>
<option value="short_delivery">Short Delivery</option>
<option value="wrong_product">Wrong Product</option>
<option value="quality_issue">Quality Issue</option>
<option value="calculation_error">Calculation Error</option>
<option value="missing_items">Missing Items</option>
<option value="damaged_goods">Damaged Goods</option>
<option value="other">Other</option>
</select>
</div>
<div style={styles.formGroup}>
<label style={styles.label}>Priority *</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
style={styles.select}
required
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div style={styles.formGroup}>
<label style={styles.label}>Title *</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={styles.input}
placeholder="Brief summary of the issue"
required
maxLength={200}
/>
</div>
<div style={styles.formGroup}>
<label style={styles.label}>Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
style={styles.textarea}
placeholder="Detailed description of the dispute (optional)..."
rows={4}
/>
</div>
<div style={styles.formRow}>
<div style={styles.formGroup}>
<label style={styles.label}>Disputed Amount (£) *</label>
<input
type="number"
step="0.01"
min="0"
value={disputedAmount}
onChange={(e) => setDisputedAmount(e.target.value)}
style={styles.input}
placeholder="0.00"
required
/>
<div style={styles.fieldHint}>Auto-fills when you select line items</div>
</div>
<div style={styles.formGroup}>
<label style={styles.label}>Expected Amount (£)</label>
<input
type="number"
step="0.01"
min="0"
value={expectedAmount}
onChange={(e) => setExpectedAmount(e.target.value)}
style={styles.input}
placeholder="0.00"
/>
</div>
</div>
{lineItems.length > 0 && (
<div style={styles.formGroup}>
<label style={styles.label}>Disputed Line Items (optional)</label>
<input
type="text"
value={lineItemSearch}
onChange={(e) => setLineItemSearch(e.target.value)}
style={styles.input}
placeholder="Search line items by description or code..."
/>
<div style={styles.lineItemsContainer}>
{filteredLineItems.length === 0 ? (
<div style={styles.noResults}>No items match your search</div>
) : (
filteredLineItems.map((item) => (
<label key={item.id} style={styles.checkboxLabel}>
<input
type="checkbox"
checked={selectedLineItems.has(item.id)}
onChange={() => toggleLineItem(item.id)}
style={styles.checkbox}
/>
<span style={styles.lineItemText}>
{item.description || item.product_code || 'Unnamed item'}
{item.amount != null && (
<span style={styles.lineItemAmount}> - £{Number(item.amount).toFixed(2)}</span>
)}
</span>
</label>
))
)}
</div>
{selectedLineItems.size > 0 && (
<div style={styles.selectedSummary}>
{selectedLineItems.size} item(s) selected
</div>
)}
</div>
)}
<div style={styles.footer}>
<button
type="button"
onClick={onClose}
style={styles.cancelBtn}
disabled={createMutation.isPending}
>
Cancel
</button>
<button
type="submit"
style={styles.submitBtn}
disabled={createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Create Dispute'}
</button>
</div>
{createMutation.isError && (
<div style={styles.error}>
Error: {createMutation.error?.message || 'Failed to create dispute'}
</div>
)}
</form>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
modalOverlay: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
padding: '1rem',
},
modal: {
background: 'white',
borderRadius: '12px',
maxWidth: '700px',
width: '100%',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 4px 24px rgba(0,0,0,0.2)',
},
modalHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1.5rem',
borderBottom: '1px solid #eee',
flexShrink: 0,
},
modalTitle: {
margin: 0,
color: '#1a1a2e',
},
closeBtn: {
background: 'none',
border: 'none',
fontSize: '1.5rem',
cursor: 'pointer',
color: '#666',
padding: '0.25rem',
lineHeight: 1,
},
form: {
padding: '1.5rem',
flex: 1,
overflow: 'auto',
},
formRow: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1rem',
marginBottom: '1rem',
},
formGroup: {
marginBottom: '1rem',
},
label: {
display: 'block',
marginBottom: '0.5rem',
fontWeight: 'bold',
color: '#1a1a2e',
fontSize: '0.9rem',
},
staticValue: {
padding: '0.75rem',
background: '#f5f5f5',
borderRadius: '8px',
color: '#666',
},
supplier: {
color: '#999',
fontSize: '0.9rem',
},
input: {
width: '100%',
padding: '0.75rem',
borderRadius: '8px',
border: '1px solid #ccc',
fontSize: '1rem',
boxSizing: 'border-box',
},
select: {
width: '100%',
padding: '0.75rem',
borderRadius: '8px',
border: '1px solid #ccc',
fontSize: '1rem',
boxSizing: 'border-box',
},
textarea: {
width: '100%',
padding: '0.75rem',
borderRadius: '8px',
border: '1px solid #ccc',
fontSize: '1rem',
resize: 'vertical',
boxSizing: 'border-box',
fontFamily: 'inherit',
},
lineItemsContainer: {
maxHeight: '200px',
overflow: 'auto',
border: '1px solid #eee',
borderRadius: '8px',
padding: '0.5rem',
marginTop: '0.5rem',
},
checkboxLabel: {
display: 'flex',
alignItems: 'center',
padding: '0.5rem',
cursor: 'pointer',
borderRadius: '4px',
},
checkbox: {
marginRight: '0.75rem',
cursor: 'pointer',
},
lineItemText: {
fontSize: '0.9rem',
color: '#333',
},
lineItemAmount: {
color: '#666',
fontWeight: 'bold',
},
footer: {
display: 'flex',
justifyContent: 'flex-end',
gap: '1rem',
marginTop: '1.5rem',
paddingTop: '1rem',
borderTop: '1px solid #eee',
},
cancelBtn: {
padding: '0.75rem 1.5rem',
borderRadius: '8px',
border: '1px solid #ccc',
background: 'white',
color: '#666',
cursor: 'pointer',
fontWeight: 'bold',
fontSize: '1rem',
},
submitBtn: {
padding: '0.75rem 1.5rem',
borderRadius: '8px',
border: 'none',
background: '#e94560',
color: 'white',
cursor: 'pointer',
fontWeight: 'bold',
fontSize: '1rem',
},
error: {
marginTop: '1rem',
padding: '0.75rem',
background: '#fee',
color: '#c00',
borderRadius: '8px',
fontSize: '0.9rem',
},
noResults: {
padding: '1rem',
textAlign: 'center',
color: '#999',
fontStyle: 'italic',
},
selectedSummary: {
marginTop: '0.5rem',
padding: '0.5rem',
background: '#e8f4f8',
borderRadius: '4px',
fontSize: '0.85rem',
color: '#1565c0',
fontWeight: 'bold',
},
fieldHint: {
fontSize: '0.75rem',
color: '#999',
marginTop: '0.25rem',
fontStyle: 'italic',
},
}

View file

@ -0,0 +1,893 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../App'
interface GPPeriod {
total_revenue: number
total_costs: number
gp_percentage: number
wastage_total: number | null
disputes_total: number | null
allowances_total: number | null
gp_with_allowances: number | null
}
interface DashboardData {
current_period: GPPeriod | null
previous_period: GPPeriod | null
forecast_period: GPPeriod | null
rolling_30_days: GPPeriod | null
recent_invoices: number
pending_review: number
}
interface CoversDayData {
date: string
day_label: string
total_bookings: number
total_covers: number
service_breakdown: Array<{
period: string
bookings: number
covers: number
}>
has_flagged_bookings: boolean
unique_flag_types: string[] | null
}
interface ResosCoversData {
today: CoversDayData | null
tomorrow: CoversDayData | null
day_after: CoversDayData | null
}
interface ResosSettings {
resos_flag_icon_mapping: Record<string, string> | null
}
interface ArrivalDayStats {
date: string
day_name: string
arrival_count: number
arrival_guests: number
table_bookings: number
table_covers: number
matched_arrivals: number
unmatched_arrivals: number
opportunity_guests: number
}
interface ArrivalDashboardData {
days: ArrivalDayStats[]
service_filter_name?: string | null
}
interface DisputeStats {
total_disputes: number
open_disputes: number
total_disputed_amount: number
status_counts: Record<string, { count: number; amount: number }>
recent_disputes: Array<{
id: number
invoice_id: number
invoice_number: string | null
supplier_name: string
title: string
status: string
disputed_amount: number
opened_at: string
}>
}
// Helper to format date as YYYY-MM-DD
const formatDate = (d: Date): string => {
return d.toISOString().split('T')[0]
}
// Helper to format date range for display (e.g., "Mon 27 Jan - Sun 2 Feb")
const formatDateRange = (start: Date, end: Date): string => {
const formatDay = (d: Date) => {
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
return `${dayNames[d.getDay()]} ${d.getDate()} ${monthNames[d.getMonth()]}`
}
return `${formatDay(start)} - ${formatDay(end)}`
}
// Calculate date ranges matching backend dashboard logic
const getDateRanges = () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
// Current week (Monday to today)
const dayOfWeek = today.getDay()
const daysFromMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1
const currentStart = new Date(today)
currentStart.setDate(today.getDate() - daysFromMonday)
const currentEnd = new Date(today)
// Previous week (Mon to Sun)
const prevStart = new Date(currentStart)
prevStart.setDate(currentStart.getDate() - 7)
const prevEnd = new Date(currentStart)
prevEnd.setDate(currentStart.getDate() - 1)
// Rolling 30 days (yesterday back 29 days)
const yesterday = new Date(today)
yesterday.setDate(today.getDate() - 1)
const rolling30Start = new Date(yesterday)
rolling30Start.setDate(yesterday.getDate() - 29)
return {
thisWeek: { start: currentStart, end: currentEnd },
lastWeek: { start: prevStart, end: prevEnd },
last30Days: { start: rolling30Start, end: yesterday }
}
}
export default function Dashboard() {
const { token } = useAuth()
const navigate = useNavigate()
// Calculate date ranges for GP widgets
const dateRanges = getDateRanges()
// Fetch Resos settings for flag icon mapping
const { data: resosSettings } = useQuery<ResosSettings>({
queryKey: ['resos-settings'],
queryFn: async () => {
const res = await fetch('/api/resos/settings', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch Resos settings')
return res.json()
},
enabled: !!token,
})
const { data, isLoading, error } = useQuery<DashboardData>({
queryKey: ['dashboard'],
queryFn: async () => {
const res = await fetch('/api/reports/dashboard', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch dashboard')
return res.json()
},
})
const { data: resosCovers } = useQuery<ResosCoversData>({
queryKey: ['resos-dashboard-covers'],
queryFn: async () => {
const res = await fetch('/api/resos/dashboard/today-tomorrow', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch Resos covers')
const data = await res.json()
return data
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
})
const { data: arrivalStats } = useQuery<ArrivalDashboardData>({
queryKey: ['newbook-arrival-stats'],
queryFn: async () => {
const res = await fetch('/api/newbook/dashboard/arrivals?days=3', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch arrival stats')
return res.json()
},
enabled: !!token,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
})
const { data: upcomingEvents } = useQuery<{
total_count: number
upcoming_events: Array<{
id: number
event_date: string
event_type: string
title: string
}>
}>({
queryKey: ['upcoming-events'],
queryFn: async () => {
const res = await fetch('/api/calendar-events/dashboard/upcoming', {
headers: { Authorization: `Bearer ${token}` }
})
if (!res.ok) throw new Error('Failed to fetch upcoming events')
return res.json()
},
enabled: !!token,
staleTime: 5 * 60 * 1000 // Cache for 5 minutes
})
const { data: recipeStats } = useQuery<{
total_recipes: number
dish_count: number
component_recipes: number
unmapped_ingredients: number
recipes_without_costing: number
dishes_missing_allergens: number
dishes_missing_allergens_list: Array<{ id: number; name: string }>
recipes_with_price_changes: number
stale_menu_items: number
stale_menu_names: string[]
}>({
queryKey: ['recipe-dashboard-stats'],
queryFn: async () => {
const res = await fetch('/api/recipes/dashboard-stats', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch recipe stats')
return res.json()
},
enabled: !!token,
staleTime: 5 * 60 * 1000,
})
const { data: disputeStats } = useQuery<DisputeStats>({
queryKey: ['dispute-stats'],
queryFn: async () => {
const res = await fetch('/api/disputes/stats/summary', {
headers: { Authorization: `Bearer ${token}` }
})
if (!res.ok) throw new Error('Failed to fetch dispute stats')
return res.json()
},
enabled: !!token,
staleTime: 5 * 60 * 1000 // Cache for 5 minutes
})
// Helper function to get icon for a single flag type
const getIconForFlag = (flag: string): string => {
const iconMapping = resosSettings?.resos_flag_icon_mapping || {}
// Default icons if not customized
const defaultIcons: Record<string, string> = {
'allergies': '🦀',
'large_group': '⚠️',
'note_keyword_birthday': '🎂',
'note_keyword_anniversary': '💍',
}
// First check custom mapping
if (iconMapping[flag]) {
return iconMapping[flag]
}
// Check if it's a note_keyword flag and extract the keyword
if (flag.startsWith('note_keyword_')) {
const keyword = flag.replace('note_keyword_', '')
if (iconMapping[keyword]) {
return iconMapping[keyword]
}
}
// Fall back to default icons
if (defaultIcons[flag]) {
return defaultIcons[flag]
}
return '⚠️' // Generic warning if no match found
}
// Helper function to get multiple unique icons for flag types
const getFlagIcons = (flagTypes: string[] | null): string[] => {
if (!flagTypes || flagTypes.length === 0) {
return []
}
// Map flags to icons and deduplicate
const iconSet = new Set<string>()
for (const flag of flagTypes) {
const icon = getIconForFlag(flag)
iconSet.add(icon)
}
return Array.from(iconSet)
}
// Helper to render a covers widget - clickable to open calendar for that date
const renderCoversWidget = (dayData: CoversDayData | null, title: string) => {
const handleClick = () => {
if (dayData?.date) {
navigate(`/resos?date=${dayData.date}`)
}
}
return (
<div
style={{ ...styles.card, cursor: dayData ? 'pointer' : 'default' }}
onClick={handleClick}
title={dayData ? `Click to view bookings for ${dayData.date}` : undefined}
>
<h3 style={styles.cardTitle}>
{title}
{dayData?.has_flagged_bookings && (
<span style={styles.flagIcon}>
{getFlagIcons(dayData.unique_flag_types).map((icon, idx) => (
<span key={idx}>{icon}</span>
))}
</span>
)}
</h3>
{dayData && dayData.total_covers > 0 ? (
<>
<div style={styles.coverValue}>
{dayData.total_covers} covers
</div>
<div style={styles.details}>
<span>{dayData.total_bookings} bookings</span>
{dayData.service_breakdown.map((s) => (
<span key={s.period}>
{s.period}: {s.bookings} : {s.covers}
</span>
))}
</div>
</>
) : (
<p style={styles.noData}>No booking data</p>
)}
</div>
)
}
if (isLoading) {
return <div style={styles.loading}>Loading dashboard...</div>
}
if (error) {
return <div style={styles.loading}>Error loading dashboard. Please try logging in again.</div>
}
const current = data?.current_period
const previous = data?.previous_period
return (
<div>
<h2 style={styles.title}>Dashboard</h2>
{/* ===== GP Section ===== */}
<h3 style={styles.sectionTitle}>Gross Profit</h3>
<div style={styles.fourGrid}>
<div
style={{ ...styles.card, cursor: 'pointer' }}
onClick={() => navigate(`/purchases?from=${formatDate(dateRanges.thisWeek.start)}&to=${formatDate(dateRanges.thisWeek.end)}`)}
title="Click to view flash report for this period"
>
<h3 style={styles.cardTitle}>This Week</h3>
<div style={styles.dateRange}>{formatDateRange(dateRanges.thisWeek.start, dateRanges.thisWeek.end)}</div>
{current ? (
<>
<div style={styles.gpValue}>{Number(current.gp_percentage).toFixed(1)}%</div>
{current.gp_with_allowances != null && (
<div style={styles.wastageAdjusted}>({Number(current.gp_with_allowances).toFixed(1)}% with allowances)</div>
)}
<div style={styles.details}>
<span>Revenue: £{Number(current.total_revenue).toFixed(2)}</span>
<span>Costs: £{Number(current.total_costs).toFixed(2)}</span>
</div>
</>
) : (
<p style={styles.noData}>No data for this period</p>
)}
</div>
<div style={styles.card}>
<h3 style={styles.cardTitle}>This Week Forecast</h3>
<div style={styles.dateRange}>{formatDateRange(dateRanges.thisWeek.start, dateRanges.thisWeek.end)}</div>
{data?.forecast_period ? (
<>
<div style={styles.gpValue}>{Number(data.forecast_period.gp_percentage).toFixed(1)}%</div>
<div style={styles.details}>
<span>Revenue: £{Number(data.forecast_period.total_revenue).toFixed(2)}</span>
<span>Costs: £{Number(data.forecast_period.total_costs).toFixed(2)}</span>
</div>
</>
) : (
<p style={styles.noData}>Coming soon</p>
)}
</div>
<div
style={{ ...styles.card, cursor: 'pointer' }}
onClick={() => navigate(`/purchases?from=${formatDate(dateRanges.lastWeek.start)}&to=${formatDate(dateRanges.lastWeek.end)}`)}
title="Click to view flash report for this period"
>
<h3 style={styles.cardTitle}>Last Week</h3>
<div style={styles.dateRange}>{formatDateRange(dateRanges.lastWeek.start, dateRanges.lastWeek.end)}</div>
{previous ? (
<>
<div style={styles.gpValue}>{Number(previous.gp_percentage).toFixed(1)}%</div>
{previous.gp_with_allowances != null && (
<div style={styles.wastageAdjusted}>({Number(previous.gp_with_allowances).toFixed(1)}% with allowances)</div>
)}
<div style={styles.details}>
<span>Revenue: £{Number(previous.total_revenue).toFixed(2)}</span>
<span>Costs: £{Number(previous.total_costs).toFixed(2)}</span>
</div>
</>
) : (
<p style={styles.noData}>No data for this period</p>
)}
</div>
<div
style={{ ...styles.card, cursor: 'pointer' }}
onClick={() => navigate(`/purchases?from=${formatDate(dateRanges.last30Days.start)}&to=${formatDate(dateRanges.last30Days.end)}`)}
title="Click to view flash report for this period"
>
<h3 style={styles.cardTitle}>Last 30 Days</h3>
<div style={styles.dateRange}>{formatDateRange(dateRanges.last30Days.start, dateRanges.last30Days.end)}</div>
{data?.rolling_30_days ? (
<>
<div style={styles.gpValue}>{Number(data.rolling_30_days.gp_percentage).toFixed(1)}%</div>
{data.rolling_30_days.gp_with_allowances != null && (
<div style={styles.wastageAdjusted}>({Number(data.rolling_30_days.gp_with_allowances).toFixed(1)}% with allowances)</div>
)}
<div style={styles.details}>
<span>Revenue: £{Number(data.rolling_30_days.total_revenue).toFixed(2)}</span>
<span>Costs: £{Number(data.rolling_30_days.total_costs).toFixed(2)}</span>
</div>
</>
) : (
<p style={styles.noData}>No data for this period</p>
)}
</div>
</div>
{/* ===== Documents Section ===== */}
<h3 style={styles.sectionTitle}>Documents</h3>
<div style={styles.fourGrid}>
<a href="/upload" style={{ ...styles.card, ...styles.uploadCard, ...styles.cardFlex, textDecoration: 'none' }}>
<h3 style={styles.cardTitle}>Upload New</h3>
<div style={styles.uploadIcon}>+</div>
<p style={styles.statLabel}>Upload invoice</p>
</a>
<div
style={{
...styles.card,
...styles.cardFlex,
...(data?.pending_review ? styles.alertCard : {}),
cursor: (data?.pending_review || 0) > 0 ? 'pointer' : 'default'
}}
onClick={() => (data?.pending_review || 0) > 0 && navigate('/invoices?status=pending_confirmation')}
>
<h3 style={styles.cardTitle}>Pending Confirmation</h3>
<div style={styles.statValue}>{data?.pending_review || 0}</div>
<p style={styles.statLabel}>Awaiting confirmation</p>
<div style={styles.cardLinkArea}>
{(data?.pending_review || 0) > 0 && (
<span style={styles.linkText}>Review now →</span>
)}
</div>
</div>
<div
style={{
...styles.card,
...styles.cardFlex,
...((disputeStats?.open_disputes || 0) > 0 ? styles.alertCard : {}),
cursor: (disputeStats?.open_disputes || 0) > 0 ? 'pointer' : 'default'
}}
onClick={() => (disputeStats?.open_disputes || 0) > 0 && navigate('/disputes')}
>
<h3 style={styles.cardTitle}>Disputes</h3>
<div style={{
...styles.statValue,
color: (disputeStats?.open_disputes || 0) > 0 ? '#e94560' : '#4ade80'
}}>
{disputeStats?.open_disputes || 0}
</div>
<p style={styles.statLabel}>Open disputes</p>
{disputeStats && disputeStats.status_counts && Object.keys(disputeStats.status_counts).length > 0 && (
<div style={styles.statusBreakdown}>
{Object.entries(disputeStats.status_counts)
.filter(([status]) => status !== 'RESOLVED')
.filter(([, data]) => (data as any).count > 0)
.map(([status, data]) => (
<span key={status} style={styles.statusBreakdownItem}>
{status.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}: {(data as any).count}
</span>
))}
</div>
)}
<div style={styles.cardLinkArea}>
<span style={styles.linkText}>View open →</span>
</div>
</div>
<div style={{ ...styles.card, ...styles.cardFlex }}>
<h3 style={styles.cardTitle}>Recent Invoices</h3>
<div style={styles.statValue}>{data?.recent_invoices || 0}</div>
<p style={styles.statLabel}>Uploaded this week</p>
<div style={styles.cardLinkArea}>
{(data?.recent_invoices || 0) > 0 && (
<a href={`/invoices?status=&date_from=${new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}&date_to=${new Date().toISOString().split('T')[0]}`} style={styles.linkText}>
View all →
</a>
)}
</div>
</div>
</div>
{/* ===== Recipes & Dishes Section ===== */}
{recipeStats && (recipeStats.total_recipes > 0 || recipeStats.unmapped_ingredients > 0) && (
<>
<h3 style={styles.sectionTitle}>Recipes & Dishes</h3>
<div style={styles.fourGrid}>
<div
style={{ ...styles.card, ...styles.cardFlex, cursor: 'pointer' }}
onClick={() => navigate('/recipes')}
>
<h3 style={styles.cardTitle}>Recipes</h3>
<div style={styles.statValue}>{recipeStats.component_recipes}</div>
<p style={styles.statLabel}>Component recipes</p>
<div style={styles.cardLinkArea}>
<span style={styles.linkText}>View recipes →</span>
</div>
</div>
<div
style={{ ...styles.card, ...styles.cardFlex, cursor: 'pointer' }}
onClick={() => navigate('/dishes')}
>
<h3 style={styles.cardTitle}>Dishes</h3>
<div style={styles.statValue}>{recipeStats.dish_count}</div>
<p style={styles.statLabel}>Plated dishes</p>
<div style={styles.cardLinkArea}>
<span style={styles.linkText}>View dishes →</span>
</div>
</div>
{recipeStats.dishes_missing_allergens > 0 && (
<div
style={{ ...styles.card, ...styles.cardFlex, ...styles.alertCard, cursor: 'pointer' }}
onClick={() => navigate('/allergens')}
>
<h3 style={styles.cardTitle}>Missing Allergens</h3>
<div style={{ ...styles.statValue, color: '#e94560' }}>{recipeStats.dishes_missing_allergens}</div>
<p style={styles.statLabel}>Dishes with unassessed ingredients</p>
{recipeStats.dishes_missing_allergens_list.length > 0 && (
<div style={{ fontSize: '0.75rem', color: '#94a3b8', marginTop: '0.25rem', lineHeight: 1.4 }}>
{recipeStats.dishes_missing_allergens_list.slice(0, 3).map(d => d.name).join(', ')}
{recipeStats.dishes_missing_allergens_list.length > 3 && ` +${recipeStats.dishes_missing_allergens_list.length - 3} more`}
</div>
)}
<div style={styles.cardLinkArea}>
<span style={styles.linkText}>Review allergens →</span>
</div>
</div>
)}
{recipeStats.unmapped_ingredients > 0 && (
<div
style={{ ...styles.card, ...styles.cardFlex, ...styles.alertCard, cursor: 'pointer' }}
onClick={() => navigate('/ingredients?unmapped=true')}
>
<h3 style={styles.cardTitle}>Unmapped Ingredients</h3>
<div style={{ ...styles.statValue, color: '#e94560' }}>{recipeStats.unmapped_ingredients}</div>
<p style={styles.statLabel}>Without supplier source</p>
<div style={styles.cardLinkArea}>
<span style={styles.linkText}>Map now →</span>
</div>
</div>
)}
{recipeStats.recipes_without_costing > 0 && (
<div
style={{ ...styles.card, ...styles.cardFlex, ...styles.alertCard, cursor: 'pointer' }}
onClick={() => navigate('/recipes')}
>
<h3 style={styles.cardTitle}>Without Costing</h3>
<div style={{ ...styles.statValue, color: '#f59e0b' }}>{recipeStats.recipes_without_costing}</div>
<p style={styles.statLabel}>Recipes with no cost snapshot</p>
<div style={styles.cardLinkArea}>
<span style={styles.linkText}>View recipes →</span>
</div>
</div>
)}
{recipeStats.recipes_with_price_changes > 0 && (
<div
style={{ ...styles.card, ...styles.cardFlex, cursor: 'pointer', borderLeft: '3px solid #f59e0b' }}
onClick={() => navigate('/price-impact')}
>
<h3 style={styles.cardTitle}>Price Changes</h3>
<div style={{ ...styles.statValue, color: '#f59e0b' }}>{recipeStats.recipes_with_price_changes}</div>
<p style={styles.statLabel}>Recipes affected by ingredient price changes (14 days)</p>
<div style={styles.cardLinkArea}>
<span style={styles.linkText}>View report →</span>
</div>
</div>
)}
{recipeStats.stale_menu_items > 0 && (
<div
style={{ ...styles.card, ...styles.cardFlex, ...styles.alertCard, cursor: 'pointer' }}
onClick={() => navigate('/menus')}
>
<h3 style={styles.cardTitle}>Menus Need Republishing</h3>
<div style={{ ...styles.statValue, color: '#92400e' }}>{recipeStats.stale_menu_items}</div>
<p style={styles.statLabel}>Dish{recipeStats.stale_menu_items !== 1 ? 'es' : ''} changed since last publish</p>
{recipeStats.stale_menu_names.length > 0 && (
<div style={{ fontSize: '0.75rem', color: '#94a3b8', marginTop: '0.25rem', lineHeight: 1.4 }}>
{recipeStats.stale_menu_names.join(', ')}
</div>
)}
<div style={styles.cardLinkArea}>
<span style={styles.linkText}>View menus →</span>
</div>
</div>
)}
</div>
</>
)}
{/* ===== Covers Section ===== */}
<h3 style={styles.sectionTitle}>Covers</h3>
<div style={styles.fourGrid}>
{renderCoversWidget(resosCovers?.today || null, 'Today')}
{renderCoversWidget(resosCovers?.tomorrow || null, 'Tomorrow')}
{renderCoversWidget(resosCovers?.day_after || null, resosCovers?.day_after?.day_label || 'Day After')}
<div style={styles.card}>
<h3 style={styles.cardTitle}>Upcoming Events</h3>
{upcomingEvents && upcomingEvents.total_count > 0 ? (
<>
<div style={styles.gpValue}>{upcomingEvents.total_count}</div>
<div style={styles.details}>
{upcomingEvents.upcoming_events.map((event) => (
<span key={event.id} style={{ fontSize: '0.85rem' }}>
{event.event_date}: {event.title}
</span>
))}
</div>
<a href="/resos" style={styles.link}>View Calendar →</a>
</>
) : (
<p style={styles.noData}>No upcoming events</p>
)}
</div>
</div>
{/* ===== Hotel Arrivals & Restaurant Bookings ===== */}
{arrivalStats && arrivalStats.days && arrivalStats.days.length > 0 && (
<>
<h3 style={styles.sectionTitle}>
{arrivalStats.service_filter_name
? `Hotel Arrivals & ${arrivalStats.service_filter_name} Bookings`
: 'Hotel Arrivals & Restaurant Bookings'
}
</h3>
<div style={styles.wideCard}>
{arrivalStats.service_filter_name && (
<span style={styles.serviceFilterInfo}>(showing {arrivalStats.service_filter_name} tables only)</span>
)}
<div style={styles.arrivalGrid}>
{arrivalStats.days.map((day) => (
<div key={day.date} style={styles.arrivalDay}>
<div style={styles.dayName}>{day.day_name}</div>
<div style={styles.arrivalStats}>
<div style={styles.arrivalStat}>
<div style={styles.arrivalValue}>{day.arrival_count}</div>
<div style={styles.arrivalLabel}>arrivals</div>
<div style={styles.arrivalSubtext}>{day.arrival_guests} guests</div>
</div>
<div style={styles.arrivalStat}>
<div style={styles.arrivalValue}>{day.table_bookings}</div>
<div style={styles.arrivalLabel}>table bookings</div>
<div style={styles.arrivalSubtext}>{day.table_covers} covers</div>
</div>
<div style={styles.arrivalStat}>
<div style={{ ...styles.arrivalValue, color: '#4ade80' }}>{day.matched_arrivals}</div>
<div style={styles.arrivalLabel}>have booked</div>
</div>
<div style={styles.arrivalStat}>
<div style={{ ...styles.arrivalValue, color: '#e94560' }}>{day.unmatched_arrivals}</div>
<div style={styles.arrivalLabel}>no booking yet</div>
<div style={styles.arrivalSubtext}>{day.opportunity_guests} guests</div>
</div>
</div>
</div>
))}
</div>
</div>
</>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
loading: {
padding: '2rem',
textAlign: 'center',
color: '#666',
},
title: {
marginBottom: '1.5rem',
color: '#1a1a2e',
},
sectionTitle: {
color: '#1a1a2e',
fontSize: '1rem',
fontWeight: 600,
marginBottom: '1rem',
marginTop: '0.5rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
},
fourGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '1.5rem',
marginBottom: '2rem',
},
card: {
background: 'white',
padding: '1.5rem',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
},
uploadCard: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'transform 0.2s, box-shadow 0.2s',
border: '2px dashed #e94560',
},
uploadIcon: {
fontSize: '3rem',
fontWeight: 'bold',
color: '#e94560',
marginBottom: '0.5rem',
},
alertCard: {
borderLeft: '4px solid #e94560',
},
cardTitle: {
color: '#666',
fontSize: '0.9rem',
marginBottom: '0.25rem',
textTransform: 'uppercase',
},
dateRange: {
color: '#999',
fontSize: '0.75rem',
marginBottom: '0.75rem',
},
serviceFilterInfo: {
color: '#999',
fontSize: '0.75rem',
fontWeight: 'normal',
textTransform: 'none',
marginBottom: '1rem',
display: 'block',
},
gpValue: {
fontSize: '2.5rem',
fontWeight: 'bold',
color: '#1a1a2e',
},
wastageAdjusted: {
fontSize: '0.9rem',
color: '#888',
marginTop: '-0.25rem',
marginBottom: '0.25rem',
},
statValue: {
fontSize: '2.5rem',
fontWeight: 'bold',
color: '#1a1a2e',
},
coverValue: {
fontSize: '2rem',
fontWeight: 'bold',
color: '#1a1a2e',
},
flagIcon: {
fontSize: '1.2rem',
marginLeft: '0.5rem',
},
statLabel: {
color: '#666',
marginTop: '0.5rem',
},
details: {
marginTop: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
color: '#666',
fontSize: '0.9rem',
},
noData: {
color: '#999',
fontStyle: 'italic',
},
link: {
display: 'inline-block',
marginTop: '1rem',
color: '#e94560',
textDecoration: 'none',
fontWeight: 'bold',
},
wideCard: {
background: 'white',
padding: '1.5rem',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
marginBottom: '2rem',
},
arrivalGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '1.5rem',
},
arrivalDay: {
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '1rem',
},
dayName: {
fontSize: '0.875rem',
fontWeight: 'bold',
color: '#1a1a2e',
marginBottom: '0.75rem',
textTransform: 'uppercase',
},
arrivalStats: {
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '0.75rem',
},
arrivalStat: {
textAlign: 'center',
},
arrivalValue: {
fontSize: '1.75rem',
fontWeight: 'bold',
color: '#1a1a2e',
},
arrivalLabel: {
fontSize: '0.75rem',
color: '#666',
marginTop: '0.25rem',
},
arrivalSubtext: {
fontSize: '0.75rem',
color: '#999',
marginTop: '0.125rem',
},
statusBreakdown: {
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
marginTop: '0.5rem',
marginBottom: '0.75rem',
},
statusBreakdownItem: {
fontSize: '0.75rem',
color: '#666',
},
cardFlex: {
display: 'flex',
flexDirection: 'column',
},
cardLinkArea: {
marginTop: 'auto',
paddingTop: '0.5rem',
},
linkText: {
color: '#e94560',
textDecoration: 'none',
fontSize: '0.9rem',
fontWeight: 'bold',
},
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,488 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import { useAuth } from '../App'
import DisputeDetailModal from './DisputeDetailModal'
interface Dispute {
id: number
invoice_id: number
invoice_number: string | null
supplier_id: number | null
supplier_name: string | null
dispute_type: string
status: string
priority: string
title: string
description: string | null
disputed_amount: number | null
expected_amount: number | null
difference_amount: number | null
opened_at: string
updated_at: string
opened_by_username: string | null
resolved_at: string | null
}
interface DisputeListResponse {
disputes: Dispute[]
total: number
}
const statusColors: Record<string, string> = {
new: '#e94560',
contacted: '#f0ad4e',
awaiting_credit: '#9b59b6',
awaiting_replacement: '#8b7ec8',
resolved: '#5cb85c',
}
const priorityColors: Record<string, string> = {
low: '#5cb85c',
medium: '#5bc0de',
high: '#f0ad4e',
urgent: '#e94560',
}
export default function Disputes() {
const { token } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
const urlDate = searchParams.get('date') || ''
// When navigating from purchases with a date, show all statuses for that date
const [statusFilter, setStatusFilter] = useState<string>(urlDate ? '' : 'not_resolved')
const [priorityFilter, setPriorityFilter] = useState<string>('')
const [supplierFilter, setSupplierFilter] = useState<string>('')
const [dateFilter, setDateFilter] = useState<string>(urlDate)
const [selectedDispute, setSelectedDispute] = useState<Dispute | null>(null)
// Fetch suppliers for filter dropdown
const { data: suppliersData } = useQuery<{ suppliers: Array<{ id: number; name: string }> }>({
queryKey: ['suppliers'],
queryFn: async () => {
const res = await fetch('/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json()
},
enabled: !!token,
})
// Build query params
const queryParams = new URLSearchParams()
// Handle "not_resolved" special filter - don't send to backend, filter client-side
if (statusFilter && statusFilter !== 'not_resolved') {
queryParams.append('status', statusFilter.toUpperCase())
}
if (priorityFilter) queryParams.append('priority', priorityFilter)
if (supplierFilter) queryParams.append('supplier_id', supplierFilter)
if (dateFilter) queryParams.append('opened_date', dateFilter)
const queryString = queryParams.toString()
const { data, isLoading, error } = useQuery<DisputeListResponse>({
queryKey: ['disputes', statusFilter, priorityFilter, supplierFilter, dateFilter],
queryFn: async () => {
const url = queryString ? `/api/disputes?${queryString}` : '/api/disputes'
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch disputes')
return res.json()
},
enabled: !!token,
})
// Client-side filter for "not_resolved" - show all except resolved
let disputes = data?.disputes || []
if (statusFilter === 'not_resolved') {
disputes = disputes.filter(d => d.status.toUpperCase() !== 'RESOLVED')
}
// Auto-open dispute modal if invoice_id is in URL params
useEffect(() => {
const invoiceId = searchParams.get('invoice_id')
if (invoiceId && disputes.length > 0 && !selectedDispute) {
const invoiceDisputes = disputes.filter(d => d.invoice_id === parseInt(invoiceId))
if (invoiceDisputes.length > 0) {
setSelectedDispute(invoiceDisputes[0])
// Remove the invoice_id param from URL after opening modal
setSearchParams({})
}
}
}, [disputes, searchParams, selectedDispute, setSearchParams])
if (isLoading) {
return <div style={styles.loading}>Loading disputes...</div>
}
if (error) {
return <div style={styles.error}>Error loading disputes: {error.message}</div>
}
const suppliers = suppliersData?.suppliers || []
return (
<div>
<div style={styles.header}>
<h2 style={styles.title}>Invoice Disputes</h2>
</div>
{/* Filters */}
<div style={styles.filters}>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={styles.filterSelect}
>
<option value="not_resolved">Not Resolved (Default)</option>
<option value="">All Statuses</option>
<option value="new">New</option>
<option value="contacted">Contacted</option>
<option value="awaiting_credit">Awaiting Credit</option>
<option value="awaiting_replacement">Awaiting Replacement</option>
<option value="resolved">Resolved</option>
</select>
<select
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value)}
style={styles.filterSelect}
>
<option value="">All Priorities</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<select
value={supplierFilter}
onChange={(e) => setSupplierFilter(e.target.value)}
style={styles.filterSelect}
>
<option value="">All Suppliers</option>
{suppliers.map((supplier) => (
<option key={supplier.id} value={supplier.id}>
{supplier.name}
</option>
))}
</select>
<input
type="date"
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
style={styles.filterSelect}
/>
{(statusFilter !== 'not_resolved' || priorityFilter || supplierFilter || dateFilter) && (
<button
onClick={() => {
setStatusFilter('not_resolved')
setPriorityFilter('')
setSupplierFilter('')
setDateFilter('')
}}
style={styles.clearBtn}
>
Reset Filters
</button>
)}
</div>
{disputes.length === 0 ? (
<div style={styles.empty}>
<p>No disputes found.</p>
{(statusFilter !== 'not_resolved' || priorityFilter || supplierFilter || dateFilter) ? (
<button
onClick={() => {
setStatusFilter('not_resolved')
setPriorityFilter('')
setSupplierFilter('')
setDateFilter('')
}}
style={styles.link}
>
Reset filters
</button>
) : (
<p style={styles.noDataSubtext}>
No open disputes. Great job!
</p>
)}
</div>
) : (
<div style={styles.list}>
{disputes.map((dispute) => (
<div
key={dispute.id}
onClick={() => setSelectedDispute(dispute)}
style={styles.card}
>
<div style={styles.cardMain}>
<div style={styles.disputeTitleRow}>
{dispute.supplier_name || 'Unknown'} - Invoice #{dispute.invoice_number || dispute.invoice_id}
<span style={styles.disputeType}>{dispute.dispute_type.replace(/_/g, ' ').toUpperCase()}</span>
</div>
<div style={styles.disputeDateRow}>
Opened {new Date(dispute.opened_at).toLocaleDateString()} • Last updated {new Date(dispute.updated_at).toLocaleDateString()}
</div>
<div style={styles.disputeTitle}>
{dispute.title}
</div>
{dispute.description && (
<div style={styles.description}>
{dispute.description.length > 120
? `${dispute.description.substring(0, 120)}...`
: dispute.description}
</div>
)}
</div>
<div style={styles.cardRight}>
<div
style={{
...styles.status,
background: statusColors[dispute.status.toLowerCase()] || '#999',
}}
>
{dispute.status.replace(/_/g, ' ')}
</div>
{dispute.difference_amount != null && (
<div style={styles.amount}>
£{Number(Math.abs(dispute.difference_amount)).toFixed(2)}
</div>
)}
<span
style={{
...styles.priorityBadge,
background: priorityColors[dispute.priority] || '#999',
}}
>
{dispute.priority}
</span>
</div>
</div>
))}
</div>
)}
{/* Dispute Detail Modal */}
{selectedDispute && (
<DisputeDetailModal
disputeId={selectedDispute.id}
onClose={() => setSelectedDispute(null)}
onUpdate={() => {
// Refetch disputes list after update
}}
/>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
loading: {
padding: '2rem',
textAlign: 'center',
color: '#666',
},
error: {
padding: '2rem',
textAlign: 'center',
color: '#c00',
background: '#fee',
borderRadius: '8px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
},
title: {
color: '#1a1a2e',
margin: 0,
},
filters: {
display: 'flex',
gap: '1rem',
marginBottom: '1.5rem',
flexWrap: 'wrap',
},
filterSelect: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid #ccc',
fontSize: '1rem',
minWidth: '150px',
},
clearBtn: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: 'none',
background: '#666',
color: 'white',
cursor: 'pointer',
fontWeight: 'bold',
},
empty: {
padding: '3rem',
textAlign: 'center',
color: '#666',
background: 'white',
borderRadius: '12px',
},
noDataSubtext: {
marginTop: '0.5rem',
color: '#999',
fontSize: '0.9rem',
},
link: {
display: 'inline-block',
marginTop: '1rem',
padding: '0.75rem 1.5rem',
background: 'transparent',
color: '#e94560',
border: '2px solid #e94560',
borderRadius: '8px',
textDecoration: 'none',
fontWeight: 'bold',
cursor: 'pointer',
},
list: {
display: 'flex',
flexDirection: 'column',
gap: '1rem',
},
card: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
padding: '1.5rem',
background: 'white',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
cursor: 'pointer',
textDecoration: 'none',
color: 'inherit',
transition: 'box-shadow 0.2s',
},
cardMain: {
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
},
cardRight: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: '0.5rem',
},
disputeTitleRow: {
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#1a1a2e',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
},
disputeType: {
fontSize: '0.65rem',
fontWeight: 'bold',
color: '#666',
letterSpacing: '0.5px',
},
disputeDateRow: {
fontSize: '0.75rem',
color: '#999',
marginBottom: '0.25rem',
},
disputeTitle: {
fontSize: '1rem',
fontWeight: 'bold',
color: '#1a1a2e',
},
priorityBadge: {
padding: '0.25rem 0.75rem',
borderRadius: '12px',
fontSize: '0.75rem',
fontWeight: 'bold',
color: 'white',
textTransform: 'uppercase',
flexShrink: 0,
},
description: {
color: '#666',
fontSize: '0.9rem',
},
amount: {
fontSize: '1.25rem',
fontWeight: 'bold',
color: '#e94560',
},
status: {
padding: '0.5rem 1rem',
borderRadius: '12px',
fontSize: '0.75rem',
fontWeight: 'bold',
color: 'white',
textTransform: 'uppercase',
textAlign: 'center',
minWidth: '120px',
},
modalOverlay: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
},
modal: {
background: 'white',
borderRadius: '12px',
maxWidth: '800px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
boxShadow: '0 4px 24px rgba(0,0,0,0.2)',
},
modalHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1.5rem',
borderBottom: '1px solid #eee',
},
modalTitle: {
margin: 0,
color: '#1a1a2e',
},
closeBtn: {
background: 'none',
border: 'none',
fontSize: '1.5rem',
cursor: 'pointer',
color: '#666',
padding: '0.25rem',
lineHeight: 1,
},
modalBody: {
padding: '1.5rem',
},
modalNote: {
marginTop: '2rem',
padding: '1rem',
background: '#f0f0f0',
borderRadius: '8px',
color: '#666',
fontStyle: 'italic',
},
}

View file

@ -0,0 +1,811 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../App'
interface EventOrderDetail {
id: number
name: string
event_date: string | null
notes: string | null
status: string
items: EventOrderItemDetail[]
created_at: string
updated_at: string
}
interface EventOrderItemDetail {
id: number
recipe_id: number
recipe_name: string
recipe_type: string
batch_portions: number
quantity: number
cost_per_portion: number | null
subtotal: number | null
notes: string | null
sort_order: number
}
interface ShoppingListItem {
ingredient_id: number
ingredient_name: string
category: string
total_quantity: number
adjusted_quantity: number
unit: string
yield_percent: number
sources: Array<{
supplier_name: string
product_code: string | null
pack_description: string | null
suggested_packs: number | null
cost_per_pack: number | null
subtotal: number | null
}>
recipe_breakdown: Array<{ recipe_name: string; quantity: number }>
}
interface BySupplierItem {
ingredient_name: string
quantity_needed: number
unit: string
supplier_name: string
product_code: string | null
pack_description: string | null
suggested_packs: number | null
cost_per_pack: number | null
subtotal: number | null
}
interface ShoppingListResponse {
items: ShoppingListItem[]
by_supplier: Record<string, BySupplierItem[]>
}
interface RecipeOption {
id: number
name: string
recipe_type: string
batch_portions: number
cost_per_portion: number | null
}
interface MenuOption {
id: number
name: string
description: string | null
is_active: boolean
item_count: number
division_count: number
}
interface MenuDetail {
id: number
name: string
divisions: Array<{
id: number
name: string
items: Array<{
id: number
recipe_id: number | null
display_name: string
description: string | null
is_archived: boolean
}>
}>
}
interface MenuDishEntry {
recipe_id: number
display_name: string
division_name: string
checked: boolean
quantity: number
}
export default function EventOrderEditor() {
const { id } = useParams<{ id: string }>()
const orderId = parseInt(id || '0')
const { token } = useAuth()
const navigate = useNavigate()
const queryClient = useQueryClient()
// Modal state: which modal is open
const [addModalType, setAddModalType] = useState<'recipe' | 'dish' | 'menu' | null>(null)
const [searchText, setSearchText] = useState('')
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null)
const [recipeQty, setRecipeQty] = useState('1')
// Menu modal state
const [selectedMenuId, setSelectedMenuId] = useState<number | null>(null)
const [menuDishes, setMenuDishes] = useState<MenuDishEntry[]>([])
const [covers, setCovers] = useState('1')
const [showShopping, setShowShopping] = useState(false)
const [groupBySupplier, setGroupBySupplier] = useState(false)
const { data: order } = useQuery<EventOrderDetail>({
queryKey: ['event-order', orderId],
queryFn: async () => {
const res = await fetch(`/api/event-orders/${orderId}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Not found')
return res.json()
},
enabled: !!token && !!orderId,
})
// Fetch recipes filtered by type for recipe/dish modals
const recipeType = addModalType === 'recipe' ? 'component' : addModalType === 'dish' ? 'dish' : null
const { data: recipes } = useQuery<RecipeOption[]>({
queryKey: ['recipes-for-event', recipeType],
queryFn: async () => {
const res = await fetch(`/api/recipes?recipe_type=${recipeType}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed')
return res.json()
},
enabled: !!token && (addModalType === 'recipe' || addModalType === 'dish'),
})
// Fetch menus for menu modal
const { data: menus } = useQuery<MenuOption[]>({
queryKey: ['menus-for-event'],
queryFn: async () => {
const res = await fetch('/api/menus', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed')
return res.json()
},
enabled: !!token && addModalType === 'menu' && !selectedMenuId,
})
// Fetch selected menu detail
const { data: menuDetail } = useQuery<MenuDetail>({
queryKey: ['menu-detail-for-event', selectedMenuId],
queryFn: async () => {
const res = await fetch(`/api/menus/${selectedMenuId}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed')
return res.json()
},
enabled: !!token && !!selectedMenuId,
})
const { data: shoppingList } = useQuery<ShoppingListResponse>({
queryKey: ['event-shopping-list', orderId, groupBySupplier],
queryFn: async () => {
const params = groupBySupplier ? '?group_by_supplier=true' : ''
const res = await fetch(`/api/event-orders/${orderId}/shopping-list${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
return res.json()
},
enabled: !!token && !!orderId && showShopping,
})
const addItemMutation = useMutation({
mutationFn: async (data: { recipe_id: number; quantity: number }) => {
const res = await fetch(`/api/event-orders/${orderId}/items`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-order', orderId] })
queryClient.invalidateQueries({ queryKey: ['event-shopping-list', orderId] })
closeModal()
},
})
const bulkAddMutation = useMutation({
mutationFn: async (items: Array<{ recipe_id: number; quantity: number; notes?: string }>) => {
const res = await fetch(`/api/event-orders/${orderId}/items/bulk`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
})
if (!res.ok) throw new Error('Failed')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-order', orderId] })
queryClient.invalidateQueries({ queryKey: ['event-shopping-list', orderId] })
closeModal()
},
})
const removeItemMutation = useMutation({
mutationFn: async (itemId: number) => {
const res = await fetch(`/api/event-orders/items/${itemId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-order', orderId] })
queryClient.invalidateQueries({ queryKey: ['event-shopping-list', orderId] })
},
})
const updateStatusMutation = useMutation({
mutationFn: async (status: string) => {
const res = await fetch(`/api/event-orders/${orderId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
if (!res.ok) throw new Error('Failed')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['event-order', orderId] }),
})
function closeModal() {
setAddModalType(null)
setSearchText('')
setSelectedRecipeId(null)
setRecipeQty('1')
setSelectedMenuId(null)
setMenuDishes([])
setCovers('1')
}
// When menu detail loads, build the dishes list
function handleMenuSelected(menuId: number) {
setSelectedMenuId(menuId)
setSearchText('')
}
// Populate menuDishes when menuDetail loads
const populateMenuDishes = (detail: MenuDetail) => {
const existingRecipeIds = new Set((order?.items || []).map(i => i.recipe_id))
const dishes: MenuDishEntry[] = []
for (const div of detail.divisions) {
for (const item of div.items) {
if (item.recipe_id && !item.is_archived) {
dishes.push({
recipe_id: item.recipe_id,
display_name: item.display_name,
division_name: div.name,
checked: !existingRecipeIds.has(item.recipe_id),
quantity: parseInt(covers) || 1,
})
}
}
}
setMenuDishes(dishes)
}
// Effect: when menuDetail changes, populate
if (menuDetail && menuDishes.length === 0 && selectedMenuId) {
populateMenuDishes(menuDetail)
}
function handleCoversChange(val: string) {
setCovers(val)
const num = parseInt(val) || 1
setMenuDishes(prev => prev.map(d => ({ ...d, quantity: num })))
}
function handleMenuDishQty(idx: number, val: string) {
setMenuDishes(prev => prev.map((d, i) => i === idx ? { ...d, quantity: parseInt(val) || 0 } : d))
}
function handleMenuDishToggle(idx: number) {
setMenuDishes(prev => prev.map((d, i) => i === idx ? { ...d, checked: !d.checked } : d))
}
function handleBulkAdd() {
const items = menuDishes
.filter(d => d.checked && d.quantity > 0)
.map(d => ({
recipe_id: d.recipe_id,
quantity: d.quantity,
notes: `From menu: ${menuDetail?.name || ''}`,
}))
if (items.length > 0) bulkAddMutation.mutate(items)
}
if (!order) return <div style={styles.loading}>Loading event order...</div>
const totalCost = order.items.reduce((sum, i) => sum + (i.subtotal || 0), 0)
const totalServings = order.items.reduce((sum, i) => {
if (i.recipe_type === 'dish') return sum + i.quantity
return sum + (i.quantity * i.batch_portions)
}, 0)
// Filter recipes by search
const filteredRecipes = (recipes || []).filter(r =>
!searchText || r.name.toLowerCase().includes(searchText.toLowerCase())
)
// Filter menus by search
const activeMenus = (menus || []).filter(m => m.is_active)
const filteredMenus = activeMenus.filter(m =>
!searchText || m.name.toLowerCase().includes(searchText.toLowerCase())
)
// Supplier grouped data
const bySupplier = shoppingList?.by_supplier || {}
const supplierNames = Object.keys(bySupplier).sort()
return (
<div style={styles.page}>
<div style={styles.topBar}>
<button onClick={() => navigate('/event-orders')} style={styles.backBtn}>← Back</button>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{order.status === 'DRAFT' && (
<button onClick={() => updateStatusMutation.mutate('FINALISED')} style={styles.secondaryBtn}>Finalise</button>
)}
{order.status === 'FINALISED' && (
<button onClick={() => updateStatusMutation.mutate('ORDERED')} style={styles.primaryBtn}>Mark Ordered</button>
)}
</div>
</div>
<div style={styles.headerCard}>
<div>
<h2 style={{ margin: 0 }}>{order.name}</h2>
{order.event_date && <div style={{ color: '#888', marginTop: '4px' }}>{order.event_date}</div>}
{order.notes && <div style={{ color: '#666', marginTop: '4px', fontSize: '0.85rem' }}>{order.notes}</div>}
</div>
<span style={{
background: order.status === 'DRAFT' ? '#f59e0b' : order.status === 'FINALISED' ? '#3b82f6' : '#22c55e',
color: 'white', padding: '4px 12px', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 600,
}}>
{order.status}
</span>
</div>
{/* Items */}
<div style={styles.section}>
<div style={styles.sectionHeader}>
<h3 style={{ margin: 0 }}>Items</h3>
<div style={{ display: 'flex', gap: '0.35rem' }}>
<button onClick={() => setAddModalType('recipe')} style={styles.addBtn}>+ Recipe</button>
<button onClick={() => setAddModalType('dish')} style={styles.addBtn}>+ Dish</button>
<button onClick={() => setAddModalType('menu')} style={{ ...styles.addBtn, background: '#eff6ff', borderColor: '#93c5fd' }}>+ Menu</button>
</div>
</div>
{order.items.length > 0 ? (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Recipe</th>
<th style={styles.th}>Type</th>
<th style={styles.th}>Quantity</th>
<th style={styles.th}>Cost/Portion</th>
<th style={styles.th}>Subtotal</th>
<th style={styles.th}></th>
</tr>
</thead>
<tbody>
{order.items.map(item => (
<tr key={item.id}>
<td style={styles.td}>
<span style={{ cursor: 'pointer', color: '#3b82f6' }} onClick={() => navigate(`/recipes/${item.recipe_id}`)}>
{item.recipe_name}
</span>
{item.notes && (
<span style={{ fontSize: '0.7rem', color: '#999', marginLeft: '0.5rem' }}>{item.notes}</span>
)}
</td>
<td style={styles.td}>
<span style={{ fontSize: '0.75rem', color: '#888' }}>
{item.recipe_type === 'component' ? `Component (${item.batch_portions} per batch)` : 'Dish'}
</span>
</td>
<td style={styles.td}>{item.quantity}</td>
<td style={styles.td}>{item.cost_per_portion != null ? `£${item.cost_per_portion.toFixed(2)}` : '-'}</td>
<td style={styles.td}>{item.subtotal != null ? `£${item.subtotal.toFixed(2)}` : '-'}</td>
<td style={styles.td}>
<button onClick={() => removeItemMutation.mutate(item.id)} style={styles.removeBtn}>✕</button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid #ddd' }}>
<td colSpan={4} style={{ ...styles.td, fontWeight: 600, textAlign: 'right' }}>Total:</td>
<td style={{ ...styles.td, fontWeight: 600 }}>£{totalCost.toFixed(2)}</td>
<td></td>
</tr>
</tfoot>
</table>
) : (
<div style={{ color: '#888', fontStyle: 'italic', padding: '1rem 0' }}>No items added yet</div>
)}
{order.items.length > 0 && (
<div style={{ fontSize: '0.8rem', color: '#666', marginTop: '0.5rem' }}>
~{totalServings} total servings | £{totalServings > 0 ? (totalCost / totalServings).toFixed(2) : '0.00'}/head
</div>
)}
</div>
{/* Shopping list */}
{order.items.length > 0 && (
<div style={styles.section}>
<div style={styles.sectionHeader}>
<h3 style={{ margin: 0 }}>Shopping List</h3>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<label style={{ fontSize: '0.8rem' }}>
<input type="checkbox" checked={groupBySupplier} onChange={(e) => setGroupBySupplier(e.target.checked)} />
{' '}Group by supplier
</label>
<button onClick={() => setShowShopping(!showShopping)} style={styles.addBtn}>
{showShopping ? 'Hide' : 'Show'} Shopping List
</button>
</div>
</div>
{showShopping && shoppingList && !groupBySupplier && (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Ingredient</th>
<th style={styles.th}>Category</th>
<th style={styles.th}>Quantity</th>
<th style={styles.th}>Source</th>
<th style={styles.th}>Packs</th>
<th style={styles.th}>Est. Cost</th>
</tr>
</thead>
<tbody>
{shoppingList.items.map(item => (
<tr key={item.ingredient_id}>
<td style={styles.td}>{item.ingredient_name}</td>
<td style={styles.td}><span style={{ fontSize: '0.75rem', color: '#888' }}>{item.category}</span></td>
<td style={styles.td}>{item.adjusted_quantity}{item.unit}</td>
<td style={styles.td}>
{item.sources.length > 0
? item.sources[0].supplier_name
: <span style={{ color: '#f59e0b', fontSize: '0.8rem' }}>No source</span>
}
</td>
<td style={styles.td}>
{item.sources.length > 0 && item.sources[0].suggested_packs
? `${item.sources[0].suggested_packs} × ${item.sources[0].pack_description || 'pack'}`
: '-'
}
</td>
<td style={styles.td}>
{item.sources.length > 0 && item.sources[0].subtotal != null
? `£${item.sources[0].subtotal.toFixed(2)}`
: '-'
}
</td>
</tr>
))}
</tbody>
</table>
)}
{showShopping && shoppingList && groupBySupplier && (
<div>
{supplierNames.length === 0 && (
<p style={{ color: '#888', fontStyle: 'italic', fontSize: '0.85rem' }}>No supplier data available</p>
)}
{supplierNames.map(supplier => {
const items = bySupplier[supplier]
const supplierTotal = items.reduce((sum, i) => sum + (i.subtotal || 0), 0)
return (
<div key={supplier} style={{ marginBottom: '1rem' }}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0.4rem 0.5rem', background: '#f8f9fa', borderRadius: '4px', marginBottom: '0.25rem',
}}>
<strong style={{ fontSize: '0.85rem' }}>{supplier}</strong>
{supplierTotal > 0 && (
<span style={{ fontSize: '0.8rem', color: '#666' }}>£{supplierTotal.toFixed(2)}</span>
)}
</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Ingredient</th>
<th style={styles.th}>Quantity</th>
<th style={styles.th}>Packs</th>
<th style={styles.th}>Est. Cost</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx}>
<td style={styles.td}>{item.ingredient_name}</td>
<td style={styles.td}>{item.quantity_needed}{item.unit}</td>
<td style={styles.td}>
{item.suggested_packs
? `${item.suggested_packs} × ${item.pack_description || 'pack'}`
: '-'
}
</td>
<td style={styles.td}>
{item.subtotal != null ? `£${item.subtotal.toFixed(2)}` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
})}
{/* Show unsourced ingredients */}
{shoppingList.items.filter(i => i.sources.length === 0).length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<div style={{
padding: '0.4rem 0.5rem', background: '#fef3c7', borderRadius: '4px', marginBottom: '0.25rem',
}}>
<strong style={{ fontSize: '0.85rem', color: '#92400e' }}>No Supplier</strong>
</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Ingredient</th>
<th style={styles.th}>Category</th>
<th style={styles.th}>Quantity</th>
</tr>
</thead>
<tbody>
{shoppingList.items.filter(i => i.sources.length === 0).map(item => (
<tr key={item.ingredient_id}>
<td style={styles.td}>{item.ingredient_name}</td>
<td style={styles.td}><span style={{ fontSize: '0.75rem', color: '#888' }}>{item.category}</span></td>
<td style={styles.td}>{item.adjusted_quantity}{item.unit}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)}
{/* Add Recipe / Add Dish modal */}
{(addModalType === 'recipe' || addModalType === 'dish') && (
<div style={styles.overlay} onClick={closeModal}>
<div style={styles.modal} onClick={e => e.stopPropagation()}>
<div style={styles.modalHeader}>
<h3 style={{ margin: 0 }}>Add {addModalType === 'recipe' ? 'Recipe' : 'Dish'}</h3>
<button onClick={closeModal} style={styles.closeBtn}>✕</button>
</div>
<div style={styles.modalBody}>
<input
type="text"
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder={`Search ${addModalType === 'recipe' ? 'recipes' : 'dishes'}...`}
style={styles.input}
autoFocus
/>
<div style={{ maxHeight: '250px', overflow: 'auto', border: '1px solid #eee', borderRadius: '6px', marginTop: '0.5rem' }}>
{filteredRecipes.map(r => (
<div
key={r.id}
onClick={() => setSelectedRecipeId(r.id)}
style={{
padding: '0.5rem 0.75rem',
cursor: 'pointer',
background: selectedRecipeId === r.id ? '#eff6ff' : '#fff',
borderBottom: '1px solid #f3f4f6',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}
>
<span style={{ fontWeight: selectedRecipeId === r.id ? 600 : 400, fontSize: '0.9rem' }}>
{r.name}
</span>
<span style={{ fontSize: '0.75rem', color: '#888' }}>
{addModalType === 'recipe'
? `batch: ${r.batch_portions}`
: r.cost_per_portion != null ? `£${r.cost_per_portion.toFixed(2)}/portion` : ''
}
</span>
</div>
))}
{filteredRecipes.length === 0 && (
<p style={{ padding: '0.75rem', color: '#999', textAlign: 'center', margin: 0, fontSize: '0.85rem' }}>
No {addModalType === 'recipe' ? 'recipes' : 'dishes'} found
</p>
)}
</div>
{selectedRecipeId && (
<>
<label style={styles.label}>
Quantity ({addModalType === 'recipe' ? 'batches' : 'servings'})
</label>
<input type="number" value={recipeQty} onChange={e => setRecipeQty(e.target.value)} style={styles.input} min="1" />
</>
)}
</div>
<div style={styles.modalFooter}>
<button onClick={closeModal} style={styles.cancelBtn}>Cancel</button>
<button
onClick={() => selectedRecipeId && addItemMutation.mutate({ recipe_id: selectedRecipeId, quantity: parseInt(recipeQty) || 1 })}
disabled={!selectedRecipeId || !recipeQty || addItemMutation.isPending}
style={styles.primaryBtn}
>
{addItemMutation.isPending ? 'Adding...' : 'Add'}
</button>
</div>
</div>
</div>
)}
{/* Add Menu modal */}
{addModalType === 'menu' && (
<div style={styles.overlay} onClick={closeModal}>
<div style={{ ...styles.modal, width: '600px' }} onClick={e => e.stopPropagation()}>
<div style={styles.modalHeader}>
<h3 style={{ margin: 0 }}>
{selectedMenuId ? `Add from: ${menuDetail?.name || 'Loading...'}` : 'Add Menu'}
</h3>
<button onClick={closeModal} style={styles.closeBtn}>✕</button>
</div>
<div style={styles.modalBody}>
{!selectedMenuId && (
<>
<input
type="text"
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder="Search menus..."
style={styles.input}
autoFocus
/>
<div style={{ maxHeight: '300px', overflow: 'auto', border: '1px solid #eee', borderRadius: '6px', marginTop: '0.5rem' }}>
{filteredMenus.map(m => (
<div
key={m.id}
onClick={() => handleMenuSelected(m.id)}
style={{
padding: '0.6rem 0.75rem',
cursor: 'pointer',
borderBottom: '1px solid #f3f4f6',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#f9fafb')}
onMouseLeave={e => (e.currentTarget.style.background = '#fff')}
>
<div>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>{m.name}</span>
{m.description && <div style={{ fontSize: '0.75rem', color: '#888' }}>{m.description}</div>}
</div>
<span style={{ fontSize: '0.75rem', color: '#888' }}>
{m.item_count} dish{m.item_count !== 1 ? 'es' : ''}
</span>
</div>
))}
{filteredMenus.length === 0 && (
<p style={{ padding: '0.75rem', color: '#999', textAlign: 'center', margin: 0, fontSize: '0.85rem' }}>
No active menus found
</p>
)}
</div>
</>
)}
{selectedMenuId && menuDishes.length > 0 && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
<label style={{ fontSize: '0.8rem', fontWeight: 600, color: '#555', whiteSpace: 'nowrap' }}>Covers:</label>
<input
type="number"
value={covers}
onChange={e => handleCoversChange(e.target.value)}
style={{ ...styles.input, width: '80px' }}
min="1"
/>
<span style={{ fontSize: '0.75rem', color: '#888' }}>Sets quantity for all dishes</span>
</div>
<div style={{ maxHeight: '350px', overflow: 'auto', border: '1px solid #eee', borderRadius: '6px' }}>
<table style={{ ...styles.table, fontSize: '0.85rem' }}>
<thead>
<tr>
<th style={{ ...styles.th, width: '30px' }}></th>
<th style={styles.th}>Dish</th>
<th style={styles.th}>Section</th>
<th style={{ ...styles.th, width: '80px' }}>Qty</th>
</tr>
</thead>
<tbody>
{menuDishes.map((dish, idx) => {
const alreadyOnOrder = (order?.items || []).some(i => i.recipe_id === dish.recipe_id)
return (
<tr key={idx} style={{ opacity: alreadyOnOrder ? 0.5 : 1 }}>
<td style={styles.td}>
<input
type="checkbox"
checked={dish.checked}
onChange={() => handleMenuDishToggle(idx)}
disabled={alreadyOnOrder}
/>
</td>
<td style={styles.td}>
{dish.display_name}
{alreadyOnOrder && <span style={{ fontSize: '0.7rem', color: '#f59e0b', marginLeft: '0.5rem' }}>already added</span>}
</td>
<td style={styles.td}>
<span style={{ fontSize: '0.75rem', color: '#888' }}>{dish.division_name}</span>
</td>
<td style={styles.td}>
<input
type="number"
value={dish.quantity}
onChange={e => handleMenuDishQty(idx, e.target.value)}
style={{ width: '60px', padding: '0.25rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.85rem', textAlign: 'center' }}
min="0"
disabled={!dish.checked || alreadyOnOrder}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</>
)}
{selectedMenuId && menuDishes.length === 0 && menuDetail && (
<p style={{ color: '#888', fontStyle: 'italic', fontSize: '0.85rem' }}>No dishes on this menu</p>
)}
</div>
<div style={styles.modalFooter}>
{selectedMenuId && (
<button onClick={() => { setSelectedMenuId(null); setMenuDishes([]); setSearchText('') }} style={styles.cancelBtn}>
← Back
</button>
)}
<button onClick={closeModal} style={styles.cancelBtn}>Cancel</button>
{selectedMenuId && (
<button
onClick={handleBulkAdd}
disabled={menuDishes.filter(d => d.checked && d.quantity > 0).length === 0 || bulkAddMutation.isPending}
style={styles.primaryBtn}
>
{bulkAddMutation.isPending ? 'Adding...' : `Add ${menuDishes.filter(d => d.checked && d.quantity > 0).length} Dishes`}
</button>
)}
</div>
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1100px', margin: '0 auto' },
topBar: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
backBtn: { padding: '0.5rem 1rem', background: 'white', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer' },
headerCard: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', background: 'white', padding: '1.25rem', borderRadius: '8px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', marginBottom: '1rem' },
section: { background: 'white', padding: '1rem 1.25rem', borderRadius: '8px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', marginBottom: '1rem' },
sectionHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' },
table: { width: '100%', borderCollapse: 'collapse' as const },
th: { padding: '0.5rem 0.5rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', fontSize: '0.75rem', fontWeight: 600, color: '#666' },
td: { padding: '0.4rem 0.5rem', fontSize: '0.85rem', borderBottom: '1px solid #f0f0f0' },
addBtn: { padding: '0.35rem 0.75rem', background: '#f0f0f0', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem' },
removeBtn: { padding: '2px 6px', background: 'none', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '0.7rem', color: '#e94560' },
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 },
secondaryBtn: { padding: '0.5rem 1rem', background: 'white', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer' },
cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' },
loading: { padding: '3rem', textAlign: 'center' as const, color: '#888' },
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: '450px', 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' },
modalFooter: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '1rem 1.25rem', borderTop: '1px solid #eee' },
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
label: { display: 'block', fontSize: '0.8rem', fontWeight: 600, color: '#555', marginTop: '0.75rem', marginBottom: '0.25rem' },
input: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' as const },
}

View file

@ -0,0 +1,189 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
import { useNavigate } from 'react-router-dom'
interface EventOrderItem {
id: number
name: string
event_date: string | null
notes: string | null
status: string
item_count: number
estimated_cost: number | null
created_at: string
updated_at: string
}
export default function EventOrders() {
const { token } = useAuth()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [formName, setFormName] = useState('')
const [formDate, setFormDate] = useState('')
const [formNotes, setFormNotes] = useState('')
const { data: orders, isLoading } = useQuery<EventOrderItem[]>({
queryKey: ['event-orders'],
queryFn: async () => {
const res = await fetch('/api/event-orders', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed')
return res.json()
},
enabled: !!token,
})
const createMutation = useMutation({
mutationFn: async (data: { name: string; event_date?: string; notes?: string }) => {
const res = await fetch('/api/event-orders', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to create')
return res.json()
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['event-orders'] })
setShowCreate(false)
navigate(`/event-orders/${data.id}`)
},
})
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/event-orders/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['event-orders'] }),
})
const statusColors: Record<string, string> = {
DRAFT: '#f59e0b',
FINALISED: '#3b82f6',
ORDERED: '#22c55e',
}
return (
<div style={styles.page}>
<div style={styles.header}>
<h2 style={{ margin: 0 }}>Event Orders</h2>
<button onClick={() => { setShowCreate(true); setFormName(''); setFormDate(''); setFormNotes('') }} style={styles.primaryBtn}>
+ New Event Order
</button>
</div>
{isLoading ? (
<div style={styles.loading}>Loading...</div>
) : orders && orders.length > 0 ? (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Name</th>
<th style={styles.th}>Date</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>Recipes</th>
<th style={styles.th}>Actions</th>
</tr>
</thead>
<tbody>
{orders.map(o => (
<tr key={o.id} style={styles.tr} onClick={() => navigate(`/event-orders/${o.id}`)} >
<td style={{ ...styles.td, fontWeight: 500, cursor: 'pointer' }}>{o.name}</td>
<td style={styles.td}>{o.event_date || '-'}</td>
<td style={styles.td}>
<span style={{
background: statusColors[o.status] || '#ccc',
color: 'white',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 600,
}}>
{o.status}
</span>
</td>
<td style={styles.td}>{o.item_count}</td>
<td style={styles.td}>
{o.status === 'DRAFT' && (
<button
onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(o.id) }}
style={{ ...styles.smallBtn, color: '#e94560' }}
>
Delete
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div style={{ textAlign: 'center', padding: '3rem', color: '#888' }}>
No event orders yet. Create one to start planning.
</div>
)}
{showCreate && (
<div style={styles.overlay}>
<div style={styles.modal}>
<div style={styles.modalHeader}>
<h3 style={{ margin: 0 }}>New Event Order</h3>
<button onClick={() => setShowCreate(false)} style={styles.closeBtn}>✕</button>
</div>
<div style={styles.modalBody}>
<label style={styles.label}>Event Name *</label>
<input value={formName} onChange={(e) => setFormName(e.target.value)} style={styles.input} placeholder="e.g. Wedding Reception 15th March" />
<label style={styles.label}>Event Date</label>
<input type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} style={styles.input} />
<label style={styles.label}>Notes</label>
<textarea value={formNotes} onChange={(e) => setFormNotes(e.target.value)} style={{ ...styles.input, minHeight: '60px' }} />
</div>
<div style={styles.modalFooter}>
<button onClick={() => setShowCreate(false)} style={styles.cancelBtn}>Cancel</button>
<button
onClick={() => createMutation.mutate({
name: formName,
event_date: formDate || undefined,
notes: formNotes || undefined,
})}
disabled={!formName || createMutation.isPending}
style={styles.primaryBtn}
>
Create
</button>
</div>
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1000px', margin: '0 auto' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
tr: { borderBottom: '1px solid #f0f0f0', cursor: 'pointer' },
td: { padding: '0.6rem 0.75rem', fontSize: '0.85rem' },
smallBtn: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer', fontSize: '0.75rem' },
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600 },
cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' },
loading: { padding: '3rem', textAlign: 'center' as const, color: '#888' },
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: '450px', 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' },
modalFooter: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '1rem 1.25rem', borderTop: '1px solid #eee' },
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
label: { display: 'block', fontSize: '0.8rem', fontWeight: 600, color: '#555', marginTop: '0.75rem', marginBottom: '0.25rem' },
input: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' as const },
}

View file

@ -0,0 +1,95 @@
import { CSSProperties } from 'react'
interface FlagInfo {
name?: string | null
code?: string | null
icon?: string | null
category?: string | null
propagation?: string | null
active?: boolean | null
excludable?: boolean | null
food_flag_id?: number | null
flag_name?: string | null
flag_code?: string | null
flag_icon?: string | null
category_name?: string | null
propagation_type?: string | null
is_active?: boolean | null
excludable_on_request?: boolean | null
}
interface FlagSourceTrace {
[flagName: string]: string[]
}
interface Props {
flags: FlagInfo[]
size?: 'small' | 'medium'
source_ingredients?: FlagSourceTrace
}
export default function FoodFlagBadges({ flags, size = 'small', source_ingredients }: Props) {
const activeFlags = flags.filter(f => (f.active ?? f.is_active) !== false)
if (!activeFlags.length) return null
const fontSize = size === 'small' ? '0.7rem' : '0.8rem'
const padding = size === 'small' ? '1px 5px' : '2px 8px'
return (
<div style={styles.container}>
{activeFlags.map((f, i) => {
const name = f.name || f.flag_name || ''
const code = f.code || f.flag_code || ''
const icon = f.icon || f.flag_icon || ''
const propagation = f.propagation || f.propagation_type || 'contains'
const excludable = f.excludable ?? f.excludable_on_request ?? false
const bg = propagation === 'contains' ? '#dc3545' : '#28a745'
// Build tooltip with optional source trace
const sourceList = source_ingredients?.[name]
let tooltipText = name
if (sourceList && sourceList.length > 0) {
tooltipText += ` \u2014 from: ${sourceList.join(', ')}`
}
if (excludable) {
tooltipText += ' (excludable on request)'
}
return (
<span
key={i}
title={tooltipText}
style={{
...styles.badge,
background: bg,
fontSize,
padding,
border: excludable ? '1.5px dashed rgba(255,255,255,0.6)' : 'none',
opacity: excludable ? 0.85 : 1,
}}
>
{icon ? `${icon} ` : ''}{code || name.substring(0, 3)}
</span>
)
})}
</div>
)
}
const styles: Record<string, CSSProperties> = {
container: {
display: 'flex',
flexWrap: 'wrap',
gap: '3px',
alignItems: 'center',
},
badge: {
display: 'inline-block',
borderRadius: '10px',
color: 'white',
fontWeight: 600,
whiteSpace: 'nowrap',
lineHeight: 1.4,
},
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,266 @@
import { useState, useRef, useEffect, useCallback, CSSProperties } from 'react'
interface Props {
imageFile: File
onCropped: (croppedFile: File) => void
onCancel: () => void
}
export default function ImageCropModal({ imageFile, onCropped, onCancel }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const imgRef = useRef<HTMLImageElement | null>(null)
const [imgLoaded, setImgLoaded] = useState(false)
const [drawing, setDrawing] = useState(false)
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null)
const [rect, setRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
const [rotation, setRotation] = useState(0) // 0, 90, 180, 270
// Load image
useEffect(() => {
const img = new Image()
img.onload = () => {
imgRef.current = img
setImgLoaded(true)
}
img.src = URL.createObjectURL(imageFile)
return () => URL.revokeObjectURL(img.src)
}, [imageFile])
// Recalculate canvas size when image loads or rotation changes
useEffect(() => {
const img = imgRef.current
if (!img || !imgLoaded) return
const isRotated = rotation === 90 || rotation === 270
const srcW = isRotated ? img.height : img.width
const srcH = isRotated ? img.width : img.height
const maxW = 700, maxH = 500
const scale = Math.min(maxW / srcW, maxH / srcH, 1)
setCanvasSize({ width: srcW * scale, height: srcH * scale })
setRect(null)
}, [imgLoaded, rotation])
// Draw rotated image onto canvas
const drawRotatedImage = useCallback((ctx: CanvasRenderingContext2D, img: HTMLImageElement, cw: number, ch: number) => {
ctx.save()
ctx.translate(cw / 2, ch / 2)
ctx.rotate((rotation * Math.PI) / 180)
const isRotated = rotation === 90 || rotation === 270
const dw = isRotated ? ch : cw
const dh = isRotated ? cw : ch
ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh)
ctx.restore()
}, [rotation])
// Draw canvas
const drawCanvas = useCallback(() => {
const canvas = canvasRef.current
const img = imgRef.current
if (!canvas || !img) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
drawRotatedImage(ctx, img, canvas.width, canvas.height)
if (rect) {
// Darken outside selection
ctx.fillStyle = 'rgba(0,0,0,0.4)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Clear selection area
ctx.clearRect(rect.x, rect.y, rect.w, rect.h)
drawRotatedImage(ctx, img, canvas.width, canvas.height)
// Draw border
ctx.strokeStyle = '#f59e0b'
ctx.lineWidth = 2
ctx.setLineDash([6, 3])
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.setLineDash([])
// Re-apply overlay outside
ctx.fillStyle = 'rgba(0,0,0,0.4)'
// Top
ctx.fillRect(0, 0, canvas.width, rect.y)
// Bottom
ctx.fillRect(0, rect.y + rect.h, canvas.width, canvas.height - rect.y - rect.h)
// Left
ctx.fillRect(0, rect.y, rect.x, rect.h)
// Right
ctx.fillRect(rect.x + rect.w, rect.y, canvas.width - rect.x - rect.w, rect.h)
}
}, [rect, drawRotatedImage])
useEffect(() => {
if (imgLoaded && canvasSize.width > 0) drawCanvas()
}, [imgLoaded, drawCanvas, canvasSize])
const getCanvasPos = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } => {
const canvas = canvasRef.current!
const bounds = canvas.getBoundingClientRect()
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
return {
x: clientX - bounds.left,
y: clientY - bounds.top,
}
}
const handlePointerDown = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault()
const pos = getCanvasPos(e)
setStartPos(pos)
setRect(null)
setDrawing(true)
}
const handlePointerMove = (e: React.MouseEvent | React.TouchEvent) => {
if (!drawing || !startPos) return
e.preventDefault()
const pos = getCanvasPos(e)
setRect({
x: Math.min(startPos.x, pos.x),
y: Math.min(startPos.y, pos.y),
w: Math.abs(pos.x - startPos.x),
h: Math.abs(pos.y - startPos.y),
})
}
const handlePointerUp = () => {
setDrawing(false)
}
const handleRotate = () => {
setRotation(prev => (prev + 90) % 360)
}
const handleCrop = () => {
const img = imgRef.current
if (!img || !rect || rect.w < 10 || rect.h < 10) return
const isRotated = rotation === 90 || rotation === 270
const fullW = isRotated ? img.height : img.width
const fullH = isRotated ? img.width : img.height
// Scale rect back to original (rotated) image coordinates
const scaleX = fullW / canvasSize.width
const scaleY = fullH / canvasSize.height
const sx = rect.x * scaleX
const sy = rect.y * scaleY
const sw = rect.w * scaleX
const sh = rect.h * scaleY
// First render the full rotated image, then crop from it
const fullCanvas = document.createElement('canvas')
fullCanvas.width = fullW
fullCanvas.height = fullH
const fCtx = fullCanvas.getContext('2d')!
fCtx.translate(fullW / 2, fullH / 2)
fCtx.rotate((rotation * Math.PI) / 180)
const dw = isRotated ? fullH : fullW
const dh = isRotated ? fullW : fullH
fCtx.drawImage(img, -dw / 2, -dh / 2, dw, dh)
// Now crop from the rotated full image
const cropCanvas = document.createElement('canvas')
cropCanvas.width = sw
cropCanvas.height = sh
const ctx = cropCanvas.getContext('2d')!
ctx.drawImage(fullCanvas, sx, sy, sw, sh, 0, 0, sw, sh)
cropCanvas.toBlob((blob) => {
if (blob) {
const croppedFile = new File([blob], imageFile.name, { type: 'image/jpeg' })
onCropped(croppedFile)
}
}, 'image/jpeg', 0.92)
}
const handleSkip = () => {
if (rotation === 0) {
onCropped(imageFile)
return
}
// Export full rotated image
const img = imgRef.current
if (!img) { onCropped(imageFile); return }
const isRotated = rotation === 90 || rotation === 270
const fullW = isRotated ? img.height : img.width
const fullH = isRotated ? img.width : img.height
const fullCanvas = document.createElement('canvas')
fullCanvas.width = fullW
fullCanvas.height = fullH
const fCtx = fullCanvas.getContext('2d')!
fCtx.translate(fullW / 2, fullH / 2)
fCtx.rotate((rotation * Math.PI) / 180)
const dw = isRotated ? fullH : fullW
const dh = isRotated ? fullW : fullH
fCtx.drawImage(img, -dw / 2, -dh / 2, dw, dh)
fullCanvas.toBlob((blob) => {
if (blob) {
onCropped(new File([blob], imageFile.name, { type: 'image/jpeg' }))
}
}, 'image/jpeg', 0.92)
}
return (
<div style={styles.overlay}>
<div style={styles.modal}>
<div style={styles.header}>
<h3 style={{ margin: 0, fontSize: '1rem' }}>Select Ingredients Area</h3>
<button onClick={onCancel} style={styles.closeBtn}>{'\u2715'}</button>
</div>
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#666', textAlign: 'center' as const, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.75rem' }}>
<span>Draw a rectangle around the ingredients list</span>
<button onClick={handleRotate} style={styles.rotateBtn} title="Rotate 90°">
{'\u21BB'} Rotate
</button>
</div>
<div style={{ display: 'flex', justifyContent: 'center', padding: '0.5rem', background: '#f0f0f0' }}>
{imgLoaded ? (
<canvas
ref={canvasRef}
width={canvasSize.width}
height={canvasSize.height}
style={{ cursor: 'crosshair', touchAction: 'none', borderRadius: '4px' }}
onMouseDown={handlePointerDown}
onMouseMove={handlePointerMove}
onMouseUp={handlePointerUp}
onMouseLeave={handlePointerUp}
onTouchStart={handlePointerDown}
onTouchMove={handlePointerMove}
onTouchEnd={handlePointerUp}
/>
) : (
<div style={{ padding: '2rem', color: '#888' }}>Loading image...</div>
)}
</div>
<div style={styles.footer}>
<button onClick={handleSkip} style={styles.skipBtn}>
Use Full Image
</button>
<button
onClick={handleCrop}
disabled={!rect || rect.w < 10 || rect.h < 10}
style={{
...styles.cropBtn,
opacity: rect && rect.w >= 10 && rect.h >= 10 ? 1 : 0.4,
cursor: rect && rect.w >= 10 && rect.h >= 10 ? 'pointer' : 'default',
}}
>
Crop & Scan
</button>
</div>
</div>
</div>
)
}
const styles: Record<string, CSSProperties> = {
overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1100 },
modal: { background: 'white', borderRadius: '10px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 4px 20px rgba(0,0,0,0.3)' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 1rem', borderBottom: '1px solid #eee' },
closeBtn: { background: 'none', border: 'none', fontSize: '1.1rem', cursor: 'pointer', color: '#888' },
footer: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '0.75rem 1rem', borderTop: '1px solid #eee' },
skipBtn: { padding: '0.5rem 1rem', background: '#f0f0f0', color: '#333', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem' },
cropBtn: { padding: '0.5rem 1rem', background: '#6366f1', color: 'white', border: 'none', borderRadius: '6px', fontWeight: 600, fontSize: '0.85rem' },
rotateBtn: { padding: '0.3rem 0.6rem', background: 'white', color: '#555', border: '1px solid #ccc', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', whiteSpace: 'nowrap' as const },
}

View file

@ -0,0 +1,892 @@
import { useState, useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useDebounce } from '../utils/searchHelpers'
interface FlagCategory {
id: number
name: string
propagation_type: string
required: boolean
sort_order: number
flags: Array<{
id: number
name: string
code: string | null
icon: string | null
sort_order: number
}>
}
interface IngredientFlagInfo {
food_flag_id: number
flag_name: string
flag_code: string | null
category_name: string
source: string
}
export interface AllergenSuggestion {
flag_id: number
flag_name: string
flag_code: string | null
category_name: string
matched_keywords: string[]
}
export interface DismissalInfo {
id?: number // server ID (only present after persist)
food_flag_id: number
flag_name?: string
dismissed_by_name: string
reason?: string
matched_keyword?: string
}
interface Props {
ingredientId: number | null // null = create mode (flags stored locally)
token: string
onChange?: (flagIds: number[], noneCategoryIds: number[]) => void
onDismissalsChange?: (dismissals: DismissalInfo[]) => void // for create mode batch persist
ingredientName?: string // for name-based suggestions
productIngredients?: string // for product-text-based suggestions
lineItemDescription?: string // for line-item-based suggestions
scanSuggestions?: AllergenSuggestion[] // from label OCR scan (passed from parent)
llmSuggestions?: AllergenSuggestion[] // LLM FEATURE — from AI label analysis (passed from parent)
llmAnalysing?: boolean // LLM FEATURE — show spinner while LLM is processing
autoApplyFlagIds?: number[] // flag IDs to auto-apply (e.g. from Brakes "Contains" statement)
autoApplyNoneCategoryIds?: number[] // category IDs to auto-set "None" (e.g. Brakes "Contains: None")
}
export default function IngredientFlagEditor({ ingredientId, token, onChange, onDismissalsChange, ingredientName, productIngredients, lineItemDescription, scanSuggestions, llmSuggestions, llmAnalysing, autoApplyFlagIds, autoApplyNoneCategoryIds }: Props) {
const queryClient = useQueryClient()
const [activeFlagIds, setActiveFlagIds] = useState<Set<number>>(new Set())
const [noneCategoryIds, setNoneCategoryIds] = useState<Set<number>>(new Set())
const [autoApplied, setAutoApplied] = useState<Set<number>>(new Set()) // track which were auto-applied
const [autoAppliedNones, setAutoAppliedNones] = useState<Set<number>>(new Set()) // track auto-applied none categories
const [expandedCats, setExpandedCats] = useState<Set<number>>(new Set())
const [saving, setSaving] = useState(false)
const [suggestionsExpanded, setSuggestionsExpanded] = useState(true)
// Dismissal state
const [dismissals, setDismissals] = useState<DismissalInfo[]>([])
const [dismissingFlagId, setDismissingFlagId] = useState<number | null>(null)
const [dismissName, setDismissName] = useState('')
const [dismissReason, setDismissReason] = useState('')
const [showDismissed, setShowDismissed] = useState(false)
// Debounce name, line item description, and product ingredients for suggestion queries
const debouncedName = useDebounce(ingredientName || '', 500)
const debouncedLineItem = useDebounce(lineItemDescription || '', 500)
const debouncedText = useDebounce(productIngredients || '', 500)
// Fetch all flag categories
const { data: categories } = useQuery<FlagCategory[]>({
queryKey: ['food-flag-categories'],
queryFn: async () => {
const res = await fetch('/api/food-flags/categories', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return []
return res.json()
},
enabled: !!token,
})
// Fetch current ingredient flags (edit mode only)
const { data: currentFlags } = useQuery<IngredientFlagInfo[]>({
queryKey: ['ingredient-flags', ingredientId],
queryFn: async () => {
const res = await fetch(`/api/ingredients/${ingredientId}/flags`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return []
return res.json()
},
enabled: !!token && !!ingredientId,
})
// Fetch current nones (edit mode only)
const { data: currentNones } = useQuery<{ none_category_ids: number[] }>({
queryKey: ['ingredient-flag-nones', ingredientId],
queryFn: async () => {
const res = await fetch(`/api/ingredients/${ingredientId}/flags/nones`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return { none_category_ids: [] }
return res.json()
},
enabled: !!token && !!ingredientId,
})
// Fetch existing dismissals (edit mode only)
const { data: currentDismissals } = useQuery<DismissalInfo[]>({
queryKey: ['ingredient-flag-dismissals', ingredientId],
queryFn: async () => {
const res = await fetch(`/api/ingredients/${ingredientId}/flags/dismissals`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return []
return res.json()
},
enabled: !!token && !!ingredientId,
})
// Fetch allergen suggestions based on name, line item description, and product ingredients text
const { data: nameSuggestions } = useQuery<AllergenSuggestion[]>({
queryKey: ['allergen-suggestions', debouncedName, debouncedLineItem, debouncedText],
queryFn: async () => {
const params = new URLSearchParams()
if (debouncedName) params.set('name', debouncedName)
if (debouncedLineItem) params.set('line_item', debouncedLineItem)
if (debouncedText) params.set('text', debouncedText)
const res = await fetch(`/api/food-flags/suggest?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return []
return res.json()
},
enabled: !!token && (debouncedName.length >= 2 || debouncedLineItem.length >= 2 || debouncedText.length >= 5),
})
// Sync from API data for edit mode
useEffect(() => {
if (currentFlags) {
setActiveFlagIds(new Set(currentFlags.map(f => f.food_flag_id)))
}
}, [currentFlags])
useEffect(() => {
if (currentNones) {
setNoneCategoryIds(new Set(currentNones.none_category_ids))
}
}, [currentNones])
// Sync dismissals from API for edit mode
useEffect(() => {
if (currentDismissals) {
setDismissals(currentDismissals)
}
}, [currentDismissals])
// Reset local state when ingredientId changes (e.g. modal opens for different ingredient)
useEffect(() => {
if (!ingredientId) {
setActiveFlagIds(new Set())
setNoneCategoryIds(new Set())
setExpandedCats(new Set())
setSuggestionsExpanded(true)
setDismissals([])
setDismissingFlagId(null)
setShowDismissed(false)
setAutoAppliedNones(new Set())
}
}, [ingredientId])
// Auto-expand required categories in create mode
useEffect(() => {
if (!ingredientId && categories?.length) {
const requiredIds = categories.filter(c => c.required).map(c => c.id)
if (requiredIds.length > 0) {
setExpandedCats(prev => {
const next = new Set(prev)
for (const id of requiredIds) next.add(id)
return next
})
}
}
}, [ingredientId, categories])
// Auto-apply flags from Brakes "Contains" statement
useEffect(() => {
if (!autoApplyFlagIds?.length || !categories?.length) return
const toApply = autoApplyFlagIds.filter(id => !activeFlagIds.has(id) && !autoApplied.has(id))
if (toApply.length === 0) return
const newFlags = new Set(activeFlagIds)
const newNones = new Set(noneCategoryIds)
for (const flagId of toApply) {
newFlags.add(flagId)
const cat = categories.find(c => c.flags.some(f => f.id === flagId))
if (cat) newNones.delete(cat.id)
}
setActiveFlagIds(newFlags)
setNoneCategoryIds(newNones)
setAutoApplied(prev => new Set([...prev, ...toApply]))
onChange?.([...newFlags], [...newNones])
// For edit mode, persist to API
if (ingredientId) {
fetch(`/api/ingredients/${ingredientId}/flags`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ food_flag_ids: [...newFlags] }),
}).then(() => {
queryClient.invalidateQueries({ queryKey: ['ingredient-flags', ingredientId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
}).catch(() => {})
}
}, [autoApplyFlagIds, categories])
// Auto-apply "None" to allergen categories (from Brakes "Contains: None of the 14 Food Allergens")
useEffect(() => {
if (!autoApplyNoneCategoryIds?.length || !categories?.length) return
const toApply = autoApplyNoneCategoryIds.filter(id => !noneCategoryIds.has(id) && !autoAppliedNones.has(id))
if (toApply.length === 0) return
const newNones = new Set(noneCategoryIds)
const newFlags = new Set(activeFlagIds)
for (const catId of toApply) {
// Only set "None" if no flags are active in this category
const catFlags = categories.find(c => c.id === catId)?.flags || []
const hasActiveFlags = catFlags.some(f => newFlags.has(f.id))
if (!hasActiveFlags) {
newNones.add(catId)
}
}
setNoneCategoryIds(newNones)
setAutoAppliedNones(prev => new Set([...prev, ...toApply]))
onChange?.([...newFlags], [...newNones])
// For edit mode, persist each "None" to API
if (ingredientId) {
for (const catId of toApply) {
const catFlags = categories.find(c => c.id === catId)?.flags || []
const hasActiveFlags = catFlags.some(f => newFlags.has(f.id))
if (!hasActiveFlags) {
fetch(`/api/ingredients/${ingredientId}/flags/none`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ category_id: catId }),
}).catch(() => {})
}
}
queryClient.invalidateQueries({ queryKey: ['ingredient-flag-nones', ingredientId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
}
}, [autoApplyNoneCategoryIds, categories])
// Check if two flag names conflict (e.g. "Gluten" vs "Gluten Free")
const flagNamesConflict = (containsName: string, suitableName: string): boolean => {
const base = suitableName.replace(/\s*free$/i, '').trim().toLowerCase()
const cName = containsName.toLowerCase()
if (base === cName) return true
// Plural: "Eggs" vs "Egg Free", "Nuts" vs "Nut Free"
if (base + 's' === cName || base === cName + 's') return true
if (cName.endsWith('s') && base === cName.slice(0, -1)) return true
return false
}
const toggleFlag = async (flagId: number, categoryId: number) => {
const newFlags = new Set(activeFlagIds)
const newNones = new Set(noneCategoryIds)
if (newFlags.has(flagId)) {
newFlags.delete(flagId)
} else {
// Find the flag and its category
const thisCat = categories?.find(c => c.id === categoryId)
const thisFlag = thisCat?.flags.find(f => f.id === flagId)
if (thisCat && thisFlag && categories) {
// If enabling a "suitable_for" flag, check for conflicting active "contains" flags
if (thisCat.propagation_type === 'suitable_for') {
for (const cat of categories) {
if (cat.propagation_type !== 'contains') continue
for (const f of cat.flags) {
if (newFlags.has(f.id) && flagNamesConflict(f.name, thisFlag.name)) {
alert(`Cannot set "${thisFlag.name}" — this ingredient contains ${f.name}. Remove the ${f.name} flag first.`)
return
}
}
}
}
// If enabling a "contains" flag, auto-remove conflicting "suitable_for" flags
if (thisCat.propagation_type === 'contains') {
for (const cat of categories) {
if (cat.propagation_type !== 'suitable_for') continue
for (const f of cat.flags) {
if (newFlags.has(f.id) && flagNamesConflict(thisFlag.name, f.name)) {
newFlags.delete(f.id)
}
}
}
}
}
newFlags.add(flagId)
newNones.delete(categoryId)
}
setActiveFlagIds(newFlags)
setNoneCategoryIds(newNones)
onChange?.([...newFlags], [...newNones])
if (ingredientId) {
setSaving(true)
try {
await fetch(`/api/ingredients/${ingredientId}/flags`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ food_flag_ids: [...newFlags] }),
})
queryClient.invalidateQueries({ queryKey: ['ingredient-flags', ingredientId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
} catch { /* ignore */ }
setSaving(false)
}
}
const toggleNone = async (categoryId: number) => {
const newNones = new Set(noneCategoryIds)
const newFlags = new Set(activeFlagIds)
if (newNones.has(categoryId)) {
newNones.delete(categoryId)
} else {
newNones.add(categoryId)
const catFlags = categories?.find(c => c.id === categoryId)?.flags || []
catFlags.forEach(f => newFlags.delete(f.id))
}
setNoneCategoryIds(newNones)
setActiveFlagIds(newFlags)
onChange?.([...newFlags], [...newNones])
if (ingredientId) {
setSaving(true)
try {
await fetch(`/api/ingredients/${ingredientId}/flags/none`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ category_id: categoryId }),
})
queryClient.invalidateQueries({ queryKey: ['ingredient-flags', ingredientId] })
queryClient.invalidateQueries({ queryKey: ['ingredient-flag-nones', ingredientId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
} catch { /* ignore */ }
setSaving(false)
}
}
// Apply a single suggestion (add the flag)
const applySuggestion = (suggestion: AllergenSuggestion) => {
// Find which category this flag belongs to
const cat = categories?.find(c => c.flags.some(f => f.id === suggestion.flag_id))
if (cat) {
toggleFlag(suggestion.flag_id, cat.id)
}
}
// Dismiss a suggestion
const confirmDismissal = async (suggestion: AllergenSuggestion) => {
if (!dismissName.trim()) return
const dismissal: DismissalInfo = {
food_flag_id: suggestion.flag_id,
flag_name: suggestion.flag_name,
dismissed_by_name: dismissName.trim(),
reason: dismissReason.trim() || undefined,
matched_keyword: suggestion.matched_keywords.join(', '),
}
// Edit mode: persist immediately
if (ingredientId) {
try {
const res = await fetch(`/api/ingredients/${ingredientId}/flags/dismissals`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(dismissal),
})
if (res.ok) {
const saved = await res.json()
dismissal.id = saved.id
}
} catch { /* ignore */ }
}
const updated = [...dismissals, dismissal]
setDismissals(updated)
setDismissingFlagId(null)
setDismissReason('')
// Keep dismissName for next dismiss in same session
onDismissalsChange?.(updated)
}
// Undo a dismissal
const undoDismissal = async (flagId: number) => {
const dismissal = dismissals.find(d => d.food_flag_id === flagId)
if (!dismissal) return
// Edit mode: delete from API
if (ingredientId && dismissal.id) {
try {
await fetch(`/api/ingredients/${ingredientId}/flags/dismissals/${dismissal.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
} catch { /* ignore */ }
}
const updated = dismissals.filter(d => d.food_flag_id !== flagId)
setDismissals(updated)
onDismissalsChange?.(updated)
}
// Apply all pending suggestions at once
const applyAllSuggestions = async () => {
const newFlags = new Set(activeFlagIds)
const newNones = new Set(noneCategoryIds)
for (const s of pendingSuggestions) {
newFlags.add(s.flag_id)
// Clear "none" for the category if we're adding a flag
const cat = categories?.find(c => c.flags.some(f => f.id === s.flag_id))
if (cat) newNones.delete(cat.id)
}
setActiveFlagIds(newFlags)
setNoneCategoryIds(newNones)
onChange?.([...newFlags], [...newNones])
if (ingredientId) {
setSaving(true)
try {
await fetch(`/api/ingredients/${ingredientId}/flags`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ food_flag_ids: [...newFlags] }),
})
queryClient.invalidateQueries({ queryKey: ['ingredient-flags', ingredientId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
} catch { /* ignore */ }
setSaving(false)
}
}
if (!categories?.length) return null
// Merge name-based and scan-based suggestions, deduplicate by flag_id
const allSuggestions: AllergenSuggestion[] = []
const seenFlagIds = new Set<number>()
for (const s of (nameSuggestions || [])) {
if (!seenFlagIds.has(s.flag_id)) {
seenFlagIds.add(s.flag_id)
allSuggestions.push(s)
}
}
for (const s of (scanSuggestions || [])) {
if (!seenFlagIds.has(s.flag_id)) {
seenFlagIds.add(s.flag_id)
// Use the source field if available (e.g. Brakes: "contains", "dietary", "keyword"),
// otherwise fall back to "label" for OCR label scan results
const src = 'source' in s ? (s as unknown as { source: string }).source : ''
const sourceLabel = src ? ({ contains: 'product data', dietary: 'product data', keyword: 'product data' }[src] || 'label') : 'label'
allSuggestions.push({ ...s, matched_keywords: s.matched_keywords.map(k => `${k} (${sourceLabel})`) })
}
}
// LLM FEATURE — see LLM-MANIFEST.md for removal instructions
for (const s of (llmSuggestions || [])) {
if (!seenFlagIds.has(s.flag_id)) {
seenFlagIds.add(s.flag_id)
allSuggestions.push({ ...s, matched_keywords: s.matched_keywords.map(k => `\u2728 ${k}`) })
}
}
// Filter out suggestions for flags already active or dismissed
const dismissedFlagIds = new Set(dismissals.map(d => d.food_flag_id))
const pendingSuggestions = allSuggestions.filter(s => !activeFlagIds.has(s.flag_id) && !dismissedFlagIds.has(s.flag_id))
// Build badge list from active flags
const activeFlags = categories.flatMap(c =>
c.flags.filter(f => activeFlagIds.has(f.id)).map(f => ({
name: f.name,
code: f.code,
icon: f.icon,
propagation_type: c.propagation_type,
}))
)
return (
<div style={{ borderTop: '1px solid #eee', padding: '0.75rem 1.25rem' }}>
{/* Current flag badges */}
{activeFlags.length > 0 && (
<div style={{ display: 'flex', gap: '3px', flexWrap: 'wrap', marginBottom: '0.5rem' }}>
{activeFlags.map(f => (
<span
key={f.name}
style={{
background: f.propagation_type === 'contains' ? '#dc3545' : '#28a745',
color: 'white',
padding: '1px 5px',
borderRadius: '10px',
fontSize: '0.7rem',
fontWeight: 600,
}}
title={f.name}
>
{f.icon ? `${f.icon} ` : ''}{f.code || f.name.substring(0, 3)}
</span>
))}
</div>
)}
{/* LLM FEATURE — AI analysing spinner when no other suggestions yet */}
{pendingSuggestions.length === 0 && llmAnalysing && (
<div style={{ padding: '0.3rem 0.5rem', fontSize: '0.73rem', color: '#7c3aed' }}>
{'\u2728'} AI analysing ingredients...
</div>
)}
{/* Allergen suggestions bar */}
{pendingSuggestions.length > 0 && (
<div style={{ marginBottom: '0.5rem' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.3rem 0.5rem',
background: '#fffbeb',
border: '1px solid #f59e0b',
borderRadius: suggestionsExpanded ? '6px 6px 0 0' : '6px',
cursor: 'pointer',
}}
onClick={() => setSuggestionsExpanded(!suggestionsExpanded)}
>
<span style={{ fontSize: '0.73rem', color: '#b45309', fontWeight: 600 }}>
{'\u26A0'} {pendingSuggestions.length} allergen suggestion{pendingSuggestions.length !== 1 ? 's' : ''}
{/* LLM FEATURE — see LLM-MANIFEST.md */}
{llmAnalysing && <span style={{ marginLeft: '0.4rem', fontSize: '0.68rem', color: '#7c3aed' }}>\u2728 AI analysing...</span>}
</span>
<div style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
<button
onClick={(e) => { e.stopPropagation(); applyAllSuggestions() }}
disabled={saving}
style={{
padding: '0.15rem 0.4rem',
border: '1px solid #f59e0b',
borderRadius: '4px',
background: '#f59e0b',
color: 'white',
cursor: saving ? 'default' : 'pointer',
fontSize: '0.68rem',
fontWeight: 600,
}}
>
Apply All
</button>
<span style={{ fontSize: '0.7rem', color: '#b45309' }}>
{suggestionsExpanded ? '\u25B2' : '\u25BC'}
</span>
</div>
</div>
{suggestionsExpanded && (
<div style={{
padding: '0.4rem 0.5rem',
background: '#fffef5',
border: '1px solid #f59e0b',
borderTop: 'none',
borderRadius: '0 0 6px 6px',
display: 'flex',
flexDirection: 'column',
gap: '0.3rem',
}}>
{pendingSuggestions.map(s => (
<div key={s.flag_id}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.73rem', color: '#555', flex: 1 }}>
<strong>{s.flag_name}</strong>
{s.flag_code && <span style={{ color: '#888' }}> ({s.flag_code})</span>}
<span style={{ color: '#999', marginLeft: '0.3rem', fontSize: '0.68rem' }}>
matched: {s.matched_keywords.join(', ')}
</span>
</span>
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button
onClick={() => {
setDismissingFlagId(s.flag_id)
setDismissReason('')
}}
disabled={saving}
style={{
padding: '0.1rem 0.35rem',
border: '1px solid #dc3545',
borderRadius: '4px',
background: 'white',
cursor: saving ? 'default' : 'pointer',
fontSize: '0.68rem',
color: '#dc3545',
}}
>
Dismiss
</button>
<button
onClick={() => applySuggestion(s)}
disabled={saving}
style={{
padding: '0.1rem 0.35rem',
border: '1px solid #ccc',
borderRadius: '4px',
background: 'white',
cursor: saving ? 'default' : 'pointer',
fontSize: '0.68rem',
color: '#555',
}}
>
+ Apply
</button>
</div>
</div>
{/* Inline dismiss form */}
{dismissingFlagId === s.flag_id && (
<div style={{
marginTop: '0.25rem',
padding: '0.35rem 0.5rem',
background: '#fff5f5',
border: '1px solid #fca5a5',
borderRadius: '4px',
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
}}>
<div style={{ display: 'flex', gap: '0.3rem', alignItems: 'center' }}>
<input
type="text"
placeholder="Your name (required)"
value={dismissName}
onChange={e => setDismissName(e.target.value)}
style={{
flex: 1,
padding: '0.2rem 0.4rem',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '0.73rem',
}}
autoFocus
/>
</div>
<div style={{ display: 'flex', gap: '0.3rem', alignItems: 'center' }}>
<input
type="text"
placeholder="Reason (optional)"
value={dismissReason}
onChange={e => setDismissReason(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && dismissName.trim()) confirmDismissal(s) }}
style={{
flex: 1,
padding: '0.2rem 0.4rem',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '0.73rem',
}}
/>
<button
onClick={() => confirmDismissal(s)}
disabled={!dismissName.trim()}
style={{
padding: '0.15rem 0.4rem',
border: '1px solid #dc3545',
borderRadius: '4px',
background: dismissName.trim() ? '#dc3545' : '#f0f0f0',
color: dismissName.trim() ? 'white' : '#999',
cursor: dismissName.trim() ? 'pointer' : 'default',
fontSize: '0.68rem',
fontWeight: 600,
}}
>
Confirm
</button>
<button
onClick={() => setDismissingFlagId(null)}
style={{
padding: '0.15rem 0.4rem',
border: '1px solid #ccc',
borderRadius: '4px',
background: 'white',
cursor: 'pointer',
fontSize: '0.68rem',
color: '#666',
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Dismissed suggestions */}
{dismissals.length > 0 && (
<div style={{ marginBottom: '0.5rem' }}>
<div
onClick={() => setShowDismissed(!showDismissed)}
style={{
padding: '0.2rem 0.5rem',
background: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: showDismissed ? '6px 6px 0 0' : '6px',
cursor: 'pointer',
fontSize: '0.68rem',
color: '#888',
}}
>
{dismissals.length} dismissed {showDismissed ? '\u25B2' : '\u25BC'}
</div>
{showDismissed && (
<div style={{
padding: '0.3rem 0.5rem',
background: '#f9fafb',
border: '1px solid #e5e7eb',
borderTop: 'none',
borderRadius: '0 0 6px 6px',
display: 'flex',
flexDirection: 'column',
gap: '0.2rem',
}}>
{dismissals.map(d => (
<div key={d.food_flag_id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.7rem', color: '#999', textDecoration: 'line-through' }}>
{d.flag_name || `Flag #${d.food_flag_id}`}
{d.dismissed_by_name && (
<span style={{ fontStyle: 'italic', marginLeft: '0.3rem' }}>
by {d.dismissed_by_name}
</span>
)}
{d.reason && (
<span style={{ marginLeft: '0.3rem' }}>
— {d.reason}
</span>
)}
</span>
<button
onClick={() => undoDismissal(d.food_flag_id)}
style={{
padding: '0.05rem 0.3rem',
border: '1px solid #ccc',
borderRadius: '4px',
background: 'white',
cursor: 'pointer',
fontSize: '0.65rem',
color: '#666',
}}
>
Undo
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Category buttons */}
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap', marginBottom: expandedCats.size > 0 ? '0.5rem' : 0 }}>
{categories.map(cat => {
const catFlagCount = cat.flags.filter(f => activeFlagIds.has(f.id)).length
const isNone = noneCategoryIds.has(cat.id)
const isExpanded = expandedCats.has(cat.id)
const isAssessed = catFlagCount > 0 || isNone
return (
<button
key={cat.id}
onClick={() => setExpandedCats(prev => {
const next = new Set(prev)
if (next.has(cat.id)) next.delete(cat.id)
else next.add(cat.id)
return next
})}
style={{
padding: '0.25rem 0.5rem',
border: `1px solid ${isExpanded ? '#555' : isAssessed ? '#ccc' : cat.required ? '#f59e0b' : '#ddd'}`,
borderRadius: '4px',
background: isExpanded ? '#f0f0f0' : 'white',
cursor: 'pointer',
fontSize: '0.75rem',
fontWeight: isExpanded ? 600 : 400,
color: isAssessed ? '#333' : cat.required ? '#b45309' : '#888',
}}
>
{cat.name}
{catFlagCount > 0 && <span style={{ marginLeft: '0.25rem', color: cat.propagation_type === 'contains' ? '#dc3545' : '#28a745', fontWeight: 600 }}>({catFlagCount})</span>}
{isNone && <span style={{ marginLeft: '0.25rem', color: '#888' }}>({'\u2014'})</span>}
{!isAssessed && cat.required && <span style={{ marginLeft: '0.25rem', color: '#f59e0b' }}>?</span>}
</button>
)
})}
</div>
{/* Expanded categories: flag toggles */}
{categories.filter(c => expandedCats.has(c.id)).map(cat => {
const isNone = noneCategoryIds.has(cat.id)
return (
<div key={cat.id} style={{
display: 'flex',
gap: '0.35rem',
flexWrap: 'wrap',
padding: '0.5rem',
background: '#fafafa',
borderRadius: '6px',
border: '1px solid #e0e0e0',
alignItems: 'center',
opacity: saving ? 0.6 : 1,
marginBottom: '0.35rem',
}}>
<span style={{ fontSize: '0.7rem', color: '#888', fontWeight: 600, marginRight: '0.25rem' }}>
{cat.name} ({cat.propagation_type}):
</span>
{cat.flags.map(flag => {
const isActive = activeFlagIds.has(flag.id)
const color = cat.propagation_type === 'contains' ? '#dc3545' : '#28a745'
return (
<button
key={flag.id}
onClick={() => toggleFlag(flag.id, cat.id)}
disabled={saving || isNone}
title={flag.name}
style={{
padding: '0.2rem 0.5rem',
border: `1.5px solid ${isActive ? color : '#ccc'}`,
borderRadius: '12px',
background: isActive ? color : 'white',
color: isActive ? 'white' : isNone ? '#ccc' : '#555',
cursor: saving || isNone ? 'default' : 'pointer',
fontSize: '0.73rem',
fontWeight: isActive ? 600 : 400,
}}
>
{flag.icon ? `${flag.icon} ` : ''}{flag.code || flag.name}
</button>
)
})}
<button
onClick={() => toggleNone(cat.id)}
disabled={saving}
style={{
padding: '0.2rem 0.5rem',
border: `1.5px dashed ${isNone ? '#888' : '#ccc'}`,
borderRadius: '12px',
background: isNone ? '#888' : 'white',
color: isNone ? 'white' : '#888',
cursor: saving ? 'default' : 'pointer',
fontSize: '0.73rem',
fontWeight: isNone ? 600 : 400,
marginLeft: '0.25rem',
}}
title="None of these apply"
>
None
</button>
</div>
)
})}
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,396 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
import FoodFlagBadges from './FoodFlagBadges'
import IngredientModal from './IngredientModal'
import { IngredientCategory, EditingIngredient } from '../utils/ingredientHelpers'
interface FlagInfo {
id: number
food_flag_id: number
flag_name: string
flag_code: string | null
category_name: string
propagation_type: string
source: string
}
interface IngredientItem {
id: number
name: string
category_id: number | null
category_name: string | null
standard_unit: string
yield_percent: number
manual_price: number | null
notes: string | null
is_archived: boolean
is_prepackaged: boolean
is_free: boolean
product_ingredients: string | null
has_label_image: boolean
source_count: number
effective_price: number | null
flags: FlagInfo[]
none_categories: string[]
created_at: string
}
interface SourceItem {
id: number
supplier_id: number
supplier_name: string
product_code: string | null
description_pattern: string | null
pack_quantity: number | null
unit_size: number | null
unit_size_type: string | null
latest_unit_price: number | null
latest_invoice_date: string | null
price_per_std_unit: number | null
}
export default function Ingredients() {
const { token } = useAuth()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('')
const [showUnmapped, setShowUnmapped] = useState(false)
const [showArchived, setShowArchived] = useState(false)
const [expandedId, setExpandedId] = useState<number | null>(null)
const [showModal, setShowModal] = useState(false)
const [editingIngredient, setEditingIngredient] = useState<EditingIngredient | null>(null)
// Categories (for filter dropdown)
const { data: categories } = useQuery<IngredientCategory[]>({
queryKey: ['ingredient-categories'],
queryFn: async () => {
const res = await fetch('/api/ingredients/categories', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch categories')
return res.json()
},
enabled: !!token,
})
// Ingredients list
const { data: ingredients, isLoading } = useQuery<IngredientItem[]>({
queryKey: ['ingredients', search, categoryFilter, showUnmapped, showArchived],
queryFn: async () => {
const params = new URLSearchParams()
if (search) params.set('search', search)
if (categoryFilter) params.set('category_id', categoryFilter)
if (showUnmapped) params.set('unmapped', 'true')
if (showArchived) params.set('archived', 'true')
const res = await fetch(`/api/ingredients?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch ingredients')
return res.json()
},
enabled: !!token,
})
// Sources for expanded ingredient
const { data: sources } = useQuery<SourceItem[]>({
queryKey: ['ingredient-sources', expandedId],
queryFn: async () => {
const res = await fetch(`/api/ingredients/${expandedId}/sources`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch sources')
return res.json()
},
enabled: !!token && !!expandedId,
})
const archiveMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/ingredients/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to archive')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }),
})
const unarchiveMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/ingredients/${id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ is_archived: false }),
})
if (!res.ok) throw new Error('Failed to unarchive')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ingredients'] }),
})
const startEdit = (ing: IngredientItem) => {
setEditingIngredient({
id: ing.id,
name: ing.name,
category_id: ing.category_id,
standard_unit: ing.standard_unit,
yield_percent: ing.yield_percent,
manual_price: ing.manual_price,
notes: ing.notes,
is_prepackaged: ing.is_prepackaged,
is_free: ing.is_free,
product_ingredients: ing.product_ingredients,
has_label_image: ing.has_label_image,
})
setShowModal(true)
}
return (
<div style={styles.page}>
<div style={styles.header}>
<h2 style={{ margin: 0 }}>Ingredient Library</h2>
<button onClick={() => { setEditingIngredient(null); setShowModal(true) }} style={styles.primaryBtn}>
+ Create Ingredient
</button>
</div>
{/* Filters */}
<div style={styles.filterBar}>
<input
type="text"
placeholder="Search ingredients..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={styles.searchInput}
/>
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} style={styles.select}>
<option value="">All Categories</option>
{categories?.map(c => (
<option key={c.id} value={c.id}>{c.name} ({c.ingredient_count})</option>
))}
</select>
<label style={styles.checkLabel}>
<input
type="checkbox"
checked={showUnmapped}
onChange={(e) => setShowUnmapped(e.target.checked)}
/>
Unmapped only
</label>
<label style={styles.checkLabel}>
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
/>
Show Archived
</label>
</div>
{/* Stats */}
<div style={styles.statsBar}>
<span>{ingredients?.length || 0} {showArchived ? 'archived' : ''} ingredients</span>
<span style={{ color: '#888' }}>|</span>
<span>{ingredients?.filter(i => i.source_count === 0).length || 0} unmapped</span>
</div>
{/* Table */}
{isLoading ? (
<div style={styles.loading}>Loading ingredients...</div>
) : (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Name</th>
<th style={styles.th}>Category</th>
<th style={styles.th}>Unit</th>
<th style={styles.th}>Yield %</th>
<th style={styles.th}>Sources</th>
<th style={styles.th}>Price/Unit</th>
<th style={styles.th}>Flags</th>
<th style={styles.th}>Actions</th>
</tr>
</thead>
<tbody>
{ingredients?.map(ing => (
<>
<tr
key={ing.id}
style={{ ...styles.tr, cursor: 'pointer', ...(ing.is_archived ? { opacity: 0.5 } : {}) }}
onClick={() => setExpandedId(expandedId === ing.id ? null : ing.id)}
>
<td style={styles.td}>
<span style={{ fontWeight: 500, ...(ing.is_archived ? { textDecoration: 'line-through' } : {}) }}>{ing.name}</span>
{ing.is_archived && <span style={{ marginLeft: '0.5rem', fontSize: '0.7rem', color: '#999', fontStyle: 'italic' }}>archived</span>}
</td>
<td style={styles.td}>{ing.category_name || '-'}</td>
<td style={styles.td}>{ing.standard_unit}</td>
<td style={styles.td}>
<span style={{ color: ing.yield_percent < 100 ? '#e94560' : '#666' }}>
{ing.yield_percent}%
</span>
</td>
<td style={styles.td}>
<span style={{
background: ing.source_count > 0 ? '#22c55e' : '#f59e0b',
color: 'white',
padding: '2px 8px',
borderRadius: '10px',
fontSize: '0.75rem',
fontWeight: 600,
}}>
{ing.source_count}
</span>
</td>
<td style={styles.td}>
{ing.effective_price != null
? (() => {
const p = ing.effective_price!
const u = ing.standard_unit
if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg`
if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr`
return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}`
})()
: <span style={{ color: '#aaa' }}>-</span>
}
</td>
<td style={styles.td}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', alignItems: 'center' }}>
<FoodFlagBadges flags={ing.flags.map(f => ({
name: f.flag_name,
code: f.flag_code || undefined,
category_name: f.category_name,
propagation: f.propagation_type,
}))} />
{ing.none_categories?.map(cat => (
<span
key={cat}
title={`${cat}: None apply`}
style={{
display: 'inline-block',
borderRadius: '10px',
color: 'white',
fontWeight: 600,
whiteSpace: 'nowrap',
lineHeight: 1.4,
fontSize: '0.7rem',
padding: '1px 5px',
background: '#999',
}}
>
{cat.substring(0, 3)}: None
</span>
))}
</div>
</td>
<td style={styles.td}>
<button onClick={(e) => { e.stopPropagation(); startEdit(ing) }} style={styles.smallBtn}>Edit</button>
{ing.is_archived ? (
<button onClick={(e) => { e.stopPropagation(); unarchiveMutation.mutate(ing.id) }} style={{ ...styles.smallBtn, color: '#22c55e' }}>Unarchive</button>
) : (
<button onClick={(e) => { e.stopPropagation(); archiveMutation.mutate(ing.id) }} style={{ ...styles.smallBtn, color: '#e94560' }}>Archive</button>
)}
</td>
</tr>
{expandedId === ing.id && (
<tr key={`${ing.id}-expanded`}>
<td colSpan={8} style={styles.expandedTd}>
<div style={styles.sourcesSection}>
<h4 style={{ margin: '0 0 8px 0' }}>Supplier Sources</h4>
{sources && sources.length > 0 ? (
<table style={{ ...styles.table, margin: 0 }}>
<thead>
<tr>
<th style={styles.thSmall}>Supplier</th>
<th style={styles.thSmall}>Code/Pattern</th>
<th style={styles.thSmall}>Pack</th>
<th style={styles.thSmall}>Last Price</th>
<th style={styles.thSmall}>Price/{ing.standard_unit}</th>
<th style={styles.thSmall}>Last Invoice</th>
</tr>
</thead>
<tbody>
{sources.map(s => (
<tr key={s.id}>
<td style={styles.tdSmall}>{s.supplier_name}</td>
<td style={styles.tdSmall}>
{s.product_code && s.supplier_name?.toLowerCase().includes('brakes') ? (
<a
href={`https://www.brake.co.uk/p/${s.product_code}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#2563eb', textDecoration: 'underline' }}
title="View on Brakes website"
>
{s.product_code}
</a>
) : (
s.product_code || s.description_pattern || '-'
)}
</td>
<td style={styles.tdSmall}>
{s.pack_quantity && s.unit_size
? `${s.pack_quantity}×${s.unit_size}${s.unit_size_type || ''}`
: '-'
}
</td>
<td style={styles.tdSmall}>
{s.latest_unit_price != null ? `£${s.latest_unit_price.toFixed(2)}` : '-'}
</td>
<td style={styles.tdSmall}>
{s.price_per_std_unit != null ? (() => {
const p = typeof s.price_per_std_unit === 'string' ? parseFloat(s.price_per_std_unit) : s.price_per_std_unit
const u = ing.standard_unit
if (u === 'g' && p < 1) return `£${(p * 1000).toFixed(2)}/kg`
if (u === 'ml' && p < 1) return `£${(p * 1000).toFixed(2)}/ltr`
return `£${p >= 1 ? p.toFixed(2) : p.toFixed(4)}/${u}`
})() : '-'}
</td>
<td style={styles.tdSmall}>{s.latest_invoice_date || '-'}</td>
</tr>
))}
</tbody>
</table>
) : (
<div style={{ color: '#888', fontStyle: 'italic' }}>No supplier sources mapped yet</div>
)}
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
)}
<IngredientModal
open={showModal}
onClose={() => { setShowModal(false); setEditingIngredient(null) }}
editingIngredient={editingIngredient}
/>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1400px', margin: '0 auto' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
filterBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' },
searchInput: { padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', width: '250px' },
select: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' },
checkLabel: { display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.9rem', color: '#555' },
statsBar: { display: 'flex', gap: '0.75rem', fontSize: '0.85rem', color: '#666', marginBottom: '0.75rem' },
table: { width: '100%', borderCollapse: 'collapse', background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
thSmall: { padding: '0.4rem 0.5rem', textAlign: 'left' as const, borderBottom: '1px solid #e0e0e0', background: '#f5f5f5', fontSize: '0.75rem', fontWeight: 600, color: '#666' },
tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' },
tdSmall: { padding: '0.35rem 0.5rem', fontSize: '0.8rem' },
expandedTd: { padding: '0.75rem 1rem', background: '#f9f9f9' },
sourcesSection: { padding: '0.5rem' },
smallBtn: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer', fontSize: '0.75rem', marginRight: '0.25rem' },
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' },
loading: { padding: '2rem', textAlign: 'center' as const, color: '#888' },
}

View file

@ -0,0 +1,687 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import { useAuth } from '../App'
interface Invoice {
id: number
invoice_number: string | null
invoice_date: string | null
total: number | null
net_total: number | null
stock_total: number | null
status: string
category: string | null
ocr_confidence: number | null
created_at: string
document_type: string | null
duplicate_status: string | null
supplier_id: number | null
supplier_name: string | null
supplier_match_type: string | null
vendor_name: string | null // OCR-extracted vendor name (for unmatched display)
source: string // upload, email, api
source_reference: string | null
}
// Source badge component - shows where invoice came from
const sourceConfig: Record<string, { label: string; color: string; icon: string }> = {
upload: { label: 'Upload', color: '#6c757d', icon: '📤' },
email: { label: 'Email', color: '#3498db', icon: '📧' },
api: { label: 'API', color: '#9b59b6', icon: '🔌' },
}
const SourceBadge = ({ source }: { source: string }) => {
const { label, color, icon } = sourceConfig[source] || sourceConfig.upload
return (
<span style={{
background: color,
color: 'white',
padding: '0.15rem 0.4rem',
borderRadius: '4px',
fontSize: '0.7rem',
fontWeight: 'bold',
display: 'inline-flex',
alignItems: 'center',
gap: '0.2rem',
}}>
{icon} {label}
</span>
)
}
interface Supplier {
id: number
name: string
}
interface InvoiceListResponse {
invoices: Invoice[]
total: number
}
// Match Review.tsx status colors for consistency (uppercase keys to match API values)
const statusColors: Record<string, string> = {
PENDING: '#f59e0b', // amber/orange
PROCESSED: '#3b82f6', // blue
REVIEWED: '#8b5cf6', // purple
CONFIRMED: '#22c55e', // green
NEW: '#06b6d4', // cyan for new status
}
export default function InvoiceList() {
const { token } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
// Initialize filters from URL params, with defaults
const [statusFilter, setStatusFilter] = useState<string>(() => {
const urlStatus = searchParams.get('status')
// If status param exists (even empty string), use it; otherwise default to pending_confirmation
return urlStatus !== null ? urlStatus : 'pending_confirmation'
})
const [supplierFilter, setSupplierFilter] = useState<string>(() =>
searchParams.get('supplier_id') || ''
)
const [dateFrom, setDateFrom] = useState<string>(() =>
searchParams.get('date_from') || ''
)
const [dateTo, setDateTo] = useState<string>(() =>
searchParams.get('date_to') || ''
)
// Clear URL params after reading them (keeps URL clean)
useEffect(() => {
if (searchParams.toString()) {
setSearchParams({}, { replace: true })
}
}, [])
// Fetch suppliers for filter dropdown
const { data: suppliers } = useQuery<Supplier[]>({
queryKey: ['suppliers'],
queryFn: async () => {
const res = await fetch('/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return []
return res.json()
},
})
// Build query params
const queryParams = new URLSearchParams()
if (statusFilter) queryParams.append('status', statusFilter)
if (supplierFilter) queryParams.append('supplier_id', supplierFilter)
if (dateFrom) queryParams.append('date_from', dateFrom)
if (dateTo) queryParams.append('date_to', dateTo)
const queryString = queryParams.toString()
const { data, isLoading, error } = useQuery<InvoiceListResponse>({
queryKey: ['invoices', statusFilter, supplierFilter, dateFrom, dateTo],
queryFn: async () => {
const url = queryString ? `/api/invoices/?${queryString}` : '/api/invoices/'
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch invoices')
return res.json()
},
})
// Fetch completed invoices when showing pending confirmation and there are no pending invoices
const showCompletedInvoices = statusFilter === 'pending_confirmation' && data?.invoices.length === 0
const { data: completedData } = useQuery<InvoiceListResponse>({
queryKey: ['completed-invoices'],
queryFn: async () => {
const params = new URLSearchParams()
params.set('status', 'confirmed')
params.set('limit', '20')
params.set('sort', 'recent')
const res = await fetch(`/api/invoices/?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch completed invoices')
return res.json()
},
enabled: showCompletedInvoices && !!token,
})
if (isLoading) {
return <div style={styles.loading}>Loading invoices...</div>
}
if (error) {
return <div style={styles.error}>Error loading invoices: {error.message}</div>
}
const invoices = data?.invoices || []
const completedInvoices = completedData?.invoices || []
return (
<div>
<div style={styles.header}>
<h2 style={styles.title}>Uploaded Invoices</h2>
<a href="/upload" style={styles.uploadBtn}>
+ Upload New
</a>
</div>
{/* Filters */}
<div style={styles.filters}>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={styles.filterSelect}
>
<option value="">All Statuses</option>
<option value="pending_confirmation">Pending Confirmation</option>
<option value="pending">Pending</option>
<option value="processed">Processed</option>
<option value="reviewed">Reviewed</option>
<option value="confirmed">Confirmed</option>
</select>
<select
value={supplierFilter}
onChange={(e) => setSupplierFilter(e.target.value)}
style={styles.filterSelect}
>
<option value="">All Suppliers</option>
{suppliers?.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<div style={styles.dateFilters}>
<label style={styles.dateLabel}>
Upload Date From:
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
style={styles.dateInput}
/>
</label>
<label style={styles.dateLabel}>
Upload Date To:
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
style={styles.dateInput}
/>
</label>
</div>
{(statusFilter !== 'pending_confirmation' || supplierFilter || dateFrom || dateTo) && (
<button
onClick={() => { setStatusFilter('pending_confirmation'); setSupplierFilter(''); setDateFrom(''); setDateTo(''); }}
style={styles.clearBtn}
>
Clear Filters
</button>
)}
</div>
{invoices.length === 0 ? (
<div style={styles.empty}>
<p>No invoices pending confirmation.</p>
<a href="/upload" style={styles.link}>
Upload one now
</a>
</div>
) : (
<div style={styles.list}>
{invoices.map((invoice) => (
<a
key={invoice.id}
href={`/invoice/${invoice.id}`}
style={{
...styles.card,
borderLeft: `4px solid ${statusColors[invoice.status] || '#999'}`,
}}
>
<div style={styles.cardMain}>
<div style={styles.invoiceNumberRow}>
<span style={styles.invoiceNumber}>
{invoice.invoice_number
|| (invoice.status === 'PENDING' ? 'Pending extraction...'
: invoice.vendor_name ? `(${invoice.vendor_name})`
: 'No invoice #')}
</span>
{invoice.document_type === 'delivery_note' && (
<span style={styles.dnBadge}>DN</span>
)}
{invoice.duplicate_status === 'firm_duplicate' && (
<span style={styles.duplicateBadge}>DUPLICATE</span>
)}
{invoice.duplicate_status === 'possible_duplicate' && (
<span style={styles.possibleDuplicateBadge}>POSSIBLE DUPLICATE</span>
)}
</div>
<div style={styles.invoiceMeta}>
{invoice.supplier_name ? (
<span style={{
...styles.supplierName,
...(invoice.supplier_match_type === 'fuzzy' ? styles.fuzzySupplier : {})
}}>
{invoice.supplier_name}
{invoice.supplier_match_type === 'fuzzy' && (
<span style={styles.fuzzyBadge} title="Suggested match - please verify">?</span>
)}
</span>
) : invoice.vendor_name ? (
<span style={styles.unmatchedSupplier} title="No matching supplier found - click to assign">
{invoice.vendor_name}
<span style={styles.unmatchedBadge}>!</span>
</span>
) : null}
<span style={styles.invoiceDate}>
{invoice.invoice_date
? new Date(invoice.invoice_date).toLocaleDateString()
: 'No date'}
</span>
</div>
</div>
<div style={styles.cardRight}>
<div style={styles.total}>
{invoice.stock_total != null && invoice.total != null && invoice.stock_total !== invoice.total ? (
<>
£{Number(invoice.stock_total).toFixed(2)}
<span style={styles.fullTotal}>
(£{Number(invoice.total).toFixed(2)})
</span>
</>
) : invoice.net_total != null ? (
<>
£{Number(invoice.net_total).toFixed(2)}
{invoice.total != null && invoice.total !== invoice.net_total && (
<span style={styles.fullTotal}>
(£{Number(invoice.total).toFixed(2)})
</span>
)}
</>
) : invoice.total != null ? (
`£${Number(invoice.total).toFixed(2)}`
) : '—'}
</div>
<div style={styles.badgeRow}>
{invoice.source && invoice.source !== 'upload' && (
<SourceBadge source={invoice.source} />
)}
<div
style={{
...styles.status,
background: statusColors[invoice.status] || '#999',
}}
>
{invoice.status}
</div>
</div>
</div>
{invoice.ocr_confidence != null && (
<div style={styles.confidence}>
{(Number(invoice.ocr_confidence) * 100).toFixed(0)}% confidence
</div>
)}
</a>
))}
</div>
)}
{/* Show completed invoices section when no pending invoices */}
{showCompletedInvoices && completedInvoices.length > 0 && (
<div style={{ marginTop: '2rem' }}>
<h3 style={{ ...styles.title, fontSize: '1.2rem', marginBottom: '1rem' }}>
Recently Completed Invoices
</h3>
<div style={styles.list}>
{completedInvoices.map((invoice) => (
<a
key={invoice.id}
href={`/invoice/${invoice.id}`}
style={{
...styles.card,
borderLeft: `4px solid ${statusColors[invoice.status] || '#999'}`,
}}
>
<div style={styles.cardMain}>
<div style={styles.invoiceNumberRow}>
<span style={styles.invoiceNumber}>
{invoice.invoice_number
|| (invoice.status === 'PENDING' ? 'Pending extraction...'
: invoice.vendor_name ? `(${invoice.vendor_name})`
: 'No invoice #')}
</span>
{invoice.document_type === 'delivery_note' && (
<span style={styles.dnBadge}>DN</span>
)}
{invoice.duplicate_status === 'firm_duplicate' && (
<span style={styles.duplicateBadge}>DUPLICATE</span>
)}
{invoice.duplicate_status === 'possible_duplicate' && (
<span style={styles.possibleDuplicateBadge}>POSSIBLE DUPLICATE</span>
)}
</div>
<div style={styles.invoiceMeta}>
{invoice.supplier_name ? (
<span style={{
...styles.supplierName,
...(invoice.supplier_match_type === 'fuzzy' ? styles.fuzzySupplier : {})
}}>
{invoice.supplier_name}
{invoice.supplier_match_type === 'fuzzy' && (
<span style={styles.fuzzyBadge} title="Suggested match - please verify">?</span>
)}
</span>
) : invoice.vendor_name ? (
<span style={styles.unmatchedSupplier} title="No matching supplier found - click to assign">
{invoice.vendor_name}
<span style={styles.unmatchedBadge}>!</span>
</span>
) : null}
<span style={styles.invoiceDate}>
{invoice.invoice_date
? new Date(invoice.invoice_date).toLocaleDateString()
: 'No date'}
</span>
</div>
</div>
<div style={styles.cardRight}>
<div style={styles.total}>
{invoice.stock_total != null && invoice.total != null && invoice.stock_total !== invoice.total ? (
<>
£{Number(invoice.stock_total).toFixed(2)}
<span style={styles.fullTotal}>
(£{Number(invoice.total).toFixed(2)})
</span>
</>
) : invoice.net_total != null ? (
<>
£{Number(invoice.net_total).toFixed(2)}
{invoice.total != null && invoice.total !== invoice.net_total && (
<span style={styles.fullTotal}>
(£{Number(invoice.total).toFixed(2)})
</span>
)}
</>
) : invoice.total != null ? (
`£${Number(invoice.total).toFixed(2)}`
) : '—'}
</div>
<div style={styles.badgeRow}>
{invoice.source && invoice.source !== 'upload' && (
<SourceBadge source={invoice.source} />
)}
<div
style={{
...styles.status,
background: statusColors[invoice.status] || '#999',
}}
>
{invoice.status}
</div>
</div>
</div>
{invoice.ocr_confidence != null && (
<div style={styles.confidence}>
{(Number(invoice.ocr_confidence) * 100).toFixed(0)}% confidence
</div>
)}
</a>
))}
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
loading: {
padding: '2rem',
textAlign: 'center',
color: '#666',
},
error: {
padding: '2rem',
textAlign: 'center',
color: '#c00',
background: '#fee',
borderRadius: '8px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
},
title: {
color: '#1a1a2e',
margin: 0,
},
uploadBtn: {
padding: '0.75rem 1.5rem',
background: '#e94560',
color: 'white',
textDecoration: 'none',
borderRadius: '8px',
fontWeight: 'bold',
},
filters: {
display: 'flex',
gap: '1rem',
marginBottom: '1.5rem',
alignItems: 'center',
flexWrap: 'wrap',
background: 'white',
padding: '1rem',
borderRadius: '8px',
},
filterSelect: {
padding: '0.5rem 1rem',
borderRadius: '6px',
border: '1px solid #ddd',
fontSize: '0.9rem',
minWidth: '150px',
},
dateFilters: {
display: 'flex',
gap: '1rem',
alignItems: 'center',
},
dateLabel: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.9rem',
color: '#666',
},
dateInput: {
padding: '0.5rem',
borderRadius: '6px',
border: '1px solid #ddd',
fontSize: '0.9rem',
},
clearBtn: {
padding: '0.5rem 1rem',
background: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.85rem',
},
empty: {
background: 'white',
padding: '3rem',
borderRadius: '12px',
textAlign: 'center',
color: '#666',
},
link: {
color: '#e94560',
marginTop: '1rem',
display: 'inline-block',
},
list: {
display: 'flex',
flexDirection: 'column',
gap: '1rem',
},
card: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'white',
padding: '1.25rem 1.5rem',
borderRadius: '12px',
textDecoration: 'none',
color: 'inherit',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
transition: 'transform 0.2s, box-shadow 0.2s',
position: 'relative',
},
cardMain: {
flex: 1,
},
invoiceNumberRow: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
flexWrap: 'wrap',
},
invoiceNumber: {
fontWeight: 'bold',
fontSize: '1.1rem',
color: '#1a1a2e',
},
dnBadge: {
display: 'inline-block',
padding: '0.15rem 0.5rem',
background: '#6c757d',
color: 'white',
fontSize: '0.65rem',
fontWeight: 'bold',
borderRadius: '4px',
textTransform: 'uppercase',
},
duplicateBadge: {
display: 'inline-block',
padding: '0.15rem 0.5rem',
background: '#dc3545',
color: 'white',
fontSize: '0.65rem',
fontWeight: 'bold',
borderRadius: '4px',
textTransform: 'uppercase',
},
possibleDuplicateBadge: {
display: 'inline-block',
padding: '0.15rem 0.5rem',
background: '#fd7e14',
color: 'white',
fontSize: '0.65rem',
fontWeight: 'bold',
borderRadius: '4px',
textTransform: 'uppercase',
},
invoiceMeta: {
display: 'flex',
gap: '0.75rem',
marginTop: '0.25rem',
alignItems: 'center',
},
supplierName: {
color: '#1a1a2e',
fontSize: '0.9rem',
fontWeight: '500',
},
fuzzySupplier: {
color: '#856404',
background: '#fff3cd',
padding: '0.1rem 0.4rem',
borderRadius: '4px',
border: '1px solid #ffc107',
},
fuzzyBadge: {
display: 'inline-block',
marginLeft: '0.3rem',
width: '14px',
height: '14px',
lineHeight: '14px',
textAlign: 'center',
background: '#ffc107',
color: '#856404',
borderRadius: '50%',
fontSize: '0.7rem',
fontWeight: 'bold',
},
unmatchedSupplier: {
color: '#721c24',
background: '#f8d7da',
padding: '0.1rem 0.4rem',
borderRadius: '4px',
border: '1px solid #f5c6cb',
fontSize: '0.9rem',
},
unmatchedBadge: {
display: 'inline-block',
marginLeft: '0.3rem',
width: '14px',
height: '14px',
lineHeight: '14px',
textAlign: 'center',
background: '#dc3545',
color: 'white',
borderRadius: '50%',
fontSize: '0.7rem',
fontWeight: 'bold',
},
invoiceDate: {
color: '#666',
fontSize: '0.9rem',
},
cardRight: {
textAlign: 'right',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: '0.5rem',
},
badgeRow: {
display: 'flex',
gap: '0.4rem',
alignItems: 'center',
justifyContent: 'flex-end',
},
total: {
fontWeight: 'bold',
fontSize: '1.2rem',
color: '#1a1a2e',
},
fullTotal: {
fontSize: '0.85rem',
fontWeight: 'normal',
color: '#666',
marginLeft: '0.25rem',
},
status: {
display: 'inline-block',
padding: '0.25rem 0.75rem',
borderRadius: '20px',
color: 'white',
fontSize: '0.75rem',
textTransform: 'uppercase',
},
confidence: {
position: 'absolute',
top: '0.5rem',
right: '0.75rem',
fontSize: '0.7rem',
color: '#999',
},
}

View file

@ -0,0 +1,123 @@
import { NavLink, Outlet } from 'react-router-dom'
import {
LayoutDashboard, Upload, FileText, Search, List, AlertCircle,
ShoppingCart, TrendingUp, BarChart2, PieChart, Activity, RefreshCw,
Calendar, Users, Hotel, BookOpen, Wheat, ChefHat, UtensilsCrossed,
Menu, ShieldCheck, Package, Settings, ChevronRight,
} from 'lucide-react'
import { useAuth } from './AuthGate'
import { can } from '../types'
const ICON = { size: 15, strokeWidth: 1.75 }
function SidebarSection({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="sidebar-section">
<div className="sidebar-section-label">{label}</div>
{children}
</div>
)
}
function NavItem({ to, label, icon: Icon }: { to: string; label: string; icon: React.ComponentType<any> }) {
return (
<NavLink to={to} className={({ isActive }) => isActive ? 'active' : ''}>
<Icon {...ICON} />
{label}
</NavLink>
)
}
export default function Layout() {
const { user } = useAuth()
return (
<div className="app-shell">
<aside className="sidebar">
<div className="sidebar-logo">
<ChefHat size={18} strokeWidth={1.75} color="var(--app-primary)" />
Kitchen
</div>
<nav className="sidebar-nav">
<NavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} />
{can(user, 'invoices') && (
<SidebarSection label="Invoices">
<NavItem to="/upload" label="Upload" icon={Upload} />
<NavItem to="/invoices" label="Invoices" icon={FileText} />
<NavItem to="/search/invoices" label="Search" icon={Search} />
<NavItem to="/search/line-items" label="Line Items" icon={List} />
{can(user, 'disputes') && <NavItem to="/disputes" label="Disputes" icon={AlertCircle} />}
{can(user, 'orders') && <NavItem to="/purchase-orders" label="Purchase Orders" icon={ShoppingCart} />}
</SidebarSection>
)}
{can(user, 'view') && (
<SidebarSection label="Reports">
<NavItem to="/gp" label="Kitchen Flash" icon={TrendingUp} />
<NavItem to="/purchases-report" label="Purchases" icon={BarChart2} />
<NavItem to="/allowances-report" label="Allowances" icon={PieChart} />
<NavItem to="/sales-gp" label="Sales GP%" icon={Activity} />
<NavItem to="/usage-variance" label="Usage Variance" icon={ChevronRight} />
<NavItem to="/purchases/reconcile" label="Reconcile" icon={RefreshCw} />
</SidebarSection>
)}
{can(user, 'logbook') && (
<SidebarSection label="Kitchen">
<NavItem to="/logbook" label="Allowance Logbook" icon={BookOpen} />
<NavItem to="/budget" label="Budget" icon={PieChart} />
{can(user, 'orders') && <NavItem to="/event-orders" label="Event Orders" icon={Package} />}
</SidebarSection>
)}
{can(user, 'view') && (
<SidebarSection label="Bookings">
<NavItem to="/resos" label="ResOS Calendar" icon={Calendar} />
<NavItem to="/resos-stats" label="Booking Stats" icon={BarChart2} />
<NavItem to="/residents-table-chart" label="Residents Chart" icon={Users} />
<NavItem to="/newbook" label="NewBook Data" icon={Hotel} />
</SidebarSection>
)}
{can(user, 'recipes') && (
<SidebarSection label="Recipes">
<NavItem to="/ingredients" label="Ingredients" icon={Wheat} />
<NavItem to="/recipes" label="Recipes" icon={ChefHat} />
<NavItem to="/dishes" label="Dishes" icon={UtensilsCrossed} />
{can(user, 'menus') && <NavItem to="/menus" label="Menus" icon={Menu} />}
<NavItem to="/allergens" label="Allergens" icon={ShieldCheck} />
<NavItem to="/price-impact" label="Price Impact" icon={TrendingUp} />
</SidebarSection>
)}
{can(user, 'settings') && (
<SidebarSection label="">
<NavItem to="/settings" label="Settings" icon={Settings} />
</SidebarSection>
)}
</nav>
<div className="sidebar-user">{user.name || user.email}</div>
</aside>
{/* Mobile top bar */}
<header className="top-bar">
<ChefHat size={16} strokeWidth={1.75} color="var(--app-primary)" />
<span className="top-bar-title">Kitchen</span>
<nav className="top-bar-nav">
<NavLink to="/dashboard" className={({ isActive }) => isActive ? 'active' : ''}>Dashboard</NavLink>
{can(user, 'invoices') && <NavLink to="/invoices" className={({ isActive }) => isActive ? 'active' : ''}>Invoices</NavLink>}
{can(user, 'view') && <NavLink to="/gp" className={({ isActive }) => isActive ? 'active' : ''}>Reports</NavLink>}
{can(user, 'recipes') && <NavLink to="/recipes" className={({ isActive }) => isActive ? 'active' : ''}>Recipes</NavLink>}
{can(user, 'settings') && <NavLink to="/settings" className={({ isActive }) => isActive ? 'active' : ''}>Settings</NavLink>}
</nav>
</header>
<main className="page-content">
<Outlet />
</main>
</div>
)
}

View file

@ -0,0 +1,542 @@
/**
* Line Item History Modal
* Shows price history chart and quantity stats for a product.
* Reusable component for search pages and invoice review.
*/
import { useState, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
import { formatCurrency, formatDateForDisplay, formatPercent, formatQuantity } from '../utils/searchHelpers'
interface PriceHistoryPoint {
date: string
price: number
invoice_id: number
invoice_number: string | null
quantity: number | null
}
interface HistoryResponse {
product_code: string | null
description: string | null
supplier_id: number
supplier_name: string | null
price_history: PriceHistoryPoint[]
total_occurrences: number
total_quantity: number
avg_qty_per_invoice: number
avg_qty_per_week: number
avg_qty_per_month: number
current_price: number | null
price_change_status: string
}
interface Props {
isOpen: boolean
onClose: () => void
productCode: string | null
description: string | null
unit: string | null
supplierId: number
supplierName: string
currentPrice?: number
sourceInvoiceId?: number
sourceLineItemId?: number
onAcknowledge?: () => void
}
export default function LineItemHistoryModal({
isOpen,
onClose,
productCode,
description,
unit,
supplierId,
supplierName,
currentPrice,
sourceInvoiceId,
sourceLineItemId,
onAcknowledge,
}: Props) {
const { token } = useAuth()
const queryClient = useQueryClient()
// Date range state (default: 12 months)
const defaultDateRange = useMemo(() => {
const today = new Date()
const yearAgo = new Date(today)
yearAgo.setFullYear(yearAgo.getFullYear() - 1)
return {
from: yearAgo.toISOString().split('T')[0],
to: today.toISOString().split('T')[0],
}
}, [])
const [dateFrom, setDateFrom] = useState(defaultDateRange.from)
const [dateTo, setDateTo] = useState(defaultDateRange.to)
// Fetch history
const { data, isLoading, error } = useQuery<HistoryResponse>({
queryKey: ['line-item-history', supplierId, productCode, description, unit, dateFrom, dateTo],
queryFn: async () => {
const params = new URLSearchParams()
params.set('supplier_id', supplierId.toString())
if (productCode) params.set('product_code', productCode)
if (description) params.set('description', description)
if (unit) params.set('unit', unit)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
const res = await fetch(`/api/search/line-items/history?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch history')
return res.json()
},
enabled: isOpen && !!token,
})
// Acknowledge price mutation
const acknowledgeMutation = useMutation({
mutationFn: async () => {
const res = await fetch('/api/search/line-items/acknowledge-price', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
product_code: productCode,
description: description,
supplier_id: supplierId,
new_price: currentPrice || data?.current_price,
source_invoice_id: sourceInvoiceId,
source_line_item_id: sourceLineItemId,
}),
})
if (!res.ok) throw new Error('Failed to acknowledge price')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['line-item-history'] })
queryClient.invalidateQueries({ queryKey: ['search-line-items'] })
if (onAcknowledge) onAcknowledge()
},
})
// Calculate price range for chart (must be before early return to satisfy hooks rules)
// Uses a minimum meaningful range to avoid misleading charts when prices are very similar
const priceRange = useMemo(() => {
if (!data?.price_history.length) return { min: 0, max: 100 }
const prices = data.price_history.map((p) => typeof p.price === 'string' ? parseFloat(p.price) : p.price)
const min = Math.min(...prices)
const max = Math.max(...prices)
const actualRange = max - min
const avgPrice = (min + max) / 2
// Minimum range should be at least 20% of average price to give proper context
// This prevents tiny variations from filling the whole chart
const minMeaningfulRange = avgPrice * 0.2 || 1
const effectiveRange = Math.max(actualRange, minMeaningfulRange)
// Center the range around the actual data
const centerPrice = (min + max) / 2
const rangeMin = centerPrice - effectiveRange / 2
const rangeMax = centerPrice + effectiveRange / 2
// Add small padding
const padding = effectiveRange * 0.1
return {
min: Math.max(0, rangeMin - padding),
max: rangeMax + padding
}
}, [data])
if (!isOpen) return null
// Calculate chart height percentage for a price
const getPriceHeight = (price: number) => {
const range = priceRange.max - priceRange.min
if (range === 0) return 50
return ((price - priceRange.min) / range) * 100
}
const displayPrice = currentPrice ?? data?.current_price
const showAcknowledge =
data?.price_change_status === 'amber' || data?.price_change_status === 'red'
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
onClick={onClose}
>
<div
style={{
backgroundColor: 'white',
borderRadius: '12px',
width: '90%',
maxWidth: '800px',
maxHeight: '90vh',
overflow: 'auto',
padding: '24px',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '20px',
}}
>
<div>
<h2 style={{ margin: 0, fontSize: '18px' }}>
Price History: {description || productCode || 'Unknown'}
{productCode && description && (
<span style={{ color: '#6b7280', fontWeight: 'normal' }}> ({productCode})</span>
)}
</h2>
<p style={{ margin: '4px 0 0', color: '#6b7280' }}>Supplier: {supplierName}</p>
</div>
<button
onClick={onClose}
style={{
padding: '4px 12px',
border: 'none',
backgroundColor: '#f1f5f9',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px',
}}
>
✕
</button>
</div>
{/* Date Range */}
<div
style={{
display: 'flex',
gap: '12px',
alignItems: 'center',
marginBottom: '20px',
padding: '12px',
backgroundColor: '#f8fafc',
borderRadius: '8px',
}}
>
<label style={{ fontSize: '14px' }}>Date Range:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px' }}
/>
<span>to</span>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px' }}
/>
</div>
{isLoading && <p>Loading...</p>}
{error && <p style={{ color: 'red' }}>Error: {(error as Error).message}</p>}
{data && (
<>
{/* Price Chart (Simple bar visualization) */}
<div
style={{
marginBottom: '24px',
padding: '16px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
}}
>
<h3 style={{ margin: '0 0 16px', fontSize: '14px', fontWeight: 600 }}>
Price History
</h3>
{data.price_history.length === 0 ? (
<p style={{ color: '#6b7280', textAlign: 'center' }}>
No price history available for this period
</p>
) : (
<div>
{/* Chart */}
<div
style={{
height: '150px',
display: 'flex',
alignItems: 'flex-end',
gap: '2px',
padding: '0 0 20px',
borderBottom: '1px solid #e5e7eb',
position: 'relative',
}}
>
{/* Y-axis labels */}
<div
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 20,
width: '50px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
fontSize: '10px',
color: '#9ca3af',
}}
>
<span>{formatCurrency(priceRange.max)}</span>
<span>{formatCurrency(priceRange.min)}</span>
</div>
{/* Bars */}
<div
style={{
flex: 1,
marginLeft: '55px',
display: 'flex',
alignItems: 'flex-end',
gap: '2px',
height: '100%',
}}
>
{data.price_history.map((point, idx) => (
<div
key={idx}
style={{
flex: 1,
maxWidth: '40px',
height: `${getPriceHeight(point.price)}%`,
backgroundColor: '#3b82f6',
borderRadius: '2px 2px 0 0',
cursor: 'pointer',
position: 'relative',
}}
title={`${formatDateForDisplay(point.date)}: ${formatCurrency(point.price)} (${point.invoice_number || 'Invoice'})`}
/>
))}
</div>
</div>
{/* X-axis (dates) */}
<div
style={{
marginLeft: '55px',
display: 'flex',
justifyContent: 'space-between',
fontSize: '10px',
color: '#9ca3af',
marginTop: '4px',
}}
>
{data.price_history.length > 0 && (
<>
<span>{formatDateForDisplay(data.price_history[0].date)}</span>
<span>
{formatDateForDisplay(
data.price_history[data.price_history.length - 1].date
)}
</span>
</>
)}
</div>
{/* Price history table */}
<div style={{ marginTop: '16px', maxHeight: '150px', overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ backgroundColor: '#f8fafc' }}>
<th style={{ padding: '8px', textAlign: 'left', width: '90px' }}>Date</th>
<th style={{ padding: '8px', textAlign: 'left', minWidth: '200px' }}>Description</th>
<th style={{ padding: '8px', textAlign: 'left', width: '80px' }}>Unit</th>
<th style={{ padding: '8px', textAlign: 'right', width: '80px' }}>Qty</th>
<th style={{ padding: '8px', textAlign: 'right', width: '80px' }}>Price</th>
</tr>
</thead>
<tbody>
{data.price_history
.slice()
.reverse()
.map((point, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid #f1f5f9' }}>
<td style={{ padding: '8px', whiteSpace: 'nowrap' }}>
{formatDateForDisplay(point.date)}
</td>
<td style={{ padding: '8px', maxWidth: '300px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{data.description || '-'}
</span>
<a
href={`/invoice/${point.invoice_id}`}
target="_blank"
rel="noopener noreferrer"
style={{
color: '#2563eb',
textDecoration: 'none',
whiteSpace: 'nowrap',
flexShrink: 0,
fontSize: '12px'
}}
>
{point.invoice_number || 'View'} ↗
</a>
</div>
</td>
<td style={{ padding: '8px' }}>
{unit || '-'}
</td>
<td style={{ padding: '8px', textAlign: 'right' }}>
{formatQuantity(point.quantity)}
</td>
<td style={{ padding: '8px', textAlign: 'right' }}>
{formatCurrency(point.price)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Stats */}
<div
style={{
marginBottom: '24px',
padding: '16px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
}}
>
<h3 style={{ margin: '0 0 12px', fontSize: '14px', fontWeight: 600 }}>
Stats for Period
</h3>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px',
}}
>
<StatItem label="Total Occurrences" value={String(data.total_occurrences)} />
<StatItem label="Total Quantity" value={formatQuantity(data.total_quantity)} />
<StatItem
label="Avg Qty per Invoice"
value={formatQuantity(data.avg_qty_per_invoice, 2)}
/>
<StatItem label="Avg Qty per Week" value={formatQuantity(data.avg_qty_per_week, 2)} />
<StatItem label="Avg Qty per Month" value={formatQuantity(data.avg_qty_per_month, 2)} />
</div>
</div>
{/* Current Price & Acknowledge */}
{displayPrice && (
<div
style={{
padding: '16px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
backgroundColor:
data.price_change_status === 'red'
? '#fef2f2'
: data.price_change_status === 'amber'
? '#fffbeb'
: '#f0fdf4',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<span style={{ fontWeight: 600 }}>Current Price: </span>
<span style={{ fontSize: '18px' }}>{formatCurrency(displayPrice)}</span>
{data.price_history.length > 1 && (
<span
style={{
marginLeft: '8px',
color:
data.price_change_status === 'red'
? '#dc2626'
: data.price_change_status === 'amber'
? '#d97706'
: '#16a34a',
}}
>
{(() => {
const prev = data.price_history[data.price_history.length - 2]?.price
if (!prev) return ''
const change = ((displayPrice - prev) / prev) * 100
return `(${formatPercent(change)} from previous ${formatCurrency(prev)})`
})()}
</span>
)}
</div>
{showAcknowledge && (
<button
onClick={() => acknowledgeMutation.mutate()}
disabled={acknowledgeMutation.isPending}
style={{
padding: '8px 16px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: acknowledgeMutation.isPending ? 'not-allowed' : 'pointer',
opacity: acknowledgeMutation.isPending ? 0.7 : 1,
}}
>
{acknowledgeMutation.isPending
? 'Acknowledging...'
: 'Acknowledge Price Change'}
</button>
)}
</div>
{acknowledgeMutation.isSuccess && (
<p style={{ marginTop: '8px', color: '#16a34a', fontSize: '14px' }}>
✓ Price change acknowledged
</p>
)}
</div>
)}
</>
)}
</div>
</div>
)
}
function StatItem({ label, value }: { label: string; value: string }) {
return (
<div>
<div style={{ fontSize: '12px', color: '#6b7280' }}>{label}</div>
<div style={{ fontSize: '16px', fontWeight: 600 }}>{value}</div>
</div>
)
}

View file

@ -0,0 +1,506 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
interface OpenDispute {
id: number
title: string
dispute_type: string
status: string
disputed_amount: number
opened_at: string
invoice_number: string | null
}
interface LinkDisputeModalProps {
supplierId: number
supplierName: string | null
creditNoteInvoiceId: number
creditNoteNumber: string | null
creditNoteAmount: number | null
onClose: () => void
onSuccess?: () => void
}
const disputeTypeLabels: Record<string, string> = {
price_discrepancy: 'Price Discrepancy',
short_delivery: 'Short Delivery',
wrong_product: 'Wrong Product',
quality_issue: 'Quality Issue',
calculation_error: 'Calculation Error',
missing_items: 'Missing Items',
damaged_goods: 'Damaged Goods',
other: 'Other',
}
const statusColors: Record<string, string> = {
NEW: '#17a2b8',
OPEN: '#17a2b8',
CONTACTED: '#6c757d',
IN_PROGRESS: '#ffc107',
AWAITING_CREDIT: '#fd7e14',
AWAITING_REPLACEMENT: '#fd7e14',
ESCALATED: '#dc3545',
}
export default function LinkDisputeModal({
supplierId,
supplierName,
creditNoteInvoiceId,
creditNoteNumber,
creditNoteAmount,
onClose,
onSuccess,
}: LinkDisputeModalProps) {
const { token } = useAuth()
const queryClient = useQueryClient()
const [selectedDisputeId, setSelectedDisputeId] = useState<number | null>(null)
const [resolvedAmount, setResolvedAmount] = useState<string>(
creditNoteAmount ? Math.abs(creditNoteAmount).toFixed(2) : ''
)
const [resolutionNotes, setResolutionNotes] = useState<string>('')
// Fetch open disputes for this supplier
const { data: disputes, isLoading } = useQuery<OpenDispute[]>({
queryKey: ['open-disputes', supplierId],
queryFn: async () => {
const res = await fetch(`/api/disputes/supplier/${supplierId}/open`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch disputes')
return res.json()
},
enabled: !!supplierId,
})
// Link mutation
const linkMutation = useMutation({
mutationFn: async () => {
if (!selectedDisputeId) throw new Error('No dispute selected')
const res = await fetch(`/api/disputes/${selectedDisputeId}/link-credit-note`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
credit_note_invoice_id: creditNoteInvoiceId,
resolved_amount: resolvedAmount ? parseFloat(resolvedAmount) : undefined,
resolution_notes: resolutionNotes || undefined,
}),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.detail || 'Failed to link dispute')
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['disputes'] })
queryClient.invalidateQueries({ queryKey: ['dispute-stats'] })
queryClient.invalidateQueries({ queryKey: ['open-disputes', supplierId] })
queryClient.invalidateQueries({ queryKey: ['invoice', creditNoteInvoiceId] })
queryClient.invalidateQueries({ queryKey: ['invoices'] })
if (onSuccess) onSuccess()
onClose()
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedDisputeId) {
alert('Please select a dispute to link')
return
}
linkMutation.mutate()
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
}
return (
<div style={styles.modalOverlay} onClick={onClose}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<h2 style={styles.modalTitle}>Link Credit Note to Dispute</h2>
<button style={styles.closeBtn} onClick={onClose} type="button">
✕
</button>
</div>
<div style={styles.content}>
{/* Credit Note Info */}
<div style={styles.infoBox}>
<div style={styles.infoRow}>
<span style={styles.infoLabel}>Credit Note:</span>
<span style={styles.infoValue}>#{creditNoteNumber || creditNoteInvoiceId}</span>
</div>
<div style={styles.infoRow}>
<span style={styles.infoLabel}>Supplier:</span>
<span style={styles.infoValue}>{supplierName || 'Unknown'}</span>
</div>
{creditNoteAmount && (
<div style={styles.infoRow}>
<span style={styles.infoLabel}>Amount:</span>
<span style={styles.infoValue}>£{Math.abs(creditNoteAmount).toFixed(2)}</span>
</div>
)}
</div>
{/* Disputes List */}
<div style={styles.section}>
<h3 style={styles.sectionTitle}>Select Open Dispute</h3>
{isLoading ? (
<div style={styles.loading}>Loading disputes...</div>
) : !disputes || disputes.length === 0 ? (
<div style={styles.emptyState}>
No open disputes found for this supplier.
</div>
) : (
<div style={styles.disputeList}>
{disputes.map((dispute) => (
<label
key={dispute.id}
style={{
...styles.disputeItem,
borderColor: selectedDisputeId === dispute.id ? '#e94560' : '#eee',
background: selectedDisputeId === dispute.id ? '#fff5f7' : 'white',
}}
>
<input
type="radio"
name="dispute"
value={dispute.id}
checked={selectedDisputeId === dispute.id}
onChange={() => setSelectedDisputeId(dispute.id)}
style={styles.radio}
/>
<div style={styles.disputeContent}>
<div style={styles.disputeHeader}>
<span style={styles.disputeTitle}>{dispute.title}</span>
<span
style={{
...styles.statusBadge,
background: statusColors[dispute.status] || '#6c757d',
}}
>
{dispute.status.replace('_', ' ')}
</span>
</div>
<div style={styles.disputeMeta}>
<span style={styles.disputeType}>
{disputeTypeLabels[dispute.dispute_type] || dispute.dispute_type}
</span>
<span style={styles.disputeAmount}>
£{dispute.disputed_amount.toFixed(2)}
</span>
<span style={styles.disputeDate}>
Opened {formatDate(dispute.opened_at)}
</span>
{dispute.invoice_number && (
<span style={styles.invoiceRef}>
Invoice #{dispute.invoice_number}
</span>
)}
</div>
</div>
</label>
))}
</div>
)}
</div>
{/* Optional Fields */}
{selectedDisputeId && (
<div style={styles.section}>
<div style={styles.formGroup}>
<label style={styles.label}>Resolved Amount (£)</label>
<input
type="number"
step="0.01"
min="0"
value={resolvedAmount}
onChange={(e) => setResolvedAmount(e.target.value)}
style={styles.input}
placeholder="0.00"
/>
<div style={styles.hint}>Leave as-is to use credit note amount</div>
</div>
<div style={styles.formGroup}>
<label style={styles.label}>Resolution Notes (optional)</label>
<textarea
value={resolutionNotes}
onChange={(e) => setResolutionNotes(e.target.value)}
style={styles.textarea}
placeholder="Any additional notes about the resolution..."
rows={3}
/>
</div>
</div>
)}
</div>
<div style={styles.footer}>
<button type="button" onClick={onClose} style={styles.cancelBtn}>
Cancel
</button>
<button
onClick={handleSubmit}
style={{
...styles.submitBtn,
opacity: !selectedDisputeId || linkMutation.isPending ? 0.5 : 1,
cursor: !selectedDisputeId || linkMutation.isPending ? 'not-allowed' : 'pointer',
}}
disabled={!selectedDisputeId || linkMutation.isPending}
>
{linkMutation.isPending ? 'Linking...' : 'Link & Resolve Dispute'}
</button>
</div>
{linkMutation.isError && (
<div style={styles.error}>
Error: {linkMutation.error?.message || 'Failed to link dispute'}
</div>
)}
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
modalOverlay: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
padding: '1rem',
},
modal: {
background: 'white',
borderRadius: '12px',
maxWidth: '600px',
width: '100%',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 4px 24px rgba(0,0,0,0.2)',
},
modalHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1.5rem',
borderBottom: '1px solid #eee',
flexShrink: 0,
},
modalTitle: {
margin: 0,
color: '#1a1a2e',
fontSize: '1.25rem',
},
closeBtn: {
background: 'none',
border: 'none',
fontSize: '1.5rem',
cursor: 'pointer',
color: '#666',
padding: '0.25rem',
lineHeight: 1,
},
content: {
padding: '1.5rem',
flex: 1,
overflow: 'auto',
},
infoBox: {
background: '#f8f9fa',
padding: '1rem',
borderRadius: '8px',
marginBottom: '1.5rem',
border: '1px solid #e9ecef',
},
infoRow: {
display: 'flex',
justifyContent: 'space-between',
marginBottom: '0.5rem',
},
infoLabel: {
color: '#666',
fontWeight: '500',
},
infoValue: {
fontWeight: '600',
color: '#1a1a2e',
},
section: {
marginBottom: '1.5rem',
},
sectionTitle: {
fontSize: '1rem',
fontWeight: '600',
color: '#1a1a2e',
marginBottom: '0.75rem',
},
loading: {
padding: '2rem',
textAlign: 'center',
color: '#666',
},
emptyState: {
padding: '2rem',
textAlign: 'center',
color: '#666',
background: '#f8f9fa',
borderRadius: '8px',
fontStyle: 'italic',
},
disputeList: {
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
maxHeight: '250px',
overflow: 'auto',
},
disputeItem: {
display: 'flex',
alignItems: 'flex-start',
padding: '1rem',
border: '2px solid #eee',
borderRadius: '8px',
cursor: 'pointer',
transition: 'border-color 0.2s, background 0.2s',
},
radio: {
marginRight: '1rem',
marginTop: '0.25rem',
cursor: 'pointer',
},
disputeContent: {
flex: 1,
},
disputeHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '0.5rem',
gap: '0.5rem',
},
disputeTitle: {
fontWeight: '600',
color: '#1a1a2e',
flex: 1,
},
statusBadge: {
padding: '0.2rem 0.5rem',
borderRadius: '4px',
fontSize: '0.7rem',
fontWeight: '600',
color: 'white',
textTransform: 'uppercase',
whiteSpace: 'nowrap',
},
disputeMeta: {
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
fontSize: '0.85rem',
color: '#666',
},
disputeType: {
background: '#e9ecef',
padding: '0.15rem 0.5rem',
borderRadius: '4px',
},
disputeAmount: {
fontWeight: '600',
color: '#dc3545',
},
disputeDate: {
color: '#999',
},
invoiceRef: {
color: '#666',
},
formGroup: {
marginBottom: '1rem',
},
label: {
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#1a1a2e',
fontSize: '0.9rem',
},
input: {
width: '100%',
padding: '0.75rem',
borderRadius: '8px',
border: '1px solid #ccc',
fontSize: '1rem',
boxSizing: 'border-box',
},
textarea: {
width: '100%',
padding: '0.75rem',
borderRadius: '8px',
border: '1px solid #ccc',
fontSize: '1rem',
resize: 'vertical',
boxSizing: 'border-box',
fontFamily: 'inherit',
},
hint: {
fontSize: '0.75rem',
color: '#999',
marginTop: '0.25rem',
fontStyle: 'italic',
},
footer: {
display: 'flex',
justifyContent: 'flex-end',
gap: '1rem',
padding: '1rem 1.5rem',
borderTop: '1px solid #eee',
flexShrink: 0,
},
cancelBtn: {
padding: '0.75rem 1.5rem',
borderRadius: '8px',
border: '1px solid #ccc',
background: 'white',
color: '#666',
cursor: 'pointer',
fontWeight: 'bold',
fontSize: '1rem',
},
submitBtn: {
padding: '0.75rem 1.5rem',
borderRadius: '8px',
border: 'none',
background: '#28a745',
color: 'white',
cursor: 'pointer',
fontWeight: 'bold',
fontSize: '1rem',
},
error: {
margin: '0 1.5rem 1rem',
padding: '0.75rem',
background: '#fee',
color: '#c00',
borderRadius: '8px',
fontSize: '0.9rem',
},
}

View file

@ -0,0 +1,461 @@
import { useState, useMemo, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../App'
import { useDebounce, getDefaultDateRange } from '../utils/searchHelpers'
interface LineItemResult {
product_code: string | null
description: string | null
supplier_id: number | null
supplier_name: string | null
unit: string | null
most_recent_price: number | null
occurrence_count: number
most_recent_invoice_id: number
most_recent_date: string | null
pack_quantity: number | null
most_recent_line_item_id: number | null
most_recent_line_number: number | null
most_recent_raw_content: string | null
most_recent_pack_quantity: number | null
most_recent_unit_size: number | null
most_recent_unit_size_type: string | null
}
interface SearchResponse {
items: LineItemResult[]
total_count: number
}
interface Supplier {
id: number
name: string
}
interface MapLineItemsModalProps {
ingredient: {
id: number
name: string
standard_unit: string
yield_percent: number
effective_price: number | null
}
onClose: () => void
onSaved: () => void
}
// Unit conversion factors (same as Review.tsx)
const CONVERSIONS: Record<string, Record<string, number>> = {
g: { g: 1, kg: 0.001 }, kg: { g: 1000, kg: 1 }, oz: { g: 28.3495, kg: 0.0283495 },
ml: { ml: 1, ltr: 0.001 }, cl: { ml: 10, ltr: 0.01 }, ltr: { ml: 1000, ltr: 1 },
each: { each: 1 },
}
function calcConversionDisplay(
packQty: number, unitSize: number | null, unitSizeType: string,
standardUnit: string, unitPrice: number | null
): string {
if (!unitSize || !unitSizeType) return ''
const conv = CONVERSIONS[unitSizeType]?.[standardUnit]
if (!conv) return unitSizeType !== standardUnit ? `Cannot convert ${unitSizeType} \u2192 ${standardUnit}` : ''
const totalStd = packQty * unitSize * conv
const pricePerStd = unitPrice ? (unitPrice / totalStd) : null
const packNote = packQty > 1 ? `${packQty} \u00d7 ${unitSize}${unitSizeType} = ` : ''
let display = `${packNote}${totalStd.toFixed(totalStd % 1 ? 2 : 0)} ${standardUnit}`
if (pricePerStd) {
display += ` \u2192 \u00a3${pricePerStd.toFixed(4)} per ${standardUnit}`
if (standardUnit === 'g') display += ` (\u00a3${(pricePerStd * 1000).toFixed(2)}/kg)`
else if (standardUnit === 'ml') display += ` (\u00a3${(pricePerStd * 1000).toFixed(2)}/ltr)`
}
return display
}
export default function MapLineItemsModal({ ingredient, onClose, onSaved }: MapLineItemsModalProps) {
const { token } = useAuth()
const defaultDates = useMemo(() => getDefaultDateRange(), [])
// Search state
const [searchInput, setSearchInput] = useState('')
const [supplierId, setSupplierId] = useState('')
const [dateFrom] = useState(defaultDates.from)
const [dateTo] = useState(defaultDates.to)
const debouncedSearch = useDebounce(searchInput, 300)
// Selected item + pack config
const [selectedItem, setSelectedItem] = useState<LineItemResult | null>(null)
const [packQty, setPackQty] = useState(1)
const [unitSize, setUnitSize] = useState<string>('')
const [unitSizeType, setUnitSizeType] = useState(ingredient.standard_unit)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [successMsg, setSuccessMsg] = useState('')
const [previewError, setPreviewError] = useState(false)
// Suppliers
const { data: suppliers } = useQuery<Supplier[]>({
queryKey: ['suppliers'],
queryFn: async () => {
const res = await fetch('/api/suppliers/', { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) return []
const data = await res.json()
return data.suppliers || data || []
},
})
// Search results
const { data: searchData, isLoading } = useQuery<SearchResponse>({
queryKey: ['map-line-items-search', debouncedSearch, supplierId, dateFrom, dateTo],
queryFn: async () => {
const params = new URLSearchParams()
if (debouncedSearch) params.set('q', debouncedSearch)
if (supplierId) params.set('supplier_id', supplierId)
params.set('date_from', dateFrom)
params.set('date_to', dateTo)
params.set('limit', '50')
const res = await fetch(`/api/search/line-items?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return { items: [], total_count: 0 }
return res.json()
},
enabled: debouncedSearch.length >= 2,
})
// When selecting a line item, auto-fill pack fields from DB values first
const handleSelect = (item: LineItemResult) => {
setSelectedItem(item)
setPreviewError(false)
setError('')
setSuccessMsg('')
// Prefer DB-stored pack values from the most recent line item
if (item.most_recent_pack_quantity && item.most_recent_unit_size) {
setPackQty(item.most_recent_pack_quantity)
setUnitSize(Number(item.most_recent_unit_size).toString())
setUnitSizeType(item.most_recent_unit_size_type || ingredient.standard_unit)
} else {
// Reset — useEffect regex fallback will attempt to parse from description
setPackQty(1)
setUnitSize('')
setUnitSizeType(ingredient.standard_unit)
}
}
// Conversion display
const conversionDisplay = useMemo(() => {
const us = parseFloat(unitSize)
if (!us || !unitSizeType) return ''
const price = selectedItem?.most_recent_price != null ? Number(selectedItem.most_recent_price) : null
return calcConversionDisplay(packQty, us, unitSizeType, ingredient.standard_unit, price)
}, [packQty, unitSize, unitSizeType, ingredient.standard_unit, selectedItem?.most_recent_price])
// Fallback: auto-fill unitSize from description via client-side regex (only if DB had no pack data)
useEffect(() => {
if (!selectedItem) return
// Skip if we already populated from DB values
if (selectedItem.most_recent_pack_quantity && selectedItem.most_recent_unit_size) return
const desc = selectedItem.description || ''
const packMatch = desc.match(/(\d+)\s*[x\u00d7]\s*(\d+(?:\.\d+)?)\s*(g|kg|ml|ltr|l|oz|cl|gm|gms)\b/i)
if (packMatch) {
setPackQty(parseInt(packMatch[1]))
setUnitSize(packMatch[2])
let ut = packMatch[3].toLowerCase()
if (ut === 'l') ut = 'ltr'
if (ut === 'gm' || ut === 'gms') ut = 'g'
setUnitSizeType(ut)
return
}
const standaloneMatch = desc.match(/(\d+(?:\.\d+)?)\s*(g|kg|ml|ltr|l|oz|cl|gm|gms|gram|grams|kilo|kilos|kilogram|litre|litres|liter)\b/i)
if (standaloneMatch) {
setPackQty(1)
setUnitSize(standaloneMatch[1])
let ut = standaloneMatch[2].toLowerCase()
if (ut === 'l' || ut === 'litre' || ut === 'litres' || ut === 'liter') ut = 'ltr'
if (ut === 'gm' || ut === 'gms' || ut === 'gram' || ut === 'grams') ut = 'g'
if (ut === 'kilo' || ut === 'kilos' || ut === 'kilogram') ut = 'kg'
setUnitSizeType(ut)
}
}, [selectedItem])
const handleSave = async () => {
if (!selectedItem) return
setSaving(true)
setError('')
setSuccessMsg('')
try {
const sourceData: Record<string, unknown> = {
supplier_id: selectedItem.supplier_id,
pack_quantity: packQty || 1,
unit_size: parseFloat(unitSize) || null,
unit_size_type: unitSizeType || null,
apply_to_existing: true,
}
if (selectedItem.product_code) {
sourceData.product_code = selectedItem.product_code
} else if (selectedItem.description) {
sourceData.description_pattern = selectedItem.description.substring(0, 100).toLowerCase().trim()
}
if (selectedItem.most_recent_price) {
sourceData.latest_unit_price = selectedItem.most_recent_price
}
if (selectedItem.most_recent_invoice_id) {
sourceData.invoice_id = selectedItem.most_recent_invoice_id
}
const res = await fetch(`/api/ingredients/${ingredient.id}/sources`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(sourceData),
})
if (res.ok) {
const data = await res.json()
const count = data.matched_line_items
setSuccessMsg(`Source created${count ? ` \u2014 ${count} line item${count > 1 ? 's' : ''} mapped` : ''}`)
setTimeout(() => onSaved(), 1200)
} else if (res.status === 409) {
setError('This supplier product is already mapped to this ingredient')
} else {
const body = await res.json().catch(() => ({}))
setError(body.detail || 'Failed to create source')
}
} catch (err) {
setError('Network error')
console.error(err)
} finally {
setSaving(false)
}
}
// Preview URL for the selected line item
const previewUrl = selectedItem?.most_recent_invoice_id && selectedItem?.most_recent_line_number != null
? `/api/invoices/${selectedItem.most_recent_invoice_id}/line-items/${selectedItem.most_recent_line_number}/preview?token=${token}`
: null
return (
<div style={styles.overlay} onClick={onClose}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div style={styles.header}>
<h3 style={{ margin: 0, fontSize: '1rem' }}>Map Line Items to "{ingredient.name}"</h3>
<button onClick={onClose} style={styles.closeBtn}>{'\u2715'}</button>
</div>
{/* Context bar */}
<div style={styles.contextBar}>
<span><strong>{ingredient.name}</strong></span>
<span>Unit: {ingredient.standard_unit}</span>
{ingredient.effective_price != null && (
<span>Current: {'\u00a3'}{Number(ingredient.effective_price).toFixed(4)}/{ingredient.standard_unit}</span>
)}
</div>
<div style={styles.body}>
{/* Search controls */}
<div style={styles.searchRow}>
<input
value={searchInput}
onChange={(e) => { setSearchInput(e.target.value); setSelectedItem(null); setError(''); setSuccessMsg('') }}
style={{ ...styles.input, flex: 2 }}
placeholder="Search by product code or description..."
autoFocus
/>
<select
value={supplierId}
onChange={(e) => setSupplierId(e.target.value)}
style={{ ...styles.input, flex: 1, minWidth: '140px' }}
>
<option value="">All Suppliers</option>
{suppliers?.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
{/* Results table */}
{debouncedSearch.length >= 2 && (
<div style={styles.resultsContainer}>
{isLoading ? (
<div style={{ padding: '1rem', color: '#888', textAlign: 'center' }}>Searching...</div>
) : !searchData?.items.length ? (
<div style={{ padding: '1rem', color: '#888', textAlign: 'center' }}>No line items found</div>
) : (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Description</th>
<th style={styles.th}>Supplier</th>
<th style={styles.th}>Price</th>
<th style={styles.th}>#</th>
</tr>
</thead>
<tbody>
{searchData.items.map((item, idx) => {
const isSelected = selectedItem === item
return (
<tr
key={`${item.product_code || ''}-${item.supplier_id}-${idx}`}
style={{
...styles.tr,
background: isSelected ? '#e8f5e9' : undefined,
cursor: 'pointer',
}}
onClick={() => handleSelect(item)}
>
<td style={styles.td}>
{item.product_code && (
<span style={{ color: '#888', fontSize: '0.75rem', marginRight: '0.35rem' }}>{item.product_code}</span>
)}
{item.description}
</td>
<td style={styles.td}>{item.supplier_name || '-'}</td>
<td style={styles.td}>
{item.most_recent_price != null ? `\u00a3${Number(item.most_recent_price).toFixed(2)}` : '-'}
</td>
<td style={styles.td}>{item.occurrence_count}</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
)}
{/* OCR bounding box preview */}
{selectedItem && previewUrl && !previewError && (
<div style={styles.previewContainer}>
<img
src={previewUrl}
alt="Line item preview"
style={styles.previewImage}
onError={() => setPreviewError(true)}
/>
</div>
)}
{/* Pack config - shown when a line item is selected */}
{selectedItem && (
<div style={styles.packSection}>
<label style={styles.label}>
How much {ingredient.name} is in "{selectedItem.description}"?
</label>
<div style={{ fontSize: '0.8rem', color: '#888', marginBottom: '0.5rem' }}>
{selectedItem.supplier_name}
{selectedItem.most_recent_price != null ? ` \u2014 \u00a3${Number(selectedItem.most_recent_price).toFixed(2)}` : ''}
</div>
{/* Raw OCR content for context */}
{selectedItem.most_recent_raw_content && selectedItem.most_recent_raw_content !== selectedItem.description && (
<div style={styles.rawContent}>
{selectedItem.most_recent_raw_content}
</div>
)}
<div style={styles.packRow}>
<div style={{ flex: 1 }}>
<div style={styles.packLabel}>Contains</div>
<input
type="number"
value={unitSize}
onChange={(e) => setUnitSize(e.target.value)}
style={styles.input}
step="0.1"
min="0"
placeholder="Size"
/>
</div>
<div style={{ flex: 1 }}>
<div style={styles.packLabel}>Unit</div>
<select
value={unitSizeType}
onChange={(e) => setUnitSizeType(e.target.value)}
style={styles.input}
>
<option value="each">each</option>
<option value="g">g</option>
<option value="kg">kg</option>
<option value="ml">ml</option>
<option value="ltr">ltr</option>
<option value="oz">oz</option>
<option value="cl">cl</option>
</select>
</div>
<div style={{ flex: 1 }}>
<div style={styles.packLabel}>Pack of</div>
<input
type="number"
value={packQty}
onChange={(e) => setPackQty(parseInt(e.target.value) || 1)}
style={styles.input}
min="1"
step="1"
/>
</div>
<div style={{ flex: 1 }}>
<div style={styles.packLabel}>Line Price</div>
<div style={{ ...styles.input, background: '#f5f5f5', display: 'flex', alignItems: 'center' }}>
{selectedItem.most_recent_price != null ? `\u00a3${Number(selectedItem.most_recent_price).toFixed(2)}` : '--'}
</div>
</div>
</div>
{conversionDisplay && (
<div style={styles.conversionBar}>
{conversionDisplay}
</div>
)}
</div>
)}
{/* Messages */}
{error && <div style={styles.errorMsg}>{error}</div>}
{successMsg && <div style={styles.successMsg}>{successMsg}</div>}
</div>
{/* Footer */}
<div style={styles.footer}>
<button onClick={onClose} style={styles.cancelBtn}>Cancel</button>
<button
onClick={handleSave}
disabled={!selectedItem || saving}
style={{ ...styles.primaryBtn, opacity: !selectedItem || saving ? 0.5 : 1 }}
>
{saving ? 'Saving...' : 'Save & Map'}
</button>
</div>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
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: '750px', maxWidth: '95vw', maxHeight: '90vh', overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.2)', display: 'flex', flexDirection: 'column' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.25rem', borderBottom: '1px solid #eee' },
contextBar: { display: 'flex', gap: '1rem', padding: '0.5rem 1.25rem', background: '#f8f9fa', borderBottom: '1px solid #eee', fontSize: '0.8rem', color: '#555', flexWrap: 'wrap' },
body: { padding: '1rem 1.25rem', overflow: 'auto', flex: 1 },
footer: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '1rem 1.25rem', borderTop: '1px solid #eee' },
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
searchRow: { display: 'flex', gap: '0.5rem', marginBottom: '0.75rem' },
input: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.85rem', boxSizing: 'border-box' as const },
label: { fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.25rem' },
resultsContainer: { maxHeight: '220px', overflow: 'auto', border: '1px solid #e0e0e0', borderRadius: '6px', marginBottom: '0.75rem' },
table: { width: '100%', borderCollapse: 'collapse' },
th: { padding: '0.4rem 0.6rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.75rem', fontWeight: 600, color: '#555', position: 'sticky' as const, top: 0 },
tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.4rem 0.6rem', fontSize: '0.8rem' },
previewContainer: { marginBottom: '0.75rem', borderRadius: '6px', overflow: 'hidden', border: '2px solid #ffc107', boxShadow: '0 0 8px rgba(255, 193, 7, 0.3)' },
previewImage: { width: '100%', display: 'block', maxHeight: '80px', objectFit: 'contain' as const, background: '#fff' },
rawContent: { fontSize: '0.75rem', color: '#999', fontFamily: 'monospace', marginBottom: '0.5rem', padding: '0.35rem 0.5rem', background: '#f0f0f0', borderRadius: '4px', whiteSpace: 'pre-wrap' as const, lineHeight: 1.3 },
packSection: { background: '#f8f9fa', padding: '0.75rem', borderRadius: '6px', marginTop: '0.5rem' },
packRow: { display: 'flex', gap: '0.5rem', alignItems: 'flex-end' },
packLabel: { fontSize: '0.7rem', fontWeight: 600, color: '#888', marginBottom: '0.2rem' },
conversionBar: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#e8f5e9', borderRadius: '6px', fontSize: '0.85rem', color: '#2e7d32', fontWeight: 500 },
errorMsg: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#fdecea', borderRadius: '6px', fontSize: '0.85rem', color: '#c62828' },
successMsg: { marginTop: '0.5rem', padding: '0.5rem 0.75rem', background: '#e8f5e9', borderRadius: '6px', fontSize: '0.85rem', color: '#2e7d32' },
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' },
cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' },
}

View file

@ -0,0 +1,702 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
import { useNavigate, useParams } from 'react-router-dom'
import PublishToMenuModal from './PublishToMenuModal'
import BulkPublishModal from './BulkPublishModal'
import MenuFlagMatrix from './MenuFlagMatrix'
interface MenuItem {
id: number
recipe_id: number | null
display_name: string
description: string | null
price: string | null
sort_order: number
snapshot_json: Record<string, unknown> | null
confirmed_by_name: string | null
confirmed_by_user_id: number | null
published_at: string | null
has_image: boolean
is_stale: boolean
stale_reason: string | null
is_archived: boolean
}
interface Division {
id: number
name: string
sort_order: number
items: MenuItem[]
}
interface MenuDetail {
id: number
name: string
description: string | null
notes: string | null
is_active: boolean
sort_order: number
divisions: Division[]
}
export default function MenuEditor() {
const { id } = useParams<{ id: string }>()
const menuId = parseInt(id || '0')
const { token, user } = useAuth()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [editingName, setEditingName] = useState(false)
const [nameValue, setNameValue] = useState('')
const [editingDesc, setEditingDesc] = useState(false)
const [descValue, setDescValue] = useState('')
const [editingNotes, setEditingNotes] = useState(false)
const [notesValue, setNotesValue] = useState('')
// Division management
const [showAddDiv, setShowAddDiv] = useState(false)
const [newDivName, setNewDivName] = useState('')
const [editingDivId, setEditingDivId] = useState<number | null>(null)
const [editingDivName, setEditingDivName] = useState('')
const [sortingDivs, setSortingDivs] = useState(false)
const [divDragIdx, setDivDragIdx] = useState<number | null>(null)
const [divDragOverIdx, setDivDragOverIdx] = useState<number | null>(null)
// Item management
const [sortingItems, setSortingItems] = useState<number | null>(null) // division_id being sorted
const [itemDragIdx, setItemDragIdx] = useState<number | null>(null)
const [itemDragOverIdx, setItemDragOverIdx] = useState<number | null>(null)
const [editingItemId, setEditingItemId] = useState<number | null>(null)
const [editItemName, setEditItemName] = useState('')
const [editItemDesc, setEditItemDesc] = useState('')
const [editItemPrice, setEditItemPrice] = useState('')
const [editItemDivId, setEditItemDivId] = useState<number | null>(null)
// Modals
const [publishDivId, setPublishDivId] = useState<number | null>(null)
const [bulkDivId, setBulkDivId] = useState<number | null>(null)
const [showFlagMatrix, setShowFlagMatrix] = useState(false)
const [showBatchRepublish, setShowBatchRepublish] = useState(false)
const [batchConfirmedBy, setBatchConfirmedBy] = useState('')
const [collapsedDivs, setCollapsedDivs] = useState<Set<number>>(new Set())
const { data: menu, isLoading } = useQuery<MenuDetail>({
queryKey: ['menu', menuId],
queryFn: async () => {
const res = await fetch(`/api/menus/${menuId}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch menu')
return res.json()
},
enabled: !!token && menuId > 0,
staleTime: 0,
})
// Mutations
const updateMenuMutation = useMutation({
mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch(`/api/menus/${menuId}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to update')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['menu', menuId] }),
})
const addDivMutation = useMutation({
mutationFn: async (name: string) => {
const res = await fetch(`/api/menus/${menuId}/divisions`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!res.ok) throw new Error('Failed to add division')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['menu', menuId] })
setShowAddDiv(false)
setNewDivName('')
},
})
const updateDivMutation = useMutation({
mutationFn: async ({ divId, name }: { divId: number; name: string }) => {
const res = await fetch(`/api/menus/${menuId}/divisions/${divId}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!res.ok) throw new Error('Failed to rename')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['menu', menuId] })
setEditingDivId(null)
},
})
const deleteDivMutation = useMutation({
mutationFn: async (divId: number) => {
const res = await fetch(`/api/menus/${menuId}/divisions/${divId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to delete')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['menu', menuId] }),
})
const reorderDivsMutation = useMutation({
mutationFn: async (ids: number[]) => {
const res = await fetch(`/api/menus/${menuId}/divisions/reorder`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
})
if (!res.ok) throw new Error('Failed to reorder')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['menu', menuId] }),
})
const updateItemMutation = useMutation({
mutationFn: async ({ itemId, data }: { itemId: number; data: Record<string, unknown> }) => {
const res = await fetch(`/api/menus/${menuId}/items/${itemId}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to update')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['menu', menuId] })
setEditingItemId(null)
},
})
const deleteItemMutation = useMutation({
mutationFn: async (itemId: number) => {
const res = await fetch(`/api/menus/${menuId}/items/${itemId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to remove')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['menu', menuId] }),
})
const republishMutation = useMutation({
mutationFn: async ({ itemId, confirmed_by_name }: { itemId: number; confirmed_by_name: string }) => {
const res = await fetch(`/api/menus/${menuId}/items/${itemId}/republish`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ confirmed_by_name }),
})
if (!res.ok) throw new Error('Failed to republish')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['menu', menuId] }),
})
const reorderItemsMutation = useMutation({
mutationFn: async (ids: number[]) => {
const res = await fetch(`/api/menus/${menuId}/items/reorder`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
})
if (!res.ok) throw new Error('Failed to reorder')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['menu', menuId] }),
})
const batchRepublishMutation = useMutation({
mutationFn: async (body: { confirmed_by_name: string; items: Array<{ id: number; confirmed: boolean }> }) => {
const res = await fetch(`/api/menus/${menuId}/republish-stale`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error('Failed to batch republish')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['menu', menuId] })
setShowBatchRepublish(false)
},
})
const uploadImageMutation = useMutation({
mutationFn: async ({ itemId, file }: { itemId: number; file: File }) => {
const formData = new FormData()
formData.append('file', file)
const res = await fetch(`/api/menus/${menuId}/items/${itemId}/image`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
})
if (!res.ok) throw new Error('Failed to upload image')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['menu', menuId] }),
})
const deleteImageMutation = useMutation({
mutationFn: async (itemId: number) => {
const res = await fetch(`/api/menus/${menuId}/items/${itemId}/image`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to delete image')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['menu', menuId] }),
})
if (isLoading) return <div style={{ padding: '2rem' }}>Loading...</div>
if (!menu) return <div style={{ padding: '2rem' }}>Menu not found</div>
const allItems = menu.divisions.flatMap(d => d.items)
const staleCount = allItems.filter(i => i.is_stale).length
const archivedCount = allItems.filter(i => i.is_archived).length
const handleDivDrop = () => {
if (divDragIdx === null || divDragOverIdx === null) return
const sorted = [...menu.divisions]
const [moved] = sorted.splice(divDragIdx, 1)
sorted.splice(divDragOverIdx, 0, moved)
reorderDivsMutation.mutate(sorted.map(d => d.id))
setDivDragIdx(null)
setDivDragOverIdx(null)
}
const handleItemDrop = (divItems: MenuItem[]) => {
if (itemDragIdx === null || itemDragOverIdx === null) return
const sorted = [...divItems]
const [moved] = sorted.splice(itemDragIdx, 1)
sorted.splice(itemDragOverIdx, 0, moved)
reorderItemsMutation.mutate(sorted.map(i => i.id))
setItemDragIdx(null)
setItemDragOverIdx(null)
}
const toggleCollapse = (divId: number) => {
const next = new Set(collapsedDivs)
if (next.has(divId)) next.delete(divId)
else next.add(divId)
setCollapsedDivs(next)
}
const staleItems = allItems.filter(i => i.is_stale && !i.is_archived)
return (
<div style={styles.container}>
{/* Back button */}
<button onClick={() => navigate('/menus')} style={styles.backBtn}>← Menus</button>
{/* Header */}
<div style={styles.menuHeader}>
<div style={{ flex: 1 }}>
{editingName ? (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input value={nameValue} onChange={(e) => setNameValue(e.target.value)} style={styles.input} autoFocus />
<button onClick={() => { updateMenuMutation.mutate({ name: nameValue }); setEditingName(false) }} style={styles.btnPrimary}>Save</button>
<button onClick={() => setEditingName(false)} style={styles.btn}>Cancel</button>
</div>
) : (
<h2 style={{ margin: 0, cursor: 'pointer' }} onClick={() => { setNameValue(menu.name); setEditingName(true) }}>
{menu.name}
</h2>
)}
{editingDesc ? (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.25rem' }}>
<input value={descValue} onChange={(e) => setDescValue(e.target.value)} style={styles.input} placeholder="Customer-facing description" />
<button onClick={() => { updateMenuMutation.mutate({ description: descValue }); setEditingDesc(false) }} style={styles.btnSmall}>Save</button>
<button onClick={() => setEditingDesc(false)} style={styles.btnSmall}>Cancel</button>
</div>
) : (
<p style={{ margin: '0.25rem 0 0', fontSize: '0.875rem', color: '#666', cursor: 'pointer' }}
onClick={() => { setDescValue(menu.description || ''); setEditingDesc(true) }}>
{menu.description || 'Click to add description...'}
</p>
)}
{editingNotes ? (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.25rem' }}>
<input value={notesValue} onChange={(e) => setNotesValue(e.target.value)} style={styles.input} placeholder="Internal notes" />
<button onClick={() => { updateMenuMutation.mutate({ notes: notesValue }); setEditingNotes(false) }} style={styles.btnSmall}>Save</button>
<button onClick={() => setEditingNotes(false)} style={styles.btnSmall}>Cancel</button>
</div>
) : (
<p style={{ margin: '0.25rem 0 0', fontSize: '0.8rem', color: '#999', cursor: 'pointer', fontStyle: 'italic' }}
onClick={() => { setNotesValue(menu.notes || ''); setEditingNotes(true) }}>
{menu.notes ? `Notes: ${menu.notes}` : 'Click to add internal notes...'}
</p>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<button
onClick={() => updateMenuMutation.mutate({ is_active: !menu.is_active })}
style={{
...styles.btn,
background: menu.is_active ? '#dcfce7' : '#f3f4f6',
color: menu.is_active ? '#166534' : '#6b7280',
border: 'none',
}}
>
{menu.is_active ? 'Active' : 'Inactive'}
</button>
{staleCount > 0 && (
<button onClick={() => { setBatchConfirmedBy(user?.name || ''); setShowBatchRepublish(true) }} style={{ ...styles.btn, background: '#fef3c7', color: '#92400e', border: 'none' }}>
Republish All Stale ({staleCount})
</button>
)}
<button onClick={() => setShowFlagMatrix(true)} style={styles.btn}>Print Flag Matrix</button>
</div>
</div>
{/* Notification bars */}
{staleCount > 0 && (
<div style={{ ...styles.notifBar, background: '#fef3c7', borderColor: '#f59e0b' }}>
{staleCount} item{staleCount !== 1 ? 's' : ''} need{staleCount === 1 ? 's' : ''} republishing
</div>
)}
{archivedCount > 0 && (
<div style={{ ...styles.notifBar, background: '#fee2e2', borderColor: '#ef4444' }}>
{archivedCount} dish{archivedCount !== 1 ? 'es have' : ' has'} been archived and should be removed
</div>
)}
{/* Divisions */}
<div style={styles.divHeader}>
<h3 style={{ margin: 0 }}>Sections</h3>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{menu.divisions.length > 1 && (
<button onClick={() => setSortingDivs(!sortingDivs)} style={{ ...styles.btn, background: sortingDivs ? '#3b82f6' : undefined, color: sortingDivs ? '#fff' : undefined }}>
{sortingDivs ? 'Done' : 'Sort'}
</button>
)}
<button onClick={() => setShowAddDiv(true)} style={styles.btnPrimary}>+ Add Section</button>
</div>
</div>
{showAddDiv && (
<div style={styles.inlineForm}>
<input value={newDivName} onChange={(e) => setNewDivName(e.target.value)} style={styles.input} placeholder="Section name" autoFocus />
<button onClick={() => { if (newDivName.trim()) addDivMutation.mutate(newDivName.trim()) }} style={styles.btnPrimary} disabled={!newDivName.trim()}>Add</button>
<button onClick={() => { setShowAddDiv(false); setNewDivName('') }} style={styles.btn}>Cancel</button>
</div>
)}
{menu.divisions.length === 0 && (
<p style={{ color: '#666', padding: '1rem' }}>No sections yet. Add a section to start publishing dishes.</p>
)}
{menu.divisions.map((div, divIdx) => (
<div
key={div.id}
style={{
...styles.divisionCard,
opacity: sortingDivs && divDragIdx === divIdx ? 0.4 : 1,
background: sortingDivs && divDragOverIdx === divIdx ? '#e0f2fe' : '#fff',
}}
draggable={sortingDivs}
onDragStart={() => setDivDragIdx(divIdx)}
onDragOver={(e) => { if (sortingDivs) { e.preventDefault(); setDivDragOverIdx(divIdx) } }}
onDrop={() => { if (sortingDivs) handleDivDrop() }}
onDragEnd={() => { setDivDragIdx(null); setDivDragOverIdx(null) }}
>
<div style={styles.divisionHeader} onClick={() => toggleCollapse(div.id)}>
{sortingDivs && <span style={styles.dragHandle}>☰</span>}
<span style={{ cursor: 'pointer', userSelect: 'none' }}>{collapsedDivs.has(div.id) ? '▶' : '▼'}</span>
{editingDivId === div.id ? (
<div style={{ display: 'flex', gap: '0.5rem', flex: 1 }} onClick={(e) => e.stopPropagation()}>
<input value={editingDivName} onChange={(e) => setEditingDivName(e.target.value)} style={styles.input} autoFocus />
<button onClick={() => updateDivMutation.mutate({ divId: div.id, name: editingDivName })} style={styles.btnSmall}>Save</button>
<button onClick={() => setEditingDivId(null)} style={styles.btnSmall}>Cancel</button>
</div>
) : (
<>
<span style={{ fontWeight: 600, flex: 1 }}>{div.name}</span>
<span style={{ fontSize: '0.8rem', color: '#999' }}>{div.items.length} item{div.items.length !== 1 ? 's' : ''}</span>
</>
)}
<div style={{ display: 'flex', gap: '0.25rem' }} onClick={(e) => e.stopPropagation()}>
<button onClick={() => { setEditingDivId(div.id); setEditingDivName(div.name) }} style={styles.btnTiny}>Rename</button>
<button onClick={() => setPublishDivId(div.id)} style={{ ...styles.btnTiny, background: '#eff6ff', color: '#2563eb' }}>+ Dish</button>
<button onClick={() => setBulkDivId(div.id)} style={{ ...styles.btnTiny, background: '#f0fdf4', color: '#166534' }}>+ Bulk</button>
<button
onClick={() => {
const msg = div.items.length > 0
? `Delete "${div.name}" and its ${div.items.length} item(s)?`
: `Delete "${div.name}"?`
if (confirm(msg)) deleteDivMutation.mutate(div.id)
}}
style={{ ...styles.btnTiny, color: '#dc2626' }}
>
Delete
</button>
</div>
</div>
{!collapsedDivs.has(div.id) && (
<div style={styles.itemsList}>
{div.items.length > 1 && (
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '0.25rem 0' }}>
<button
onClick={() => setSortingItems(sortingItems === div.id ? null : div.id)}
style={{ ...styles.btnTiny, background: sortingItems === div.id ? '#3b82f6' : undefined, color: sortingItems === div.id ? '#fff' : undefined }}
>
{sortingItems === div.id ? 'Done' : 'Sort'}
</button>
</div>
)}
{div.items.map((item, itemIdx) => (
<div
key={item.id}
style={{
...styles.itemRow,
opacity: item.is_archived ? 0.5 : (sortingItems === div.id && itemDragIdx === itemIdx ? 0.4 : 1),
background: sortingItems === div.id && itemDragOverIdx === itemIdx ? '#e0f2fe' : (item.is_archived ? '#f9fafb' : 'transparent'),
}}
draggable={sortingItems === div.id}
onDragStart={() => setItemDragIdx(itemIdx)}
onDragOver={(e) => { if (sortingItems === div.id) { e.preventDefault(); setItemDragOverIdx(itemIdx) } }}
onDrop={() => { if (sortingItems === div.id) handleItemDrop(div.items) }}
onDragEnd={() => { setItemDragIdx(null); setItemDragOverIdx(null) }}
>
{sortingItems === div.id && <span style={styles.dragHandle}>☰</span>}
{/* Image thumbnail */}
{item.has_image ? (
<img
src={`/api/menus/${menuId}/items/${item.id}/image?token=${token}`}
alt=""
style={styles.thumbnail}
/>
) : (
<div style={styles.thumbnailPlaceholder}>
<label style={{ cursor: 'pointer', fontSize: '0.7rem', color: '#999' }}>
+ img
<input
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) uploadImageMutation.mutate({ itemId: item.id, file: f })
}}
/>
</label>
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
{editingItemId === item.id ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }} onClick={(e) => e.stopPropagation()}>
<input value={editItemName} onChange={(e) => setEditItemName(e.target.value)} style={styles.inputSm} placeholder="Display name" />
<input value={editItemDesc} onChange={(e) => setEditItemDesc(e.target.value)} style={styles.inputSm} placeholder="Description" />
<div style={{ display: 'flex', gap: '0.25rem', alignItems: 'center' }}>
<input value={editItemPrice} onChange={(e) => setEditItemPrice(e.target.value)} style={{ ...styles.inputSm, width: '80px' }} placeholder="Price" />
<select
value={editItemDivId || ''}
onChange={(e) => setEditItemDivId(parseInt(e.target.value))}
style={styles.inputSm}
>
{menu.divisions.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
<button onClick={() => {
const data: Record<string, unknown> = {}
if (editItemName) data.display_name = editItemName
if (editItemDesc !== undefined) data.description = editItemDesc
if (editItemPrice) data.price = parseFloat(editItemPrice)
if (editItemDivId && editItemDivId !== div.id) data.division_id = editItemDivId
updateItemMutation.mutate({ itemId: item.id, data })
}} style={styles.btnSmall}>Save</button>
<button onClick={() => setEditingItemId(null)} style={styles.btnSmall}>Cancel</button>
</div>
</div>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontWeight: 500 }}>{item.display_name}</span>
{item.price && <span style={{ color: '#666', fontSize: '0.875rem' }}>£{item.price}</span>}
{item.is_archived && <span style={{ ...styles.badgeSm, background: '#fee2e2', color: '#dc2626' }}>Archived</span>}
{item.is_stale && <span style={{ ...styles.badgeSm, background: '#fef3c7', color: '#92400e' }} title={item.stale_reason || ''}>Needs Republishing</span>}
</div>
{item.description && <p style={{ margin: '0.125rem 0 0', fontSize: '0.8rem', color: '#666' }}>{item.description}</p>}
{/* Flag badges from snapshot */}
{item.snapshot_json && (item.snapshot_json as any).confirmed_flags && (
<div style={{ display: 'flex', gap: '0.25rem', marginTop: '0.25rem', flexWrap: 'wrap' }}>
{((item.snapshot_json as any).confirmed_flags as Array<{ code?: string; name: string }>).map((f, i) => (
<span key={i} style={{ ...styles.flagBadge }}>{f.code || f.name}</span>
))}
</div>
)}
</>
)}
</div>
{editingItemId !== item.id && (
<div style={{ display: 'flex', gap: '0.25rem', flexShrink: 0 }}>
<button onClick={() => {
setEditingItemId(item.id)
setEditItemName(item.display_name)
setEditItemDesc(item.description || '')
setEditItemPrice(item.price || '')
setEditItemDivId(div.id)
}} style={styles.btnTiny}>Edit</button>
{item.is_stale && !item.is_archived && (
<button
onClick={() => {
const name = prompt('Confirmed by:', user?.name || '')
if (name) republishMutation.mutate({ itemId: item.id, confirmed_by_name: name })
}}
style={{ ...styles.btnTiny, background: '#fef3c7', color: '#92400e' }}
>
Republish
</button>
)}
{item.has_image && (
<button onClick={() => { if (confirm('Remove image?')) deleteImageMutation.mutate(item.id) }} style={{ ...styles.btnTiny, color: '#dc2626' }}>- img</button>
)}
{!item.has_image && (
<label style={{ ...styles.btnTiny, background: '#f0fdf4', color: '#166534', cursor: 'pointer', display: 'inline-flex', alignItems: 'center' }}>
+ img
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => {
const f = e.target.files?.[0]
if (f) uploadImageMutation.mutate({ itemId: item.id, file: f })
}} />
</label>
)}
<button
onClick={() => { if (confirm(`Remove "${item.display_name}" from menu?`)) deleteItemMutation.mutate(item.id) }}
style={{ ...styles.btnTiny, color: '#dc2626' }}
>
Remove
</button>
</div>
)}
</div>
))}
{div.items.length === 0 && (
<p style={{ color: '#999', fontSize: '0.875rem', padding: '0.5rem 0' }}>
No dishes in this section.{' '}
<button onClick={() => setPublishDivId(div.id)} style={{ ...styles.btnTiny, background: '#eff6ff', color: '#2563eb' }}>+ Add Dish</button>
</p>
)}
</div>
)}
</div>
))}
{/* Publish Modal */}
{publishDivId !== null && (
<PublishToMenuModal
menuId={menuId}
menuName={menu.name}
divisions={menu.divisions}
preSelectedDivisionId={publishDivId}
onClose={() => setPublishDivId(null)}
onPublished={() => {
queryClient.invalidateQueries({ queryKey: ['menu', menuId] })
setPublishDivId(null)
}}
/>
)}
{/* Bulk Publish Modal */}
{bulkDivId !== null && (
<BulkPublishModal
menuId={menuId}
divisionId={bulkDivId}
divisionName={menu.divisions.find(d => d.id === bulkDivId)?.name || ''}
onClose={() => setBulkDivId(null)}
onPublished={() => {
queryClient.invalidateQueries({ queryKey: ['menu', menuId] })
setBulkDivId(null)
}}
/>
)}
{/* Flag Matrix Modal */}
{showFlagMatrix && (
<MenuFlagMatrix menuId={menuId} menuName={menu.name} onClose={() => setShowFlagMatrix(false)} />
)}
{/* Batch Republish Modal */}
{showBatchRepublish && (
<div style={styles.overlay} onClick={() => setShowBatchRepublish(false)}>
<div style={{ ...styles.modal, maxWidth: '600px' }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 1rem' }}>Batch Republish Stale Items</h3>
<p style={{ fontSize: '0.875rem', color: '#666', marginBottom: '1rem' }}>
{staleItems.length} item{staleItems.length !== 1 ? 's' : ''} will be republished with updated allergen information.
</p>
<div style={{ maxHeight: '300px', overflow: 'auto', marginBottom: '1rem' }}>
{staleItems.map(item => {
const flags = item.snapshot_json ? ((item.snapshot_json as any).confirmed_flags || []) : []
return (
<div key={item.id} style={{ padding: '0.5rem', borderBottom: '1px solid #eee' }}>
<span style={{ fontWeight: 500 }}>{item.display_name}</span>
{item.stale_reason && <span style={{ fontSize: '0.75rem', color: '#92400e', marginLeft: '0.5rem' }}>({item.stale_reason})</span>}
<div style={{ display: 'flex', gap: '0.25rem', marginTop: '0.25rem', flexWrap: 'wrap' }}>
{flags.map((f: any, i: number) => (
<span key={i} style={styles.flagBadge}>{f.code || f.name}</span>
))}
</div>
</div>
)
})}
</div>
<label style={styles.label}>Confirmed by *</label>
<input value={batchConfirmedBy} onChange={(e) => setBatchConfirmedBy(e.target.value)} style={styles.input} />
<div style={styles.modalActions}>
<button onClick={() => setShowBatchRepublish(false)} style={styles.btn}>Cancel</button>
<button
onClick={() => batchRepublishMutation.mutate({
confirmed_by_name: batchConfirmedBy,
items: staleItems.map(i => ({ id: i.id, confirmed: true })),
})}
style={styles.btnPrimary}
disabled={!batchConfirmedBy.trim() || batchRepublishMutation.isPending}
>
{batchRepublishMutation.isPending ? 'Republishing...' : `Republish ${staleItems.length} Items`}
</button>
</div>
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
container: { maxWidth: '1000px', margin: '0 auto', padding: '1rem' },
backBtn: { background: 'none', border: 'none', color: '#2563eb', cursor: 'pointer', fontSize: '0.875rem', padding: '0.25rem 0', marginBottom: '0.5rem' },
menuHeader: { display: 'flex', justifyContent: 'space-between', gap: '1rem', marginBottom: '1rem', flexWrap: 'wrap' },
notifBar: { padding: '0.5rem 1rem', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.5rem', borderLeft: '4px solid' },
divHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem', marginTop: '1rem' },
divisionCard: { border: '1px solid #e5e7eb', borderRadius: '8px', marginBottom: '0.5rem', overflow: 'hidden' },
divisionHeader: { display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.75rem 1rem', background: '#f9fafb', cursor: 'pointer', borderBottom: '1px solid #e5e7eb' },
dragHandle: { cursor: 'grab', fontSize: '1rem', color: '#999' },
itemsList: { padding: '0.5rem 1rem' },
itemRow: { display: 'flex', alignItems: 'flex-start', gap: '0.5rem', padding: '0.5rem 0', borderBottom: '1px solid #f3f4f6' },
thumbnail: { width: '48px', height: '48px', objectFit: 'cover', borderRadius: '4px', flexShrink: 0 },
thumbnailPlaceholder: { width: '48px', height: '48px', border: '1px dashed #ddd', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
flagBadge: { padding: '1px 6px', background: '#f3f4f6', borderRadius: '4px', fontSize: '0.7rem', color: '#374151' },
badgeSm: { padding: '1px 6px', borderRadius: '10px', fontSize: '0.7rem', fontWeight: 500 },
btn: { padding: '0.4rem 0.75rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
btnPrimary: { padding: '0.4rem 0.75rem', border: 'none', borderRadius: '4px', background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
btnSmall: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff', cursor: 'pointer', fontSize: '0.75rem' },
btnTiny: { padding: '2px 6px', border: '1px solid #e5e7eb', borderRadius: '3px', background: '#fff', cursor: 'pointer', fontSize: '0.7rem' },
inlineForm: { display: 'flex', gap: '0.5rem', marginBottom: '0.75rem', alignItems: 'center' },
input: { padding: '0.4rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.875rem', flex: 1 },
inputSm: { padding: '0.25rem 0.4rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.8rem' },
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
modal: { background: '#fff', borderRadius: '8px', padding: '1.5rem', width: '90%', maxWidth: '500px' },
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '1rem' },
label: { display: 'block', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.25rem', marginTop: '0.5rem' },
}

View file

@ -0,0 +1,149 @@
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../App'
interface FlagDef {
id: number
name: string
code: string | null
icon: string | null
category_id: number
category_name: string
}
interface MatrixItem {
id: number
display_name: string
flags: Record<string, boolean>
}
interface MatrixDivision {
name: string
items: MatrixItem[]
}
interface MatrixData {
menu_name: string
all_flags: FlagDef[]
divisions: MatrixDivision[]
}
interface Props {
menuId: number
menuName: string
onClose: () => void
}
export default function MenuFlagMatrix({ menuId, menuName, onClose }: Props) {
const { token } = useAuth()
const { data, isLoading } = useQuery<MatrixData>({
queryKey: ['menu-flag-matrix', menuId],
queryFn: async () => {
const res = await fetch(`/api/menus/${menuId}/flags`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed')
return res.json()
},
enabled: !!token,
})
if (isLoading) return (
<div style={styles.overlay} onClick={onClose}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>Loading...</div>
</div>
)
if (!data) return null
// Group flags by category
const categories: Array<{ name: string; flags: FlagDef[] }> = []
const catMap = new Map<string, FlagDef[]>()
for (const f of data.all_flags) {
if (!catMap.has(f.category_name)) catMap.set(f.category_name, [])
catMap.get(f.category_name)!.push(f)
}
for (const [name, flags] of catMap) {
categories.push({ name, flags })
}
return (
<div style={styles.overlay} onClick={onClose}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<div style={styles.printHeader}>
<h3 style={{ margin: 0 }}>{menuName} — Allergen Matrix</h3>
<div style={{ display: 'flex', gap: '0.5rem' }} className="no-print">
<button onClick={() => window.print()} style={styles.btnPrimary}>Print</button>
<button onClick={onClose} style={styles.btn}>Close</button>
</div>
</div>
{data.divisions.length === 0 && <p style={{ color: '#666' }}>No items on this menu.</p>}
{data.divisions.length > 0 && (
<div style={{ overflowX: 'auto' }}>
<table style={styles.table}>
<thead>
<tr>
<th style={{ ...styles.th, minWidth: '180px' }}>Dish</th>
{data.all_flags.map(f => (
<th key={f.id} style={{ ...styles.th, writingMode: 'vertical-lr', textAlign: 'center', fontSize: '0.7rem', padding: '4px 2px', maxWidth: '30px' }}>
{f.code || f.name}
</th>
))}
</tr>
</thead>
<tbody>
{data.divisions.map(div => (
<>
<tr key={`div-${div.name}`}>
<td colSpan={data.all_flags.length + 1} style={styles.sectionRow}>
{div.name}
</td>
</tr>
{div.items.map(item => (
<tr key={item.id}>
<td style={styles.td}>{item.display_name}</td>
{data.all_flags.map(f => (
<td key={f.id} style={{
...styles.td,
textAlign: 'center',
background: item.flags[String(f.id)] ? '#fee2e2' : 'transparent',
color: item.flags[String(f.id)] ? '#dc2626' : '#ccc',
fontWeight: item.flags[String(f.id)] ? 700 : 400,
}}>
{item.flags[String(f.id)] ? 'Y' : '-'}
</td>
))}
</tr>
))}
</>
))}
</tbody>
</table>
</div>
)}
<style>{`
@media print {
.no-print { display: none !important; }
body > * { display: none !important; }
body > div:last-child { display: block !important; }
}
`}</style>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
modal: { background: '#fff', borderRadius: '8px', padding: '1.5rem', width: '95%', maxWidth: '1000px', maxHeight: '90vh', overflow: 'auto' },
printHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
table: { width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' },
th: { padding: '0.4rem', borderBottom: '2px solid #e5e7eb', fontWeight: 600, textAlign: 'left', fontSize: '0.8rem' },
td: { padding: '0.35rem 0.4rem', borderBottom: '1px solid #f3f4f6', fontSize: '0.8rem' },
sectionRow: { padding: '0.5rem', fontWeight: 700, background: '#f9fafb', fontSize: '0.85rem', borderBottom: '1px solid #e5e7eb' },
btn: { padding: '0.4rem 0.75rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
btnPrimary: { padding: '0.4rem 0.75rem', border: 'none', borderRadius: '4px', background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
}

View file

@ -0,0 +1,375 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
import { useNavigate } from 'react-router-dom'
interface MenuListItem {
id: number
name: string
description: string | null
notes: string | null
is_active: boolean
sort_order: number
division_count: number
item_count: number
stale_count: number
created_at: string | null
updated_at: string | null
}
export default function MenuList() {
const { token } = useAuth()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [formName, setFormName] = useState('')
const [formDesc, setFormDesc] = useState('')
const [formNotes, setFormNotes] = useState('')
const [formPreset, setFormPreset] = useState(true)
const [sorting, setSorting] = useState(false)
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const [duplicatingId, setDuplicatingId] = useState<number | null>(null)
const [duplicateName, setDuplicateName] = useState('')
const { data: menus, isLoading } = useQuery<MenuListItem[]>({
queryKey: ['menus'],
queryFn: async () => {
const res = await fetch('/api/menus', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch menus')
return res.json()
},
enabled: !!token,
staleTime: 0,
})
const createMutation = useMutation({
mutationFn: async (data: { name: string; description: string | null; notes: string | null; preset_divisions: boolean }) => {
const res = await fetch('/api/menus', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || 'Failed to create menu')
}
return res.json()
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['menus'] })
setShowCreate(false)
setFormName('')
setFormDesc('')
setFormNotes('')
navigate(`/menus/${data.id}`)
},
})
const toggleActiveMutation = useMutation({
mutationFn: async ({ id, is_active }: { id: number; is_active: boolean }) => {
const res = await fetch(`/api/menus/${id}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active }),
})
if (!res.ok) throw new Error('Failed to update')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['menus'] }),
})
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/menus/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to delete')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['menus'] }),
})
const duplicateMutation = useMutation({
mutationFn: async ({ id, name }: { id: number; name: string }) => {
const res = await fetch(`/api/menus/${id}/duplicate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || 'Failed to duplicate')
}
return res.json()
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['menus'] })
setDuplicatingId(null)
setDuplicateName('')
navigate(`/menus/${data.id}`)
},
})
const reorderMutation = useMutation({
mutationFn: async (ids: number[]) => {
const res = await fetch('/api/menus/reorder', {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
})
if (!res.ok) throw new Error('Failed to reorder')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['menus'] }),
})
const handleDrop = () => {
if (dragIdx === null || dragOverIdx === null || !menus) return
const sorted = [...menus]
const [moved] = sorted.splice(dragIdx, 1)
sorted.splice(dragOverIdx, 0, moved)
reorderMutation.mutate(sorted.map(m => m.id))
setDragIdx(null)
setDragOverIdx(null)
}
const filtered = menus?.filter(m =>
!search || m.name.toLowerCase().includes(search.toLowerCase())
) || []
return (
<div style={styles.container}>
<div style={styles.header}>
<h2 style={styles.title}>Menus</h2>
<div style={styles.headerActions}>
<input
type="text"
placeholder="Search menus..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={styles.searchInput}
/>
{filtered.length > 1 && (
<button
onClick={() => setSorting(!sorting)}
style={{ ...styles.btn, background: sorting ? '#3b82f6' : undefined, color: sorting ? '#fff' : undefined }}
>
{sorting ? 'Done' : 'Sort'}
</button>
)}
<button onClick={() => setShowCreate(true)} style={styles.btnPrimary}>
+ New Menu
</button>
</div>
</div>
{isLoading && <p style={{ padding: '1rem' }}>Loading...</p>}
{!isLoading && filtered.length === 0 && (
<p style={{ padding: '1rem', color: '#666' }}>No menus found. Create your first menu to get started.</p>
)}
<div style={styles.list}>
{filtered.map((menu, idx) => (
<div
key={menu.id}
style={{
...styles.card,
opacity: dragIdx === idx ? 0.4 : 1,
background: dragOverIdx === idx ? '#e0f2fe' : '#fff',
}}
draggable={sorting}
onDragStart={() => { setDragIdx(idx) }}
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(idx) }}
onDrop={handleDrop}
onDragEnd={() => { setDragIdx(null); setDragOverIdx(null) }}
>
{sorting && <span style={styles.dragHandle}>☰</span>}
<div style={styles.cardContent} onClick={() => navigate(`/menus/${menu.id}`)}>
<div style={styles.cardHeader}>
<span style={styles.menuName}>{menu.name}</span>
<span style={{
...styles.badge,
background: menu.is_active ? '#dcfce7' : '#f3f4f6',
color: menu.is_active ? '#166534' : '#6b7280',
}}>
{menu.is_active ? 'Active' : 'Inactive'}
</span>
</div>
{menu.description && <p style={styles.desc}>{menu.description}</p>}
<div style={styles.meta}>
<span>{menu.division_count} section{menu.division_count !== 1 ? 's' : ''}</span>
<span style={{ margin: '0 0.5rem' }}>·</span>
<span>{menu.item_count} dish{menu.item_count !== 1 ? 'es' : ''}</span>
{menu.stale_count > 0 && (
<>
<span style={{ margin: '0 0.5rem' }}>·</span>
<span style={{ background: '#fef3c7', color: '#92400e', padding: '1px 6px', borderRadius: '3px', fontSize: '0.75rem', fontWeight: 600 }}>
{menu.stale_count} need{menu.stale_count === 1 ? 's' : ''} republishing
</span>
</>
)}
</div>
</div>
<div style={styles.cardActions}>
<button
onClick={(e) => { e.stopPropagation(); toggleActiveMutation.mutate({ id: menu.id, is_active: !menu.is_active }) }}
style={styles.btnSmall}
title={menu.is_active ? 'Deactivate' : 'Activate'}
>
{menu.is_active ? 'Deactivate' : 'Activate'}
</button>
<button
onClick={(e) => {
e.stopPropagation()
setDuplicatingId(menu.id)
setDuplicateName(`${menu.name} (Copy)`)
}}
style={styles.btnSmall}
>
Duplicate
</button>
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`Delete "${menu.name}"? This will remove all divisions and items.`))
deleteMutation.mutate(menu.id)
}}
style={{ ...styles.btnSmall, color: '#dc2626' }}
>
Delete
</button>
</div>
</div>
))}
</div>
{/* Create Modal */}
{showCreate && (
<div style={styles.overlay} onClick={() => setShowCreate(false)}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 1rem' }}>New Menu</h3>
<label style={styles.label}>Name *</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
style={styles.input}
placeholder="e.g. Evening Menu"
autoFocus
/>
<label style={styles.label}>Description (customer-facing)</label>
<input
type="text"
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
style={styles.input}
placeholder="e.g. Seasonal tasting menu"
/>
<label style={styles.label}>Internal Notes</label>
<textarea
value={formNotes}
onChange={(e) => setFormNotes(e.target.value)}
style={{ ...styles.input, minHeight: '60px', resize: 'vertical' }}
placeholder="e.g. Available Fri-Sun 6pm-9pm"
/>
<label style={styles.checkboxLabel}>
<input
type="checkbox"
checked={formPreset}
onChange={(e) => setFormPreset(e.target.checked)}
/>
<span style={{ marginLeft: '0.5rem' }}>Pre-load default sections (Starters, Mains, Sides, Desserts)</span>
</label>
<div style={styles.modalActions}>
<button onClick={() => setShowCreate(false)} style={styles.btn}>Cancel</button>
<button
onClick={() => createMutation.mutate({
name: formName.trim(),
description: formDesc.trim() || null,
notes: formNotes.trim() || null,
preset_divisions: formPreset,
})}
style={styles.btnPrimary}
disabled={!formName.trim() || createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Create Menu'}
</button>
</div>
{createMutation.isError && (
<p style={{ color: '#dc2626', marginTop: '0.5rem', fontSize: '0.875rem' }}>
{(createMutation.error as Error).message}
</p>
)}
</div>
</div>
)}
{/* Duplicate Modal */}
{duplicatingId !== null && (
<div style={styles.overlay} onClick={() => setDuplicatingId(null)}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 1rem' }}>Duplicate Menu</h3>
<label style={styles.label}>New menu name *</label>
<input
type="text"
value={duplicateName}
onChange={(e) => setDuplicateName(e.target.value)}
style={styles.input}
autoFocus
/>
<div style={styles.modalActions}>
<button onClick={() => setDuplicatingId(null)} style={styles.btn}>Cancel</button>
<button
onClick={() => duplicateMutation.mutate({ id: duplicatingId, name: duplicateName.trim() })}
style={styles.btnPrimary}
disabled={!duplicateName.trim() || duplicateMutation.isPending}
>
{duplicateMutation.isPending ? 'Duplicating...' : 'Duplicate'}
</button>
</div>
{duplicateMutation.isError && (
<p style={{ color: '#dc2626', marginTop: '0.5rem', fontSize: '0.875rem' }}>
{(duplicateMutation.error as Error).message}
</p>
)}
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
container: { maxWidth: '900px', margin: '0 auto', padding: '1rem' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem' },
title: { margin: 0, fontSize: '1.5rem' },
headerActions: { display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' },
searchInput: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.875rem', width: '200px' },
list: { display: 'flex', flexDirection: 'column', gap: '0.5rem' },
card: {
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.75rem 1rem', border: '1px solid #e5e7eb', borderRadius: '8px',
background: '#fff', cursor: 'pointer', transition: 'box-shadow 0.2s',
},
dragHandle: { cursor: 'grab', fontSize: '1rem', color: '#999' },
cardContent: { flex: 1, minWidth: 0 },
cardHeader: { display: 'flex', alignItems: 'center', gap: '0.5rem' },
menuName: { fontWeight: 600, fontSize: '1rem' },
badge: { padding: '2px 8px', borderRadius: '12px', fontSize: '0.75rem', fontWeight: 500 },
desc: { margin: '0.25rem 0 0', fontSize: '0.875rem', color: '#666' },
meta: { fontSize: '0.8rem', color: '#999', marginTop: '0.25rem' },
cardActions: { display: 'flex', gap: '0.25rem', flexShrink: 0 },
btn: { padding: '0.4rem 0.75rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
btnPrimary: { padding: '0.4rem 0.75rem', border: 'none', borderRadius: '4px', background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
btnSmall: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff', cursor: 'pointer', fontSize: '0.75rem' },
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
modal: { background: '#fff', borderRadius: '8px', padding: '1.5rem', minWidth: '360px', maxWidth: '500px', width: '90%' },
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '1rem' },
label: { display: 'block', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.25rem', marginTop: '0.75rem' },
input: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.875rem', boxSizing: 'border-box' },
checkboxLabel: { display: 'flex', alignItems: 'center', marginTop: '1rem', fontSize: '0.875rem', cursor: 'pointer' },
}

View file

@ -0,0 +1,254 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../App'
interface IngredientChange {
summary: string
date: string | null
ingredient_name: string | null
old_price: number | null
new_price: number | null
unit: string | null
cost_impact: number | null
source_invoice_id: number | null
source_invoice_number: string | null
}
interface ImpactItem {
recipe_id: number
recipe_name: string
recipe_type: string
output_unit: string
current_cost_per_unit: number | null
previous_cost_per_unit: number | null
cost_change: number | null
cost_change_pct: number | null
ingredient_changes: IngredientChange[]
}
function formatIngredientPrice(price: number, unit: string | null): string {
if (unit === 'g' && price < 1) return `\u00A3${(price * 1000).toFixed(2)}/kg`
if (unit === 'ml' && price < 1) return `\u00A3${(price * 1000).toFixed(2)}/ltr`
return `\u00A3${price.toFixed(4)}/${unit || '?'}`
}
interface ImpactData {
days: number
recipes: ImpactItem[]
}
export default function PriceImpact() {
const { token } = useAuth()
const navigate = useNavigate()
const [days, setDays] = useState(14)
const [expandedId, setExpandedId] = useState<number | null>(null)
const [typeFilter, setTypeFilter] = useState<string>('all')
const { data, isLoading } = useQuery<ImpactData>({
queryKey: ['price-impact', days],
queryFn: async () => {
const res = await fetch(`/api/recipes/price-impact?days=${days}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch price impact')
return res.json()
},
enabled: !!token,
})
const filtered = data?.recipes.filter(r => typeFilter === 'all' || r.recipe_type === typeFilter) || []
const dishCount = data?.recipes.filter(r => r.recipe_type === 'dish').length || 0
const componentCount = data?.recipes.filter(r => r.recipe_type === 'component').length || 0
return (
<div style={styles.page}>
<div style={styles.header}>
<h2 style={{ margin: 0 }}>Price Impact Report</h2>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<label style={{ fontSize: '0.85rem', color: '#666' }}>Period:</label>
<select value={days} onChange={e => setDays(Number(e.target.value))} style={styles.select}>
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
</select>
</div>
</div>
{isLoading && <div style={{ padding: '2rem', color: '#888' }}>Loading...</div>}
{data && (
<>
{/* Summary stats */}
<div style={styles.statsBar}>
<span>{data.recipes.length} recipes affected</span>
<span style={{ color: '#888' }}>|</span>
<span>{dishCount} dishes</span>
<span style={{ color: '#888' }}>|</span>
<span>{componentCount} components</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.25rem' }}>
{(['all', 'dish', 'component'] as const).map(t => (
<button
key={t}
onClick={() => setTypeFilter(t)}
style={{
...styles.filterBtn,
...(typeFilter === t ? styles.filterBtnActive : {}),
}}
>
{t === 'all' ? 'All' : t === 'dish' ? 'Dishes' : 'Recipes'}
</button>
))}
</div>
</div>
{filtered.length === 0 ? (
<div style={{ padding: '2rem', textAlign: 'center', color: '#888' }}>
No recipes affected by ingredient price changes in the last {days} days.
</div>
) : (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Recipe</th>
<th style={styles.th}>Type</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Previous Cost</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Current Cost</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Change</th>
<th style={{ ...styles.th, textAlign: 'center' }}>Details</th>
</tr>
</thead>
<tbody>
{filtered.map(r => (
<>
<tr key={r.recipe_id} style={styles.tr}>
<td style={{ ...styles.td, fontWeight: 500 }}>
<span
style={{ cursor: 'pointer', color: '#e94560' }}
onClick={() => navigate(r.recipe_type === 'dish' ? `/dishes/${r.recipe_id}` : `/recipes/${r.recipe_id}`)}
>
{r.recipe_name}
</span>
</td>
<td style={styles.td}>
<span style={{
background: r.recipe_type === 'dish' ? '#e94560' : '#3b82f6',
color: 'white',
padding: '1px 6px',
borderRadius: '4px',
fontSize: '0.7rem',
fontWeight: 600,
}}>
{r.recipe_type.toUpperCase()}
</span>
</td>
<td style={{ ...styles.td, textAlign: 'right', fontFamily: 'monospace' }}>
{r.previous_cost_per_unit != null
? `\u00A3${r.previous_cost_per_unit.toFixed(4)}/${r.output_unit}`
: '-'}
</td>
<td style={{ ...styles.td, textAlign: 'right', fontFamily: 'monospace' }}>
{r.current_cost_per_unit != null
? `\u00A3${r.current_cost_per_unit.toFixed(4)}/${r.output_unit}`
: '-'}
</td>
<td style={{
...styles.td,
textAlign: 'right',
fontFamily: 'monospace',
fontWeight: 600,
color: r.cost_change != null ? (r.cost_change > 0 ? '#dc3545' : r.cost_change < 0 ? '#22c55e' : '#888') : '#888',
}}>
{r.cost_change != null ? (
<>
{r.cost_change > 0 ? '+' : ''}{`\u00A3${r.cost_change.toFixed(4)}`}
{r.cost_change_pct != null && (
<span style={{ fontSize: '0.75rem', marginLeft: '4px' }}>
({r.cost_change_pct > 0 ? '+' : ''}{r.cost_change_pct}%)
</span>
)}
</>
) : '-'}
</td>
<td style={{ ...styles.td, textAlign: 'center' }}>
<button
onClick={() => setExpandedId(expandedId === r.recipe_id ? null : r.recipe_id)}
style={styles.detailBtn}
>
{r.ingredient_changes.length} change{r.ingredient_changes.length !== 1 ? 's' : ''}
{expandedId === r.recipe_id ? ' \u25B4' : ' \u25BE'}
</button>
</td>
</tr>
{expandedId === r.recipe_id && (
<tr key={`${r.recipe_id}-detail`}>
<td colSpan={6} style={{ padding: '0 0.75rem 0.75rem 2rem', background: '#fafafa' }}>
<div style={{ fontSize: '0.8rem', color: '#555' }}>
{r.ingredient_changes.map((c, i) => (
<div key={i} style={{ padding: '3px 0', borderBottom: i < r.ingredient_changes.length - 1 ? '1px solid #eee' : 'none', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>
<span style={{ color: '#888', marginRight: '0.5rem' }}>{c.date}</span>
{c.source_invoice_id && (
<span
style={{ cursor: 'pointer', color: '#e94560', marginRight: '0.5rem', textDecoration: 'underline' }}
onClick={() => navigate(`/invoice/${c.source_invoice_id}`)}
>
{c.source_invoice_number || `#${c.source_invoice_id}`}
</span>
)}
{c.ingredient_name && c.old_price != null && c.new_price != null ? (
<>
{c.ingredient_name} price changed: {formatIngredientPrice(c.old_price, c.unit)} → {formatIngredientPrice(c.new_price, c.unit)}
</>
) : c.ingredient_name && c.new_price != null && c.old_price == null ? (
<>
{c.ingredient_name} price set: {formatIngredientPrice(c.new_price, c.unit)}
</>
) : (
c.summary
)}
</span>
{c.cost_impact != null && (
<span style={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: '0.75rem',
color: c.cost_impact > 0 ? '#dc3545' : c.cost_impact < 0 ? '#22c55e' : '#888',
marginLeft: '1rem',
whiteSpace: 'nowrap',
}}>
{c.cost_impact > 0 ? '+' : ''}{`\u00A3${c.cost_impact.toFixed(4)}`}/{r.output_unit}
</span>
)}
</div>
))}
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
)}
</>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1100px', margin: '0 auto' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' },
select: { padding: '0.4rem 0.6rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.85rem' },
statsBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', padding: '0.5rem 0', marginBottom: '0.5rem', fontSize: '0.85rem', color: '#555' },
filterBtn: { padding: '3px 10px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f5f5', cursor: 'pointer', fontSize: '0.75rem', color: '#555' },
filterBtnActive: { background: '#e94560', color: 'white', borderColor: '#e94560' },
table: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
th: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' },
detailBtn: { padding: '2px 8px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f5f5', cursor: 'pointer', fontSize: '0.75rem', color: '#555' },
}

View file

@ -0,0 +1,372 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import { useAuth } from '../App'
interface Division {
id: number
name: string
}
interface FlagState {
food_flag_id: number
flag_name: string
flag_code: string | null
flag_icon: string | null
category_name: string
propagation_type: string
is_active: boolean
excludable_on_request: boolean
}
interface Props {
menuId?: number
menuName?: string
divisions?: Division[]
preSelectedDivisionId?: number
recipeId?: number // when opened from DishEditor
recipeName?: string
recipeDesc?: string
recipePrice?: number | null
onClose: () => void
onPublished: () => void
}
export default function PublishToMenuModal({
menuId: propMenuId,
divisions: propDivisions,
preSelectedDivisionId,
recipeId: propRecipeId,
recipeName,
recipeDesc,
recipePrice,
onClose,
onPublished,
}: Props) {
const { token, user } = useAuth()
const [step, setStep] = useState<'select' | 'confirm'>('select')
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(propRecipeId || null)
const [selectedMenuId, setSelectedMenuId] = useState<number | null>(propMenuId || null)
const [selectedDivId, setSelectedDivId] = useState<number | null>(preSelectedDivisionId || null)
const [displayName, setDisplayName] = useState(recipeName || '')
const [description, setDescription] = useState(recipeDesc || '')
const [price, setPrice] = useState(recipePrice ? String(recipePrice) : '')
const [confirmedBy, setConfirmedBy] = useState(user?.name || '')
const [dishSearch, setDishSearch] = useState('')
// LLM FEATURE — see LLM-MANIFEST.md for removal instructions
const [aiDescLoading, setAiDescLoading] = useState(false)
const { data: llmSettings } = useQuery<{ llm_enabled: boolean; anthropic_api_key_set: boolean }>({
queryKey: ['settings'],
queryFn: async () => {
const res = await fetch('/api/settings/', { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) return { llm_enabled: false, anthropic_api_key_set: false }
return res.json()
},
staleTime: 60000,
})
const handleGenerateDescription = async () => {
const recipeIdToUse = selectedRecipeId || propRecipeId
if (!recipeIdToUse) return
setAiDescLoading(true)
try {
const res = await fetch('/api/menus/generate-description', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ recipe_id: recipeIdToUse, recipe_name: displayName || recipeName || '', ingredients: [], allergen_flags: [] }),
})
if (res.ok) {
const data = await res.json()
if (data.description) setDescription(data.description)
}
} catch { /* ignore */ }
setAiDescLoading(false)
}
// Fetch dishes list (when opened from MenuEditor, need to pick a dish)
const { data: dishes } = useQuery<Array<{ id: number; name: string; description: string | null; gross_sell_price: number | null }>>({
queryKey: ['dishes-for-menu'],
queryFn: async () => {
const res = await fetch('/api/recipes?recipe_type=dish', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch dishes')
return res.json()
},
enabled: !!token && !propRecipeId,
})
// Fetch menus list (when opened from DishEditor, need to pick a menu)
const { data: menus } = useQuery<Array<{ id: number; name: string; is_active: boolean }>>({
queryKey: ['menus-for-publish'],
queryFn: async () => {
const res = await fetch('/api/menus', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch menus')
return res.json()
},
enabled: !!token && !propMenuId,
})
// Fetch divisions for selected menu (when from DishEditor)
const { data: menuDetail } = useQuery<{ divisions: Division[] }>({
queryKey: ['menu-divisions', selectedMenuId],
queryFn: async () => {
const res = await fetch(`/api/menus/${selectedMenuId}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed')
return res.json()
},
enabled: !!token && !!selectedMenuId && !propMenuId,
})
// Fetch recipe flags when a recipe is selected
const { data: flagData, isLoading: flagsLoading } = useQuery<{
flags: FlagState[]
unassessed_ingredients: Array<{ id: number; name: string; category: string }>
}>({
queryKey: ['recipe-flags-for-publish', selectedRecipeId],
queryFn: async () => {
const res = await fetch(`/api/food-flags/recipes/${selectedRecipeId}/flags`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed')
return res.json()
},
enabled: !!token && !!selectedRecipeId,
})
const divisions = propDivisions || menuDetail?.divisions || []
const activeMenus = (menus || []).filter(m => m.is_active)
const unassessed = flagData?.unassessed_ingredients || []
const activeFlags = (flagData?.flags || []).filter(f => f.is_active)
const hasUnassessed = unassessed.length > 0
// When a dish is selected (from dish picker), populate fields
useEffect(() => {
if (selectedRecipeId && dishes) {
const dish = dishes.find(d => d.id === selectedRecipeId)
if (dish) {
setDisplayName(dish.name)
setDescription(dish.description || '')
setPrice(dish.gross_sell_price ? String(dish.gross_sell_price) : '')
}
}
}, [selectedRecipeId, dishes])
const publishMutation = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/menus/${selectedMenuId}/items`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
recipe_id: selectedRecipeId,
division_id: selectedDivId,
display_name: displayName.trim(),
description: description.trim() || null,
price: price ? parseFloat(price) : null,
confirmed_by_name: confirmedBy.trim(),
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(typeof err.detail === 'string' ? err.detail : err.detail?.message || 'Failed to publish')
}
return res.json()
},
onSuccess: () => onPublished(),
})
const filteredDishes = (dishes || []).filter(d =>
!dishSearch || d.name.toLowerCase().includes(dishSearch.toLowerCase())
)
return (
<div style={styles.overlay} onClick={onClose}>
<div style={{ ...styles.modal, maxWidth: '550px' }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 1rem' }}>
{step === 'select' ? 'Publish Dish to Menu' : 'Confirm Allergens & Publish'}
</h3>
{step === 'select' && (
<>
{/* Dish selection (when from MenuEditor) */}
{!propRecipeId && (
<>
<label style={styles.label}>Select Dish *</label>
<input
type="text"
value={dishSearch}
onChange={(e) => setDishSearch(e.target.value)}
style={styles.input}
placeholder="Search dishes..."
/>
<div style={{ maxHeight: '200px', overflow: 'auto', border: '1px solid #eee', borderRadius: '4px', marginTop: '0.25rem' }}>
{filteredDishes.map(dish => (
<div
key={dish.id}
onClick={() => setSelectedRecipeId(dish.id)}
style={{
padding: '0.5rem',
cursor: 'pointer',
background: selectedRecipeId === dish.id ? '#eff6ff' : '#fff',
borderBottom: '1px solid #f3f4f6',
}}
>
<span style={{ fontWeight: selectedRecipeId === dish.id ? 600 : 400 }}>{dish.name}</span>
</div>
))}
{filteredDishes.length === 0 && <p style={{ padding: '0.5rem', color: '#999' }}>No dishes found</p>}
</div>
</>
)}
{/* Menu selection (when from DishEditor) */}
{!propMenuId && (
<>
<label style={styles.label}>Menu *</label>
<select
value={selectedMenuId || ''}
onChange={(e) => { setSelectedMenuId(parseInt(e.target.value)); setSelectedDivId(null) }}
style={styles.input}
>
<option value="">Select a menu...</option>
{activeMenus.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</>
)}
{/* Division selection */}
{divisions.length > 0 && (
<>
<label style={styles.label}>Section *</label>
<select
value={selectedDivId || ''}
onChange={(e) => setSelectedDivId(parseInt(e.target.value))}
style={styles.input}
>
<option value="">Select section...</option>
{divisions.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</>
)}
<div style={styles.modalActions}>
<button onClick={onClose} style={styles.btn}>Cancel</button>
<button
onClick={() => setStep('confirm')}
style={styles.btnPrimary}
disabled={!selectedRecipeId || !selectedMenuId || !selectedDivId}
>
Next: Review Allergens
</button>
</div>
</>
)}
{step === 'confirm' && (
<>
{flagsLoading && <p>Loading allergen data...</p>}
{!flagsLoading && hasUnassessed && (
<div style={{ background: '#fee2e2', padding: '0.75rem', borderRadius: '6px', marginBottom: '1rem' }}>
<strong style={{ color: '#dc2626' }}>Cannot publish: unassessed ingredients</strong>
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem' }}>
{unassessed.map((u, i) => <li key={i} style={{ fontSize: '0.875rem' }}>{u.name} — {u.category}</li>)}
</ul>
</div>
)}
{!flagsLoading && !hasUnassessed && (
<>
{activeFlags.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<label style={styles.label}>Confirmed Allergens</label>
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
{activeFlags.map(f => (
<span key={f.food_flag_id} style={{
padding: '2px 8px', borderRadius: '4px', fontSize: '0.8rem',
background: f.propagation_type === 'contains' ? '#fee2e2' : '#dcfce7',
color: f.propagation_type === 'contains' ? '#991b1b' : '#166534',
}}>
{f.flag_code || f.flag_name}
{f.excludable_on_request && ' *'}
</span>
))}
</div>
</div>
)}
<label style={styles.label}>Display Name *</label>
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} style={styles.input} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label style={styles.label}>Description</label>
{/* LLM FEATURE — see LLM-MANIFEST.md for removal instructions */}
{llmSettings?.llm_enabled && llmSettings?.anthropic_api_key_set && (
<button
onClick={handleGenerateDescription}
disabled={aiDescLoading}
style={{ padding: '0.2rem 0.6rem', background: '#7952b3', color: 'white', border: 'none', borderRadius: '4px', cursor: aiDescLoading ? 'wait' : 'pointer', fontSize: '0.75rem', opacity: aiDescLoading ? 0.7 : 1 }}
>
{aiDescLoading ? 'Generating...' : '\u2728 Generate'}
</button>
)}
</div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
style={{ ...styles.input, minHeight: '60px', resize: 'vertical' }}
/>
<label style={styles.label}>Price</label>
<input
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
style={{ ...styles.input, width: '120px' }}
/>
<label style={styles.label}>Confirmed by *</label>
<input value={confirmedBy} onChange={(e) => setConfirmedBy(e.target.value)} style={styles.input} />
</>
)}
<div style={styles.modalActions}>
<button onClick={() => setStep('select')} style={styles.btn}>Back</button>
<button onClick={onClose} style={styles.btn}>Cancel</button>
{!hasUnassessed && (
<button
onClick={() => publishMutation.mutate()}
style={styles.btnPrimary}
disabled={!displayName.trim() || !confirmedBy.trim() || publishMutation.isPending}
>
{publishMutation.isPending ? 'Publishing...' : 'Publish to Menu'}
</button>
)}
</div>
{publishMutation.isError && (
<p style={{ color: '#dc2626', marginTop: '0.5rem', fontSize: '0.875rem' }}>
{(publishMutation.error as Error).message}
</p>
)}
</>
)}
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
modal: { background: '#fff', borderRadius: '8px', padding: '1.5rem', width: '90%', maxHeight: '90vh', overflow: 'auto' },
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '1rem' },
label: { display: 'block', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.25rem', marginTop: '0.75rem' },
input: { width: '100%', padding: '0.4rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '0.875rem', boxSizing: 'border-box' },
btn: { padding: '0.4rem 0.75rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
btnPrimary: { padding: '0.4rem 0.75rem', border: 'none', borderRadius: '4px', background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: '0.8rem' },
}

View file

@ -0,0 +1,283 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../App'
import PurchaseOrderModal from './PurchaseOrderModal'
interface PurchaseOrderSummary {
id: number
supplier_id: number
supplier_name: string | null
order_date: string
order_type: string
status: string
total_amount: number | null
order_reference: string | null
created_by_name: string | null
created_at: string
}
const statusColors: Record<string, { bg: string; color: string }> = {
DRAFT: { bg: '#e0e0e0', color: '#555' },
PENDING: { bg: '#e3f2fd', color: '#1565c0' },
LINKED: { bg: '#d4edda', color: '#155724' },
CLOSED: { bg: '#f5f5f5', color: '#666' },
CANCELLED: { bg: '#ffebee', color: '#c62828' },
}
export default function PurchaseOrderList() {
const { token } = useAuth()
const [statusFilter, setStatusFilter] = useState('DRAFT,PENDING')
const [supplierFilter, setSupplierFilter] = useState('')
const [editPoId, setEditPoId] = useState<number | null>(null)
const [showNewModal, setShowNewModal] = useState(false)
// Fetch suppliers for filter
const { data: suppliersData } = useQuery<{ suppliers: Array<{ id: number; name: string }> }>({
queryKey: ['suppliers'],
queryFn: async () => {
const res = await fetch('/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json()
},
enabled: !!token,
})
// Build query string
const params = new URLSearchParams()
if (statusFilter) params.append('status', statusFilter)
if (supplierFilter) params.append('supplier_id', supplierFilter)
const { data: poList, refetch } = useQuery<PurchaseOrderSummary[]>({
queryKey: ['purchase-orders', statusFilter, supplierFilter],
queryFn: async () => {
const res = await fetch(`/api/purchase-orders/?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch purchase orders')
return res.json()
},
enabled: !!token,
})
const formatDate = (d: string) => {
const dt = new Date(d + 'T00:00:00')
return dt.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
}
return (
<div>
<div style={styles.headerRow}>
<h2 style={styles.pageTitle}>Purchase Orders</h2>
<button style={styles.newBtn} onClick={() => setShowNewModal(true)}>+ New PO</button>
</div>
{/* Filter bar */}
<div style={styles.filterBar}>
<div style={styles.statusTabs}>
{[
{ label: 'Open', value: 'DRAFT,PENDING' },
{ label: 'All', value: '' },
{ label: 'Draft', value: 'DRAFT' },
{ label: 'Pending', value: 'PENDING' },
{ label: 'Linked', value: 'LINKED' },
{ label: 'Closed', value: 'CLOSED' },
].map(tab => (
<button
key={tab.value}
style={{
...styles.filterTab,
...(statusFilter === tab.value ? styles.filterTabActive : {}),
}}
onClick={() => setStatusFilter(tab.value)}
>
{tab.label}
</button>
))}
</div>
<select
value={supplierFilter}
onChange={e => setSupplierFilter(e.target.value)}
style={styles.filterSelect}
>
<option value="">All Suppliers</option>
{suppliersData?.suppliers?.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
{/* Table */}
<div style={styles.tableContainer}>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Date</th>
<th style={styles.th}>Supplier</th>
<th style={styles.th}>Type</th>
<th style={styles.th}>Reference</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Total</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>Created</th>
</tr>
</thead>
<tbody>
{(!poList || poList.length === 0) ? (
<tr>
<td colSpan={7} style={styles.emptyTd}>No purchase orders found</td>
</tr>
) : (
poList.map(po => {
const sc = statusColors[po.status] || statusColors.DRAFT
return (
<tr
key={po.id}
style={styles.row}
onClick={() => setEditPoId(po.id)}
>
<td style={styles.td}>{formatDate(po.order_date)}</td>
<td style={styles.td}>{po.supplier_name || '-'}</td>
<td style={styles.td}>
<span style={styles.typeBadge}>
{po.order_type === 'itemised' ? 'Itemised' : 'Single Value'}
</span>
</td>
<td style={styles.td}>{po.order_reference || '-'}</td>
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 500 }}>
{po.total_amount != null ? `\u00A3${po.total_amount.toFixed(2)}` : '-'}
</td>
<td style={styles.td}>
<span style={{
...styles.statusBadge,
background: sc.bg,
color: sc.color,
}}>
{po.status}
</span>
</td>
<td style={{ ...styles.td, fontSize: '0.8rem', color: '#888' }}>
{po.created_by_name || ''}
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{/* Modals */}
<PurchaseOrderModal
isOpen={!!editPoId}
onClose={() => setEditPoId(null)}
onSaved={() => refetch()}
poId={editPoId}
/>
<PurchaseOrderModal
isOpen={showNewModal}
onClose={() => setShowNewModal(false)}
onSaved={() => refetch()}
/>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
headerRow: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
},
pageTitle: {
margin: 0,
color: '#1a1a2e',
},
newBtn: {
padding: '0.6rem 1.2rem',
background: '#1a1a2e',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 600,
fontSize: '0.9rem',
},
filterBar: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1rem',
gap: '1rem',
flexWrap: 'wrap',
},
statusTabs: {
display: 'flex',
gap: '0.25rem',
},
filterTab: {
padding: '0.4rem 0.75rem',
border: '1px solid #ddd',
borderRadius: '6px',
background: 'white',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 500,
},
filterTabActive: {
background: '#1a1a2e',
color: 'white',
borderColor: '#1a1a2e',
},
filterSelect: {
padding: '0.4rem 0.75rem',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '0.85rem',
},
tableContainer: {
background: 'white',
borderRadius: '8px',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
overflow: 'auto',
},
table: {
width: '100%',
borderCollapse: 'collapse',
},
th: {
textAlign: 'left',
padding: '0.75rem 1rem',
borderBottom: '2px solid #eee',
fontWeight: 600,
color: '#666',
fontSize: '0.85rem',
whiteSpace: 'nowrap',
},
td: {
padding: '0.75rem 1rem',
borderBottom: '1px solid #f0f0f0',
fontSize: '0.9rem',
},
emptyTd: {
padding: '2rem',
textAlign: 'center',
color: '#999',
},
row: {
cursor: 'pointer',
transition: 'background 0.15s',
},
statusBadge: {
display: 'inline-block',
padding: '0.2rem 0.6rem',
borderRadius: '12px',
fontSize: '0.75rem',
fontWeight: 600,
},
typeBadge: {
fontSize: '0.8rem',
color: '#666',
},
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,918 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../App'
interface SupplierBreakdown {
supplier_id: number | null
supplier_name: string
net_purchases: number
percentage: number
}
interface PurchasesSummaryResponse {
from_date: string
to_date: string
period_label: string
total_purchases: number
supplier_breakdown: SupplierBreakdown[]
}
interface DailySupplierDataPoint {
date: string
supplier_id: number | null
supplier_name: string
net_purchases: number
}
interface DailySupplierChartResponse {
from_date: string
to_date: string
suppliers: string[]
data: DailySupplierDataPoint[]
}
interface TopLineItem {
description: string
product_code: string | null
total_quantity: number
total_value: number
avg_unit_price: number
occurrence_count: number
}
interface TopItemsResponse {
from_date: string
to_date: string
top_by_quantity: TopLineItem[]
top_by_value: TopLineItem[]
}
// Helper to format date as YYYY-MM-DD for input fields and API
const formatDate = (d: Date): string => {
return d.toISOString().split('T')[0]
}
// Get the start and end of a given month
const getMonthBounds = (year: number, month: number): { start: string; end: string } => {
const start = new Date(year, month, 1)
const end = new Date(year, month + 1, 0) // Last day of month
return { start: formatDate(start), end: formatDate(end) }
}
// Helper to get first line of description (before newline)
const getFirstLineOfDescription = (description: string | null): string => {
if (!description) return ''
return description.split('\n')[0].trim()
}
// Generate list of months for the picker (last 12 months + next 2 months)
const getMonthOptions = (): { label: string; year: number; month: number }[] => {
const options: { label: string; year: number; month: number }[] = []
const today = new Date()
for (let i = -12; i <= 2; i++) {
const d = new Date(today.getFullYear(), today.getMonth() + i, 1)
const label = d.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
options.push({ label, year: d.getFullYear(), month: d.getMonth() })
}
return options.reverse()
}
// Session storage keys
const STORAGE_KEY_FROM = 'purchases-report-from-date'
const STORAGE_KEY_TO = 'purchases-report-to-date'
// Get initial dates
const getInitialDates = () => {
const storedFrom = sessionStorage.getItem(STORAGE_KEY_FROM)
const storedTo = sessionStorage.getItem(STORAGE_KEY_TO)
if (storedFrom && storedTo) {
return { from: storedFrom, to: storedTo }
}
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const thirtyDaysAgo = new Date(yesterday)
thirtyDaysAgo.setDate(yesterday.getDate() - 30)
return { from: formatDate(thirtyDaysAgo), to: formatDate(yesterday) }
}
// Color palette for suppliers
const SUPPLIER_COLORS = [
'#e94560', // Red
'#0ea5e9', // Blue
'#22c55e', // Green
'#f59e0b', // Amber
'#8b5cf6', // Purple
'#ec4899', // Pink
'#14b8a6', // Teal
'#f97316', // Orange
'#6366f1', // Indigo
'#84cc16', // Lime
]
export default function PurchasesReport() {
const { token } = useAuth()
const initialDates = getInitialDates()
const [fromDate, setFromDate] = useState(initialDates.from)
const [toDate, setToDate] = useState(initialDates.to)
const [selectedMonth, setSelectedMonth] = useState<string>('')
const [selectionMode, setSelectionMode] = useState<'last30' | 'week' | 'month' | 'custom'>('custom')
const [submittedFromDate, setSubmittedFromDate] = useState(initialDates.from)
const [submittedToDate, setSubmittedToDate] = useState(initialDates.to)
const [topItemsSupplierFilter, setTopItemsSupplierFilter] = useState<number | null>(null)
useEffect(() => {
sessionStorage.setItem(STORAGE_KEY_FROM, submittedFromDate)
sessionStorage.setItem(STORAGE_KEY_TO, submittedToDate)
}, [submittedFromDate, submittedToDate])
const monthOptions = getMonthOptions()
const hasUnsavedChanges = fromDate !== submittedFromDate || toDate !== submittedToDate
const handleGenerate = () => {
setSubmittedFromDate(fromDate)
setSubmittedToDate(toDate)
}
useEffect(() => {
if (selectedMonth) {
const [year, month] = selectedMonth.split('-').map(Number)
const bounds = getMonthBounds(year, month)
setFromDate(bounds.start)
setToDate(bounds.end)
setSubmittedFromDate(bounds.start)
setSubmittedToDate(bounds.end)
}
}, [selectedMonth])
const handleFromDateChange = (value: string) => {
setFromDate(value)
if (value > toDate) {
setToDate(value)
}
setSelectedMonth('')
setSelectionMode('custom')
}
const handleToDateChange = (value: string) => {
setToDate(value)
if (value < fromDate) {
setFromDate(value)
}
setSelectedMonth('')
setSelectionMode('custom')
}
const handleMonthChange = (value: string) => {
setSelectedMonth(value)
if (value) {
setSelectionMode('month')
} else {
setSelectionMode('custom')
}
}
const setLast30Days = () => {
const end = new Date()
end.setDate(end.getDate() - 1)
const start = new Date(end)
start.setDate(end.getDate() - 30)
const startStr = formatDate(start)
const endStr = formatDate(end)
setFromDate(startStr)
setToDate(endStr)
setSubmittedFromDate(startStr)
setSubmittedToDate(endStr)
setSelectedMonth('')
setSelectionMode('last30')
}
const setThisMonth = () => {
const now = new Date()
const monthKey = `${now.getFullYear()}-${now.getMonth()}`
const bounds = getMonthBounds(now.getFullYear(), now.getMonth())
setFromDate(bounds.start)
setToDate(bounds.end)
setSubmittedFromDate(bounds.start)
setSubmittedToDate(bounds.end)
setSelectedMonth(monthKey)
setSelectionMode('month')
}
const setLastMonth = () => {
const now = new Date()
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const monthKey = `${lastMonth.getFullYear()}-${lastMonth.getMonth()}`
const bounds = getMonthBounds(lastMonth.getFullYear(), lastMonth.getMonth())
setFromDate(bounds.start)
setToDate(bounds.end)
setSubmittedFromDate(bounds.start)
setSubmittedToDate(bounds.end)
setSelectedMonth(monthKey)
setSelectionMode('month')
}
const setThisWeek = () => {
const today = new Date()
const dayOfWeek = today.getDay()
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
const end = new Date()
end.setDate(end.getDate() - 1)
const startStr = formatDate(monday)
const endStr = formatDate(end)
setFromDate(startStr)
setToDate(endStr)
setSubmittedFromDate(startStr)
setSubmittedToDate(endStr)
setSelectedMonth('')
setSelectionMode('week')
}
const setLastWeek = () => {
const today = new Date()
const dayOfWeek = today.getDay()
const lastMonday = new Date(today)
lastMonday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1) - 7)
const lastSunday = new Date(lastMonday)
lastSunday.setDate(lastMonday.getDate() + 6)
const startStr = formatDate(lastMonday)
const endStr = formatDate(lastSunday)
setFromDate(startStr)
setToDate(endStr)
setSubmittedFromDate(startStr)
setSubmittedToDate(endStr)
setSelectedMonth('')
setSelectionMode('week')
}
const getPeriodPrefix = (): string => {
if (selectionMode === 'last30') {
return 'Last 30 Days: '
} else if (selectionMode === 'week') {
return 'Week: '
} else if (selectionMode === 'month' && selectedMonth) {
const [year, month] = selectedMonth.split('-').map(Number)
const monthName = new Date(year, month, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
return `Month of ${monthName}: `
} else {
return 'Custom Dates: '
}
}
// Fetch purchases summary
const { data: summary, isLoading, error } = useQuery<PurchasesSummaryResponse>({
queryKey: ['purchases-summary', submittedFromDate, submittedToDate],
queryFn: async () => {
const res = await fetch(`/api/reports/purchases/summary?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch purchases summary')
return res.json()
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
})
// Fetch daily supplier data for chart
const { data: chartData } = useQuery<DailySupplierChartResponse>({
queryKey: ['purchases-daily-supplier', submittedFromDate, submittedToDate],
queryFn: async () => {
const res = await fetch(`/api/reports/purchases/daily-by-supplier?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch chart data')
return res.json()
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
})
// Fetch top items (with optional supplier filter)
const { data: topItems } = useQuery<TopItemsResponse>({
queryKey: ['purchases-top-items', submittedFromDate, submittedToDate, topItemsSupplierFilter],
queryFn: async () => {
let url = `/api/reports/purchases/top-items?from_date=${submittedFromDate}&to_date=${submittedToDate}`
if (topItemsSupplierFilter !== null) {
url += `&supplier_id=${topItemsSupplierFilter}`
}
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch top items')
return res.json()
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
})
const formatCurrency = (value: number, showCR = false) => {
const num = Number(value)
if (num < 0 && showCR) {
return `-£${Math.abs(num).toFixed(2)} CR`
}
return `£${num.toFixed(2)}`
}
// Helper to get Monday of a given week
const getWeekStart = (dateStr: string): string => {
const d = new Date(dateStr)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1) // Adjust for Monday
const monday = new Date(d.setDate(diff))
return monday.toISOString().split('T')[0]
}
// Weekly bar chart for suppliers
const renderChart = () => {
if (!chartData?.data?.length || !chartData?.suppliers?.length) {
return <div style={styles.noData}>No chart data available</div>
}
const width = 650
const height = 280
const padding = { top: 20, right: 20, bottom: 40, left: 60 }
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
const suppliers = chartData.suppliers
const supplierColorMap: Record<string, string> = {}
suppliers.forEach((s, i) => {
supplierColorMap[s] = SUPPLIER_COLORS[i % SUPPLIER_COLORS.length]
})
// Group data by week (Mon-Sun)
const weeklyData: Record<string, Record<string, number>> = {}
chartData.data.forEach(point => {
const weekStart = getWeekStart(point.date)
if (!weeklyData[weekStart]) {
weeklyData[weekStart] = {}
suppliers.forEach(s => { weeklyData[weekStart][s] = 0 })
}
weeklyData[weekStart][point.supplier_name] =
(weeklyData[weekStart][point.supplier_name] || 0) + (Number(point.net_purchases) || 0)
})
const weeks = Object.keys(weeklyData).sort()
if (weeks.length === 0) {
return <div style={styles.noData}>No chart data available</div>
}
// Find max value across all weeks and suppliers
let maxValue = 1
weeks.forEach(week => {
suppliers.forEach(s => {
const val = weeklyData[week][s] || 0
if (val > maxValue) maxValue = val
})
})
// Scale functions
const weekWidth = chartWidth / weeks.length
const barGroupWidth = weekWidth * 0.8
const barWidth = Math.min(barGroupWidth / suppliers.length, 25)
const yScale = (value: number) => padding.top + chartHeight - (value / maxValue) * chartHeight
// Y-axis labels
const yLabels = [0, maxValue / 2, maxValue].map(v => ({
value: v,
y: yScale(v),
label: `£${(Number(v) / 1000).toFixed(Number(v) >= 1000 ? 0 : 1)}k`
}))
return (
<div style={styles.chartContainer}>
<h4 style={styles.chartTitle}>Weekly Purchases by Supplier</h4>
<svg width={width} height={height} style={styles.chartSvg}>
{/* Horizontal grid lines */}
{yLabels.map((l, i) => (
<g key={i}>
<line
x1={padding.left}
y1={l.y}
x2={width - padding.right}
y2={l.y}
stroke="#eee"
strokeDasharray="4,4"
/>
<text x={padding.left - 5} y={l.y + 4} textAnchor="end" fontSize="10" fill="#666">
{l.label}
</text>
</g>
))}
{/* Bars for each week */}
{weeks.map((week, weekIndex) => {
const weekCenterX = padding.left + (weekIndex + 0.5) * weekWidth
const groupStartX = weekCenterX - (suppliers.length * barWidth) / 2
return (
<g key={week}>
{/* X-axis label for week */}
<text
x={weekCenterX}
y={height - 8}
textAnchor="middle"
fontSize="9"
fill="#666"
>
{new Date(week).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
</text>
{/* Bars for each supplier */}
{suppliers.map((supplier, supplierIndex) => {
const value = weeklyData[week][supplier] || 0
if (value <= 0) return null
const barHeight = (value / maxValue) * chartHeight
const barX = groupStartX + supplierIndex * barWidth
return (
<rect
key={`${week}-${supplier}`}
x={barX}
y={padding.top + chartHeight - barHeight}
width={barWidth - 1}
height={barHeight}
fill={supplierColorMap[supplier]}
/>
)
})}
</g>
)
})}
</svg>
<div style={styles.chartLegend}>
{suppliers.map((supplier) => (
<span key={supplier} style={styles.legendItem}>
<span style={{ ...styles.legendColor, background: supplierColorMap[supplier] }} />
{supplier}
</span>
))}
</div>
</div>
)
}
if (isLoading) {
return <div style={styles.loading}>Loading purchases data...</div>
}
if (error) {
return <div style={styles.error}>Error loading purchases data: {(error as Error).message}</div>
}
return (
<div>
{/* Header */}
<div style={styles.header}>
<h2 style={styles.title}>Purchases Report</h2>
</div>
{/* Date Selection Controls */}
<div style={styles.dateControls}>
<div style={styles.dateRow}>
<div style={styles.dateField}>
<label style={styles.dateLabel}>From</label>
<input
type="date"
value={fromDate}
onChange={(e) => handleFromDateChange(e.target.value)}
style={styles.dateInput}
/>
</div>
<div style={styles.dateField}>
<label style={styles.dateLabel}>To</label>
<input
type="date"
value={toDate}
onChange={(e) => handleToDateChange(e.target.value)}
style={styles.dateInput}
/>
</div>
<div style={styles.dateField}>
<label style={styles.dateLabel}>Month</label>
<select
value={selectedMonth}
onChange={(e) => handleMonthChange(e.target.value)}
style={styles.monthSelect}
>
<option value="">Custom Range</option>
{monthOptions.map((opt) => (
<option key={`${opt.year}-${opt.month}`} value={`${opt.year}-${opt.month}`}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div style={styles.presetRow}>
<button onClick={setLast30Days} style={styles.presetBtn}>Last 30 Days</button>
<button onClick={setThisWeek} style={styles.presetBtn}>This Week</button>
<button onClick={setLastWeek} style={styles.presetBtn}>Last Week</button>
<button onClick={setThisMonth} style={styles.presetBtn}>This Month</button>
<button onClick={setLastMonth} style={styles.presetBtn}>Last Month</button>
<button
onClick={handleGenerate}
disabled={!hasUnsavedChanges}
style={{
...styles.generateBtn,
...(hasUnsavedChanges ? {} : styles.generateBtnDisabled),
}}
>
{hasUnsavedChanges ? 'Generate Report' : 'Report Generated'}
</button>
</div>
</div>
{/* Period Label */}
<div style={styles.periodLabel}>{getPeriodPrefix()}{summary?.period_label}</div>
{/* Main Content */}
<div style={styles.mainContent}>
{/* Supplier Breakdown */}
<div style={styles.sectionContainer}>
<h3 style={styles.sectionTitle}>Supplier Breakdown</h3>
<div style={styles.totalRow}>
<span>Total Purchases:</span>
<span style={{
...styles.totalValue,
...((summary?.total_purchases || 0) < 0 ? { color: '#28a745' } : {})
}}>
{formatCurrency(summary?.total_purchases || 0, true)}
</span>
</div>
<table style={styles.breakdownTable}>
<thead>
<tr>
<th style={styles.tableHeader}>Supplier</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Net Purchases</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>%</th>
</tr>
</thead>
<tbody>
{summary?.supplier_breakdown?.length ? (
summary.supplier_breakdown.map((supplier, index) => {
const isCredit = supplier.net_purchases < 0
return (
<tr key={supplier.supplier_id || `unknown-${index}`} style={isCredit ? { background: '#d4edda' } : {}}>
<td style={styles.tableCell}>
{supplier.supplier_name}
{isCredit && <span style={{ color: '#28a745', fontWeight: 'bold', marginLeft: '0.5rem' }}>(CR)</span>}
</td>
<td style={{
...styles.tableCell,
textAlign: 'right',
fontFamily: 'monospace',
...(isCredit ? { color: '#28a745', fontWeight: 'bold' } : {})
}}>
{formatCurrency(supplier.net_purchases, true)}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace' }}>
{Number(supplier.percentage).toFixed(1)}%
</td>
</tr>
)
})
) : (
<tr>
<td colSpan={3} style={{ ...styles.tableCell, textAlign: 'center', color: '#999' }}>
No supplier data
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Chart */}
<div style={styles.sectionContainer}>
{renderChart()}
</div>
</div>
{/* Top Items Section with Supplier Filter */}
<div style={styles.topItemsFilterRow}>
<span style={styles.filterLabel}>Filter by Supplier:</span>
<select
value={topItemsSupplierFilter === null ? '' : topItemsSupplierFilter}
onChange={(e) => setTopItemsSupplierFilter(e.target.value === '' ? null : Number(e.target.value))}
style={styles.supplierSelect}
>
<option value="">All Suppliers</option>
{summary?.supplier_breakdown?.map((s: SupplierBreakdown) => (
<option key={s.supplier_id ?? 'unknown'} value={s.supplier_id ?? ''}>
{s.supplier_name}
</option>
))}
</select>
</div>
{/* Top Items Tables */}
<div style={styles.breakdownRow}>
{/* Top by Quantity */}
<div style={styles.breakdownContainer}>
<h3 style={styles.sectionTitle}>Top 10 Line Items by Quantity</h3>
<table style={styles.breakdownTable}>
<thead>
<tr>
<th style={styles.tableHeader}>#</th>
<th style={styles.tableHeader}>Item</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Qty</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Value</th>
</tr>
</thead>
<tbody>
{topItems?.top_by_quantity?.length ? (
topItems.top_by_quantity.map((item, index) => (
<tr key={`qty-${index}`}>
<td style={{ ...styles.tableCell, color: '#999', width: '30px' }}>{index + 1}</td>
<td style={styles.tableCell}>{getFirstLineOfDescription(item.description)}</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', fontWeight: 'bold' }}>
{Number(item.total_quantity).toFixed(1)}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', color: '#666' }}>
{formatCurrency(item.total_value)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} style={{ ...styles.tableCell, textAlign: 'center', color: '#999' }}>
No data
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Top by Value */}
<div style={styles.breakdownContainer}>
<h3 style={styles.sectionTitle}>Top 10 Line Items by Value</h3>
<table style={styles.breakdownTable}>
<thead>
<tr>
<th style={styles.tableHeader}>#</th>
<th style={styles.tableHeader}>Item</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Value</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Qty</th>
</tr>
</thead>
<tbody>
{topItems?.top_by_value?.length ? (
topItems.top_by_value.map((item, index) => (
<tr key={`val-${index}`}>
<td style={{ ...styles.tableCell, color: '#999', width: '30px' }}>{index + 1}</td>
<td style={styles.tableCell}>{getFirstLineOfDescription(item.description)}</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', fontWeight: 'bold' }}>
{formatCurrency(item.total_value)}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', color: '#666' }}>
{Number(item.total_quantity).toFixed(1)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} style={{ ...styles.tableCell, textAlign: 'center', color: '#999' }}>
No data
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
loading: {
padding: '2rem',
textAlign: 'center',
color: '#666',
},
error: {
padding: '2rem',
textAlign: 'center',
color: '#c00',
background: '#fee',
borderRadius: '8px',
},
noData: {
padding: '2rem',
textAlign: 'center',
color: '#999',
},
header: {
marginBottom: '1.5rem',
},
title: {
color: '#1a1a2e',
margin: 0,
},
dateControls: {
background: 'white',
borderRadius: '12px',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
marginBottom: '1rem',
},
dateRow: {
display: 'flex',
gap: '1.5rem',
flexWrap: 'wrap',
marginBottom: '1rem',
},
dateField: {
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
},
dateLabel: {
fontSize: '0.85rem',
color: '#666',
fontWeight: 500,
},
dateInput: {
padding: '0.5rem 0.75rem',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '1rem',
minWidth: '150px',
},
monthSelect: {
padding: '0.5rem 0.75rem',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '1rem',
minWidth: '180px',
background: 'white',
},
presetRow: {
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
},
presetBtn: {
padding: '0.4rem 0.75rem',
background: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.85rem',
},
generateBtn: {
padding: '0.4rem 1rem',
background: '#e94560',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: 'bold',
marginLeft: 'auto',
},
generateBtnDisabled: {
background: '#ccc',
cursor: 'default',
},
periodLabel: {
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#1a1a2e',
marginBottom: '1rem',
textAlign: 'center',
},
mainContent: {
display: 'flex',
gap: '1.5rem',
flexWrap: 'wrap',
alignItems: 'stretch',
},
sectionContainer: {
background: 'white',
borderRadius: '12px',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
marginBottom: '2rem',
flex: '1 1 auto',
minWidth: '300px',
},
sectionTitle: {
margin: '0 0 1rem 0',
color: '#1a1a2e',
},
totalRow: {
display: 'flex',
justifyContent: 'space-between',
padding: '0.75rem',
background: '#f8f9fa',
borderRadius: '6px',
marginBottom: '1rem',
fontWeight: 600,
},
totalValue: {
fontFamily: 'monospace',
color: '#e94560',
},
breakdownTable: {
width: '100%',
borderCollapse: 'collapse',
},
tableHeader: {
textAlign: 'left',
padding: '0.75rem 0.5rem',
borderBottom: '2px solid #dee2e6',
color: '#1a1a2e',
fontWeight: 600,
fontSize: '0.85rem',
},
tableCell: {
padding: '0.5rem',
borderBottom: '1px solid #f0f0f0',
fontSize: '0.9rem',
},
chartContainer: {
width: '100%',
},
chartTitle: {
margin: '0 0 1rem 0',
color: '#1a1a2e',
fontSize: '1rem',
fontWeight: 600,
},
chartSvg: {
display: 'block',
maxWidth: '100%',
},
chartLegend: {
display: 'flex',
gap: '0.5rem 1rem',
marginTop: '0.75rem',
flexWrap: 'wrap',
justifyContent: 'center',
padding: '0.25rem',
maxWidth: '450px',
margin: '0.75rem auto 0',
},
legendItem: {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
fontSize: '0.7rem',
color: '#555',
whiteSpace: 'nowrap',
},
legendColor: {
width: '10px',
height: '10px',
borderRadius: '2px',
display: 'inline-block',
flexShrink: 0,
},
breakdownRow: {
display: 'flex',
gap: '1.5rem',
flexWrap: 'wrap',
marginBottom: '1.5rem',
},
breakdownContainer: {
background: 'white',
borderRadius: '12px',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
flex: '1 1 300px',
minWidth: '280px',
},
topItemsFilterRow: {
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '1rem',
padding: '0.75rem 1rem',
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
},
filterLabel: {
fontSize: '0.9rem',
color: '#555',
fontWeight: 500,
},
supplierSelect: {
padding: '0.5rem 0.75rem',
borderRadius: '6px',
border: '1px solid #ddd',
fontSize: '0.9rem',
minWidth: '200px',
cursor: 'pointer',
},
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,333 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
interface MatrixCell {
has_flag: boolean
is_unassessed: boolean
is_none: boolean
has_open_suggestion?: boolean
}
interface MatrixIngredient {
ingredient_id: number
ingredient_name: string
is_sub_recipe: boolean
sub_recipe_name: string | null
flags: Record<number, MatrixCell>
}
interface FlagColumn {
id: number
name: string
code: string | null
category_id: number
category_name: string
propagation_type: string
required: boolean
}
interface MatrixData {
flags: FlagColumn[]
ingredients: MatrixIngredient[]
}
interface Props {
recipeId: number
categoryId?: number
}
export default function RecipeFlagMatrix({ recipeId, categoryId }: Props) {
const { token } = useAuth()
const queryClient = useQueryClient()
const [pendingCells, setPendingCells] = useState<Set<string>>(new Set())
const { data: rawData, isLoading } = useQuery<MatrixData>({
queryKey: ['recipe-flag-matrix', recipeId],
queryFn: async () => {
const res = await fetch(`/api/food-flags/recipes/${recipeId}/flags/matrix`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch matrix')
return res.json()
},
enabled: !!token && !!recipeId,
})
// Filter to specific category if provided
const data = rawData ? {
flags: categoryId ? rawData.flags.filter(f => f.category_id === categoryId) : rawData.flags,
ingredients: rawData.ingredients,
} : undefined
const toggleMutation = useMutation({
mutationFn: async ({ ingredientId, flagId, hasFlag }: { ingredientId: number; flagId: number; hasFlag: boolean }) => {
const res = await fetch(`/api/food-flags/recipes/${recipeId}/flags/matrix`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: [{ ingredient_id: ingredientId, food_flag_id: flagId, has_flag: hasFlag }] }),
})
if (!res.ok) throw new Error('Failed to update flag')
},
onMutate: ({ ingredientId, flagId }) => {
setPendingCells(prev => new Set(prev).add(`${ingredientId}-${flagId}`))
},
onSettled: (_data, _err, { ingredientId, flagId }) => {
setPendingCells(prev => {
const next = new Set(prev)
next.delete(`${ingredientId}-${flagId}`)
return next
})
queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] })
queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
},
})
const toggleNoneMutation = useMutation({
mutationFn: async ({ ingredientId, catId }: { ingredientId: number; catId: number }) => {
const res = await fetch(`/api/food-flags/recipes/${recipeId}/flags/matrix/none`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ ingredient_id: ingredientId, category_id: catId }),
})
if (!res.ok) throw new Error('Failed to toggle none')
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['recipe-flag-matrix', recipeId] })
queryClient.invalidateQueries({ queryKey: ['recipe-flags', recipeId] })
queryClient.invalidateQueries({ queryKey: ['ingredients'] })
},
})
if (isLoading) return <div style={{ padding: '1rem', color: '#888' }}>Loading matrix...</div>
if (!data || !data.flags.length) return <div style={{ padding: '1rem', color: '#888' }}>No flags configured for this category</div>
// Group flags by category
const categories: Record<number, { name: string; propagation: string; required: boolean; flags: FlagColumn[] }> = {}
for (const f of data.flags) {
if (!categories[f.category_id]) {
categories[f.category_id] = { name: f.category_name, propagation: f.propagation_type, required: f.required, flags: [] }
}
categories[f.category_id].flags.push(f)
}
// Group ingredients by sub-recipe
let currentSubRecipe = ''
const rows: Array<{ type: 'header' | 'ingredient'; label: string; ingredient?: MatrixIngredient }> = []
for (const ing of data.ingredients) {
if (ing.is_sub_recipe && ing.sub_recipe_name !== currentSubRecipe) {
currentSubRecipe = ing.sub_recipe_name || ''
rows.push({ type: 'header', label: `\u25B8 ${currentSubRecipe}` })
} else if (!ing.is_sub_recipe && currentSubRecipe) {
currentSubRecipe = ''
}
rows.push({ type: 'ingredient', label: ing.is_sub_recipe ? ` \u21B3 ${ing.ingredient_name}` : ing.ingredient_name, ingredient: ing })
}
// Compute totals per flag
const totals: Record<number, { has: boolean; unassessed: boolean }> = {}
for (const f of data.flags) {
const cat = categories[f.category_id]
if (cat.propagation === 'contains') {
const anyHas = data.ingredients.some(ing => ing.flags[f.id]?.has_flag)
totals[f.id] = { has: anyHas, unassessed: false }
} else {
const allHave = data.ingredients.every(ing => ing.flags[f.id]?.has_flag)
const anyUnassessed = data.ingredients.some(ing => ing.flags[f.id]?.is_unassessed)
totals[f.id] = { has: allHave && !anyUnassessed, unassessed: anyUnassessed }
}
}
// Check if "none" is set per ingredient per category
const getNoneForCategory = (ing: MatrixIngredient, catId: number): boolean => {
const catFlags = categories[catId]?.flags || []
return catFlags.length > 0 && catFlags.every(f => ing.flags[f.id]?.is_none)
}
const handleToggle = (ingredientId: number, flagId: number, currentHasFlag: boolean) => {
toggleMutation.mutate({ ingredientId, flagId, hasFlag: !currentHasFlag })
}
const renderCell = (ingredientId: number, flagId: number, cell: MatrixCell | undefined, propagation: string, flagName?: string) => {
const isPending = pendingCells.has(`${ingredientId}-${flagId}`)
const hasFlag = cell?.has_flag ?? false
const isUnassessed = cell?.is_unassessed ?? false
const isNone = cell?.is_none ?? false
const hasOpenSuggestion = cell?.has_open_suggestion ?? false
const cellStyle: React.CSSProperties = {
...styles.cell,
cursor: 'pointer',
opacity: isPending ? 0.5 : 1,
transition: 'background 0.15s',
position: 'relative' as const,
}
// Small amber dot for open suggestions (not already flagged or unassessed)
const suggestionDot = hasOpenSuggestion && !hasFlag && !isUnassessed ? (
<span style={{
position: 'absolute', top: '2px', right: '2px',
width: '6px', height: '6px', borderRadius: '50%',
background: '#f59e0b',
}} title="Unreviewed allergen suggestion" />
) : null
const handleClick = () => {
if (isPending) return
if (isUnassessed) {
handleToggle(ingredientId, flagId, false)
} else {
handleToggle(ingredientId, flagId, hasFlag)
}
}
if (isUnassessed) {
return <td style={{ ...cellStyle, color: '#f59e0b' }} title="Unassessed \u2014 click to assess" onClick={handleClick}>{'\u2753'}{suggestionDot}</td>
}
if (isNone) {
return (
<td
style={{ ...cellStyle, color: '#94a3b8', background: '#f8fafc' }}
title={`None apply \u2014 click to set ${flagName || 'this flag'}`}
onClick={handleClick}
>
{'\u2014'}{suggestionDot}
</td>
)
}
if (propagation === 'contains') {
return (
<td
style={{ ...cellStyle, color: hasFlag ? '#dc3545' : '#ccc', background: hasFlag ? '#fef2f2' : hasOpenSuggestion ? '#fffbeb' : undefined }}
title={hasFlag ? `Contains ${flagName || ''} \u2014 click to remove` : hasOpenSuggestion ? `Unreviewed suggestion: ${flagName || ''} \u2014 click to set` : `Click to mark as contains ${flagName || ''}`}
onClick={handleClick}
>
{hasFlag ? '\u2713' : '\u00B7'}{suggestionDot}
</td>
)
} else {
return (
<td
style={{ ...cellStyle, color: hasFlag ? '#22c55e' : '#dc3545', background: hasFlag ? '#f0fdf4' : undefined }}
title={hasFlag ? `${flagName || 'Suitable'} \u2014 click to remove` : `Click to mark as ${flagName || 'suitable'}`}
onClick={handleClick}
>
{hasFlag ? '\u2713' : '\u2717'}{suggestionDot}
</td>
)
}
}
const showNoneColumn = Object.values(categories).some(c => c.required)
return (
<div style={styles.container}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: '#888' }}>
Click cells to toggle flags{showNoneColumn ? '. Use "N" column to mark "None apply".' : '.'}
</span>
</div>
<div style={{ overflowX: 'auto', maxHeight: '70vh', overflowY: 'auto' }}>
<table style={styles.table}>
<thead style={{ position: 'sticky', top: 0, zIndex: 2 }}>
<tr>
<th style={{ ...styles.th, minWidth: '160px', position: 'sticky', top: 0, background: 'white', zIndex: 2 }}>Ingredient</th>
{Object.entries(categories).map(([catId, cat]) => (
<>
{cat.flags.map(f => (
<th key={f.id} style={styles.th} title={`${f.name} (${cat.name})`}>
{Object.keys(categories).length > 1 && (
<div style={{ fontSize: '0.6rem', color: '#888' }}>{cat.name}</div>
)}
<div>{f.code || f.name.substring(0, 4)}</div>
</th>
))}
{cat.required && (
<th key={`none-${catId}`} style={{ ...styles.th, borderLeft: '2px solid #e0e0e0', fontSize: '0.6rem', minWidth: '28px' }} title={`None apply for ${cat.name}`}>
<div>N</div>
</th>
)}
</>
))}
</tr>
</thead>
<tbody>
{rows.map((row, idx) => {
if (row.type === 'header') {
const noneColCount = Object.values(categories).filter(c => c.required).length
const totalCols = data.flags.length + noneColCount + 1
return (
<tr key={`h-${idx}`}>
<td colSpan={totalCols} style={styles.subHeader}>{row.label}</td>
</tr>
)
}
const ing = row.ingredient!
return (
<tr key={ing.ingredient_id + '-' + idx}>
<td style={{ ...styles.td, fontWeight: ing.is_sub_recipe ? 400 : 500 }}>{row.label}</td>
{Object.entries(categories).map(([catId, cat]) => (
<>
{cat.flags.map(f => renderCell(ing.ingredient_id, f.id, ing.flags[f.id], cat.propagation, f.name))}
{cat.required && (
<td
key={`none-${catId}-${ing.ingredient_id}`}
style={{
...styles.cell,
borderLeft: '2px solid #e0e0e0',
cursor: 'pointer',
color: getNoneForCategory(ing, Number(catId)) ? '#3b82f6' : '#ddd',
background: getNoneForCategory(ing, Number(catId)) ? '#eff6ff' : undefined,
fontWeight: getNoneForCategory(ing, Number(catId)) ? 600 : 400,
}}
title={getNoneForCategory(ing, Number(catId)) ? `None apply \u2014 click to unset` : `Click to mark "None apply" for ${cat.name}`}
onClick={() => toggleNoneMutation.mutate({ ingredientId: ing.ingredient_id, catId: Number(catId) })}
>
N
</td>
)}
</>
))}
</tr>
)
})}
{/* Total row */}
<tr style={{ borderTop: '3px double #333' }}>
<td style={{ ...styles.td, fontWeight: 700 }}>Recipe Total</td>
{Object.entries(categories).map(([catId, cat]) => (
<>
{cat.flags.map(f => {
const t = totals[f.id]
if (t.unassessed) return <td key={f.id} style={{ ...styles.cell, color: '#f59e0b' }}>{'\u2753'}</td>
if (cat.propagation === 'contains') {
return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#dc3545' : undefined, fontWeight: 700 }}>
{t.has ? '\u2713' : ''}
</td>
} else {
return <td key={f.id} style={{ ...styles.cell, color: t.has ? '#22c55e' : '#dc3545', fontWeight: 700 }}>
{t.has ? '\u2713' : '\u2717'}
</td>
}
})}
{cat.required && (
<td key={`none-total-${catId}`} style={{ ...styles.cell, borderLeft: '2px solid #e0e0e0' }}></td>
)}
</>
))}
</tr>
</tbody>
</table>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
container: { background: 'white', padding: '1rem', borderRadius: '8px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', marginBottom: '1rem' },
table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: '0.8rem' },
th: { padding: '0.4rem 0.35rem', textAlign: 'center' as const, borderBottom: '2px solid #ddd', fontSize: '0.7rem', fontWeight: 600, minWidth: '40px', position: 'sticky' as const, top: 0, background: 'white', zIndex: 1 },
td: { padding: '0.35rem 0.5rem', borderBottom: '1px solid #f0f0f0', fontSize: '0.8rem' },
cell: { padding: '0.35rem', textAlign: 'center' as const, borderBottom: '1px solid #f0f0f0', fontSize: '0.9rem' },
subHeader: { padding: '0.5rem', fontWeight: 700, background: '#f8f9fa', fontSize: '0.8rem', color: '#555' },
}

View file

@ -0,0 +1,728 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
import { useNavigate } from 'react-router-dom'
import FoodFlagBadges from './FoodFlagBadges'
interface MenuSection {
id: number
name: string
sort_order: number
recipe_count: number
}
interface RecipeItem {
id: number
name: string
recipe_type: string
menu_section_id: number | null
menu_section_name: string | null
batch_portions: number
batch_output_type: string
batch_yield_qty: number | null
batch_yield_unit: string | null
output_unit: string
cost_per_portion: number | null
total_cost: number | null
is_archived: boolean
prep_time_minutes: number | null
cook_time_minutes: number | null
flag_summary: Array<{
name: string
code: string | null
icon: string | null
category: string
propagation: string
active: boolean
excludable: boolean
}>
image_count: number
kds_menu_item_name: string | null
sambapos_portion_name: string | null
created_at: string
updated_at: string
}
export default function RecipeList() {
const { token } = useAuth()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [sectionFilter, setSectionFilter] = useState<string>('')
const [showCreate, setShowCreate] = useState(false)
const [showSectionModal, setShowSectionModal] = useState(false)
const [viewMode, setViewMode] = useState<'card' | 'list'>('list')
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
const [showArchived, setShowArchived] = useState(false)
// Advanced filters (client-side)
const [ingredientSearch, setIngredientSearch] = useState('')
const [costMin, setCostMin] = useState('')
const [costMax, setCostMax] = useState('')
const [flagFilters, setFlagFilters] = useState<Record<string, 'neutral' | 'include' | 'exclude'>>({})
// Section management
const [editingSectionId, setEditingSectionId] = useState<number | null>(null)
const [editingSectionName, setEditingSectionName] = useState('')
// Create form
const [formName, setFormName] = useState('')
const [formSection, setFormSection] = useState<string>('')
const [formBatch, setFormBatch] = useState('1')
const [formBatchType, setFormBatchType] = useState('portions')
const [formYieldQty, setFormYieldQty] = useState('')
const [formYieldUnit, setFormYieldUnit] = useState('ml')
const [formDesc, setFormDesc] = useState('')
// Section form
const [sectionName, setSectionName] = useState('')
const { data: sections } = useQuery<MenuSection[]>({
queryKey: ['recipe-sections'],
queryFn: async () => {
const res = await fetch('/api/recipes/menu-sections?section_type=recipe', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch sections')
return res.json()
},
enabled: !!token,
})
const { data: recipes, isLoading } = useQuery<RecipeItem[]>({
queryKey: ['recipes', search, sectionFilter, showArchived],
queryFn: async () => {
const params = new URLSearchParams()
params.set('recipe_type', 'component')
if (search) params.set('search', search)
if (sectionFilter) params.set('menu_section_id', sectionFilter)
if (showArchived) params.set('archived', 'true')
const res = await fetch(`/api/recipes?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch recipes')
return res.json()
},
enabled: !!token,
})
const createMutation = useMutation({
mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch('/api/recipes', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to create recipe')
return res.json()
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['recipes'] })
setShowCreate(false)
navigate(`/recipes/${data.id}`)
},
})
const duplicateMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/recipes/${id}/duplicate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to duplicate')
return res.json()
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['recipes'] })
navigate(`/recipes/${data.id}`)
},
})
const archiveMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/recipes/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to archive')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recipes'] }),
})
const unarchiveMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/recipes/${id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ is_archived: false }),
})
if (!res.ok) throw new Error('Failed to unarchive')
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recipes'] }),
})
const createSectionMutation = useMutation({
mutationFn: async (name: string) => {
const res = await fetch('/api/recipes/menu-sections', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, section_type: 'recipe' }),
})
if (!res.ok) throw new Error('Failed to create section')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['recipe-sections'] })
setSectionName('')
setShowSectionModal(false)
},
})
const updateSectionMutation = useMutation({
mutationFn: async ({ id, name }: { id: number; name: string }) => {
const res = await fetch(`/api/recipes/menu-sections/${id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!res.ok) throw new Error('Failed to update section')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['recipe-sections'] })
queryClient.invalidateQueries({ queryKey: ['recipes'] })
setEditingSectionId(null)
setEditingSectionName('')
},
})
const deleteSectionMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/recipes/menu-sections/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to delete section')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['recipe-sections'] })
queryClient.invalidateQueries({ queryKey: ['recipes'] })
},
})
const { data: flagCategories } = useQuery<Array<{ id: number; name: string; flags: Array<{ id: number; name: string; code: string | null }> }>>({
queryKey: ['food-flag-categories'],
queryFn: async () => {
const res = await fetch('/api/food-flags/categories', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch flag categories')
return res.json()
},
enabled: !!token && showAdvancedFilters,
})
// Client-side advanced filtering
const filteredRecipes = (() => {
let result = recipes || []
// Ingredient name filter (client-side search through flag_summary or recipe name containing ingredient)
if (ingredientSearch.trim()) {
const term = ingredientSearch.toLowerCase()
result = result.filter(r =>
r.name.toLowerCase().includes(term) ||
r.flag_summary.some(f => f.name.toLowerCase().includes(term))
)
}
// Cost range filter
if (costMin) {
const min = parseFloat(costMin)
if (!isNaN(min)) {
result = result.filter(r => r.cost_per_portion != null && r.cost_per_portion >= min)
}
}
if (costMax) {
const max = parseFloat(costMax)
if (!isNaN(max)) {
result = result.filter(r => r.cost_per_portion != null && r.cost_per_portion <= max)
}
}
// Flag filters
const includeFlags = Object.entries(flagFilters).filter(([, v]) => v === 'include').map(([k]) => k)
const excludeFlags = Object.entries(flagFilters).filter(([, v]) => v === 'exclude').map(([k]) => k)
if (includeFlags.length > 0) {
result = result.filter(r =>
includeFlags.every(flagName =>
r.flag_summary.some(f => f.name === flagName && f.active)
)
)
}
if (excludeFlags.length > 0) {
result = result.filter(r =>
!excludeFlags.some(flagName =>
r.flag_summary.some(f => f.name === flagName && f.active)
)
)
}
return result
})()
const handleCreate = () => {
createMutation.mutate({
name: formName,
recipe_type: 'component',
menu_section_id: formSection ? parseInt(formSection) : null,
batch_portions: formBatchType === 'portions' ? (parseInt(formBatch) || 1) : 1,
batch_output_type: formBatchType,
batch_yield_qty: formBatchType === 'bulk' ? (parseFloat(formYieldQty) || null) : null,
batch_yield_unit: formBatchType === 'bulk' ? formYieldUnit : null,
description: formDesc || null,
})
}
return (
<div style={styles.page}>
<div style={styles.header}>
<h2 style={{ margin: 0 }}>Recipes</h2>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button onClick={() => setShowSectionModal(true)} style={styles.secondaryBtn}>+ Section</button>
<button onClick={() => { setShowCreate(true); setFormName(''); setFormSection(''); setFormBatch('1'); setFormBatchType('portions'); setFormYieldQty(''); setFormYieldUnit('ml'); setFormDesc('') }} style={styles.primaryBtn}>+ New Recipe</button>
</div>
</div>
{/* Stats */}
<div style={styles.statsBar}>
<span>{filteredRecipes.length} {showArchived ? 'archived ' : ''}recipes</span>
</div>
{/* Filters */}
<div style={styles.filterBar}>
<input
type="text"
placeholder="Search recipes..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={styles.searchInput}
/>
<select value={sectionFilter} onChange={(e) => setSectionFilter(e.target.value)} style={styles.select}>
<option value="">All Sections</option>
{sections?.map(s => (
<option key={s.id} value={s.id}>{s.name} ({s.recipe_count})</option>
))}
</select>
<label style={styles.checkLabel}>
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
/>
Show Archived
</label>
<button
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
style={{ ...styles.secondaryBtn, padding: '0.5rem 0.75rem', fontSize: '0.85rem' }}
>
{showAdvancedFilters ? 'Hide Filters' : 'Show Filters'}
</button>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '2px' }}>
<button
onClick={() => setViewMode('card')}
style={{
...styles.viewToggleBtn,
...(viewMode === 'card' ? styles.viewToggleBtnActive : {}),
borderRadius: '4px 0 0 4px',
}}
>Cards</button>
<button
onClick={() => setViewMode('list')}
style={{
...styles.viewToggleBtn,
...(viewMode === 'list' ? styles.viewToggleBtnActive : {}),
borderRadius: '0 4px 4px 0',
}}
>List</button>
</div>
</div>
{/* Advanced Filter Panel */}
{showAdvancedFilters && (
<div style={styles.advancedFilterPanel}>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' as const, alignItems: 'flex-start' }}>
<div style={{ minWidth: '200px' }}>
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: '#555', display: 'block', marginBottom: '4px' }}>Contains ingredient</label>
<input
type="text"
placeholder="Search by ingredient..."
value={ingredientSearch}
onChange={(e) => setIngredientSearch(e.target.value)}
style={{ ...styles.searchInput, width: '100%' }}
/>
</div>
<div style={{ minWidth: '150px' }}>
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: '#555', display: 'block', marginBottom: '4px' }}>Cost/portion range</label>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<input
type="number"
placeholder="Min"
value={costMin}
onChange={(e) => setCostMin(e.target.value)}
style={{ ...styles.searchInput, width: '80px', padding: '0.4rem' }}
step="0.01"
/>
<span style={{ color: '#888' }}>-</span>
<input
type="number"
placeholder="Max"
value={costMax}
onChange={(e) => setCostMax(e.target.value)}
style={{ ...styles.searchInput, width: '80px', padding: '0.4rem' }}
step="0.01"
/>
</div>
</div>
{flagCategories && flagCategories.length > 0 && (
<div style={{ flex: 1, minWidth: '250px' }}>
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: '#555', display: 'block', marginBottom: '4px' }}>Food flag filters</label>
<div style={{ display: 'flex', flexWrap: 'wrap' as const, gap: '4px' }}>
{flagCategories.flatMap(cat => cat.flags || []).map(flag => {
const state = flagFilters[flag.name] || 'neutral'
const nextState = state === 'neutral' ? 'include' : state === 'include' ? 'exclude' : 'neutral'
return (
<button
key={flag.id}
onClick={() => setFlagFilters(prev => {
const next = { ...prev }
if (nextState === 'neutral') delete next[flag.name]
else next[flag.name] = nextState
return next
})}
style={{
padding: '2px 8px',
borderRadius: '10px',
fontSize: '0.7rem',
fontWeight: 600,
cursor: 'pointer',
border: '1px solid',
borderColor: state === 'include' ? '#22c55e' : state === 'exclude' ? '#ef4444' : '#ddd',
background: state === 'include' ? '#dcfce7' : state === 'exclude' ? '#fef2f2' : 'white',
color: state === 'include' ? '#166534' : state === 'exclude' ? '#991b1b' : '#666',
}}
title={`${flag.name}: ${state} (click to cycle)`}
>
{state === 'include' ? '+ ' : state === 'exclude' ? '- ' : ''}{flag.code || flag.name}
</button>
)
})}
</div>
</div>
)}
</div>
</div>
)}
{/* Recipes */}
{isLoading ? (
<div style={styles.loading}>Loading recipes...</div>
) : viewMode === 'card' ? (
<div style={styles.grid}>
{filteredRecipes.map(r => (
<div key={r.id} style={{ ...styles.card, ...(r.is_archived ? { opacity: 0.6 } : {}) }} onClick={() => navigate(`/recipes/${r.id}`)}>
<div style={styles.cardHeader}>
<span style={{ fontWeight: 600, fontSize: '1rem', ...(r.is_archived ? { textDecoration: 'line-through' } : {}) }}>{r.name}</span>
<div style={{ display: 'flex', gap: '4px' }}>
{r.is_archived && <span style={{ background: '#999', color: 'white', padding: '2px 8px', borderRadius: '4px', fontSize: '0.7rem', fontWeight: 600 }}>ARCHIVED</span>}
<span style={{
background: '#8b5cf6',
color: 'white',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '0.7rem',
fontWeight: 600,
}}>
RECIPE
</span>
</div>
</div>
{r.menu_section_name && (
<div style={styles.sectionTag}>{r.menu_section_name}</div>
)}
{(r.batch_output_type === 'bulk' || r.batch_portions > 1) && (
<div style={{ fontSize: '0.8rem', color: '#666', marginTop: '4px' }}>
{r.batch_output_type === 'bulk'
? `Yield: ${r.batch_yield_qty}${r.batch_yield_unit}`
: `Batch: ${r.batch_portions} portions`}
</div>
)}
<div style={styles.cardMeta}>
{r.cost_per_portion != null ? (
<span style={styles.costBadge}>
{'\u00A3'}{r.cost_per_portion.toFixed(r.batch_output_type === 'bulk' ? 4 : 2)}/{r.output_unit || 'portion'}
</span>
) : (
<span style={{ color: '#aaa', fontSize: '0.8rem' }}>No costing</span>
)}
{(r.prep_time_minutes || r.cook_time_minutes) && (
<span style={{ fontSize: '0.75rem', color: '#888' }}>
{r.prep_time_minutes ? `${r.prep_time_minutes}m prep` : ''}
{r.prep_time_minutes && r.cook_time_minutes ? ' + ' : ''}
{r.cook_time_minutes ? `${r.cook_time_minutes}m cook` : ''}
</span>
)}
</div>
{r.flag_summary.length > 0 && (
<div style={{ marginTop: '6px' }}>
<FoodFlagBadges flags={r.flag_summary} />
</div>
)}
<div style={styles.cardActions}>
<button onClick={(e) => { e.stopPropagation(); duplicateMutation.mutate(r.id) }} style={styles.actionBtn}>Duplicate</button>
{r.is_archived ? (
<button onClick={(e) => { e.stopPropagation(); unarchiveMutation.mutate(r.id) }} style={{ ...styles.actionBtn, color: '#22c55e' }}>Restore</button>
) : (
<button onClick={(e) => { e.stopPropagation(); archiveMutation.mutate(r.id) }} style={{ ...styles.actionBtn, color: '#e94560' }}>Archive</button>
)}
</div>
</div>
))}
</div>
) : (
<table style={styles.listTable}>
<thead>
<tr>
<th style={styles.listTh}>Name</th>
<th style={styles.listTh}>Type</th>
<th style={styles.listTh}>Section</th>
<th style={styles.listTh}>Output</th>
<th style={styles.listTh}>Cost/Unit</th>
<th style={styles.listTh}>Flags</th>
<th style={styles.listTh}>Actions</th>
</tr>
</thead>
<tbody>
{filteredRecipes.map(r => (
<tr key={r.id} style={{ ...styles.listTr, cursor: 'pointer', ...(r.is_archived ? { opacity: 0.6 } : {}) }} onClick={() => navigate(`/recipes/${r.id}`)}>
<td style={styles.listTd}>
<span style={{ fontWeight: 500, ...(r.is_archived ? { textDecoration: 'line-through' } : {}) }}>{r.name}</span>
{r.is_archived && <span style={{ marginLeft: '0.5rem', fontSize: '0.7rem', color: '#999', fontStyle: 'italic' }}>archived</span>}
</td>
<td style={styles.listTd}>
<span style={{
background: '#8b5cf6',
color: 'white',
padding: '1px 6px',
borderRadius: '4px',
fontSize: '0.7rem',
fontWeight: 600,
}}>
RECIPE
</span>
</td>
<td style={styles.listTd}>{r.menu_section_name || '-'}</td>
<td style={styles.listTd}>
{r.batch_output_type === 'bulk'
? `${r.batch_yield_qty}${r.batch_yield_unit}`
: r.batch_portions}
</td>
<td style={styles.listTd}>
{r.cost_per_portion != null ? (
<span style={styles.costBadge}>
{'\u00A3'}{r.cost_per_portion.toFixed(r.batch_output_type === 'bulk' ? 4 : 2)}/{r.output_unit || 'portion'}
</span>
) : (
<span style={{ color: '#aaa', fontSize: '0.8rem' }}>-</span>
)}
</td>
<td style={styles.listTd}>
{r.flag_summary.length > 0 && <FoodFlagBadges flags={r.flag_summary} />}
</td>
<td style={styles.listTd}>
<button onClick={(e) => { e.stopPropagation(); duplicateMutation.mutate(r.id) }} style={styles.actionBtn}>Duplicate</button>
{r.is_archived ? (
<button onClick={(e) => { e.stopPropagation(); unarchiveMutation.mutate(r.id) }} style={{ ...styles.actionBtn, color: '#22c55e', marginLeft: '4px' }}>Restore</button>
) : (
<button onClick={(e) => { e.stopPropagation(); archiveMutation.mutate(r.id) }} style={{ ...styles.actionBtn, color: '#e94560', marginLeft: '4px' }}>Archive</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Create Recipe Modal */}
{showCreate && (
<div style={styles.overlay}>
<div style={styles.modal}>
<div style={styles.modalHeader}>
<h3 style={{ margin: 0 }}>New Recipe</h3>
<button onClick={() => setShowCreate(false)} style={styles.closeBtn}>✕</button>
</div>
<div style={styles.modalBody}>
<label style={styles.label}>Name *</label>
<input value={formName} onChange={(e) => setFormName(e.target.value)} style={styles.input} placeholder="e.g. Hollandaise Sauce, Pastry Base" />
<label style={styles.label}>Section</label>
<select value={formSection} onChange={(e) => setFormSection(e.target.value)} style={styles.input}>
<option value="">None</option>
{sections?.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<label style={styles.label}>Output Type</label>
<select value={formBatchType} onChange={(e) => setFormBatchType(e.target.value)} style={styles.input}>
<option value="portions">Portioned</option>
<option value="bulk">Bulk (volume/weight)</option>
</select>
{formBatchType === 'portions' ? (
<>
<label style={styles.label}>Batch Portions</label>
<input type="number" value={formBatch} onChange={(e) => setFormBatch(e.target.value)} style={styles.input} min="1" />
</>
) : (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div style={{ flex: 1 }}>
<label style={styles.label}>Yield Quantity</label>
<input type="number" value={formYieldQty} onChange={(e) => setFormYieldQty(e.target.value)} style={styles.input} step="0.1" min="0.1" placeholder="e.g. 500" />
</div>
<div style={{ width: '90px' }}>
<label style={styles.label}>Unit</label>
<select value={formYieldUnit} onChange={(e) => setFormYieldUnit(e.target.value)} style={styles.input}>
<option value="ml">ml</option>
<option value="ltr">ltr</option>
<option value="g">g</option>
<option value="kg">kg</option>
</select>
</div>
</div>
)}
<label style={styles.label}>Description</label>
<textarea value={formDesc} onChange={(e) => setFormDesc(e.target.value)} style={{ ...styles.input, minHeight: '60px' }} placeholder="Brief description..." />
</div>
<div style={styles.modalFooter}>
<button onClick={() => setShowCreate(false)} style={styles.cancelBtn}>Cancel</button>
<button onClick={handleCreate} disabled={!formName || createMutation.isPending} style={styles.primaryBtn}>
{createMutation.isPending ? 'Creating...' : 'Create Recipe'}
</button>
</div>
</div>
</div>
)}
{/* Section Modal */}
{showSectionModal && (
<div style={styles.overlay}>
<div style={{ ...styles.modal, width: '450px' }}>
<div style={styles.modalHeader}>
<h3 style={{ margin: 0 }}>Manage Recipe Sections</h3>
<button onClick={() => { setShowSectionModal(false); setEditingSectionId(null) }} style={styles.closeBtn}>✕</button>
</div>
<div style={styles.modalBody}>
{/* Existing sections */}
{sections && sections.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
{sections.map(s => (
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.4rem 0', borderBottom: '1px solid #f0f0f0' }}>
{editingSectionId === s.id ? (
<>
<input
value={editingSectionName}
onChange={(e) => setEditingSectionName(e.target.value)}
style={{ ...styles.input, flex: 1 }}
autoFocus
/>
<button
onClick={() => updateSectionMutation.mutate({ id: s.id, name: editingSectionName })}
disabled={!editingSectionName.trim()}
style={{ ...styles.actionBtn, color: '#22c55e' }}
>Save</button>
<button
onClick={() => { setEditingSectionId(null); setEditingSectionName('') }}
style={styles.actionBtn}
>Cancel</button>
</>
) : (
<>
<span style={{ flex: 1, fontSize: '0.9rem' }}>{s.name}</span>
<span style={{ fontSize: '0.75rem', color: '#888' }}>({s.recipe_count})</span>
<button
onClick={() => { setEditingSectionId(s.id); setEditingSectionName(s.name) }}
style={styles.actionBtn}
>Edit</button>
<button
onClick={() => {
if (confirm(`Delete section "${s.name}"? Recipes in this section will be unassigned.`)) {
deleteSectionMutation.mutate(s.id)
}
}}
style={{ ...styles.actionBtn, color: '#e94560' }}
>Delete</button>
</>
)}
</div>
))}
</div>
)}
{/* Add new section */}
<label style={styles.label}>Add New Section</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input value={sectionName} onChange={(e) => setSectionName(e.target.value)} style={{ ...styles.input, flex: 1 }} placeholder="e.g. Protein, Garnish, Pastry, Baked" />
<button onClick={() => createSectionMutation.mutate(sectionName)} disabled={!sectionName} style={styles.primaryBtn}>Add</button>
</div>
</div>
<div style={styles.modalFooter}>
<button onClick={() => { setShowSectionModal(false); setEditingSectionId(null) }} style={styles.cancelBtn}>Close</button>
</div>
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
page: { padding: '1.5rem', maxWidth: '1400px', margin: '0 auto' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' },
statsBar: { display: 'flex', gap: '0.75rem', fontSize: '0.85rem', color: '#666', marginBottom: '0.75rem' },
filterBar: { display: 'flex', gap: '0.75rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' as const },
searchInput: { padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', width: '250px' },
select: { padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem' },
checkLabel: { display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.85rem', color: '#555', cursor: 'pointer', whiteSpace: 'nowrap' as const },
grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '1rem' },
card: { background: 'white', borderRadius: '8px', padding: '1rem', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', cursor: 'pointer', transition: 'box-shadow 0.2s', border: '1px solid #eee' },
cardHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' },
sectionTag: { fontSize: '0.75rem', color: '#888', marginTop: '2px' },
cardMeta: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '8px' },
costBadge: { background: '#f0fdf4', color: '#166534', padding: '2px 8px', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 600 },
cardActions: { display: 'flex', gap: '0.5rem', marginTop: '10px', borderTop: '1px solid #f0f0f0', paddingTop: '8px' },
actionBtn: { padding: '0.25rem 0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer', fontSize: '0.75rem' },
primaryBtn: { padding: '0.6rem 1.25rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' },
secondaryBtn: { padding: '0.6rem 1.25rem', background: 'white', color: '#333', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer', fontSize: '0.9rem' },
cancelBtn: { padding: '0.6rem 1.25rem', background: '#f0f0f0', color: '#333', border: 'none', borderRadius: '6px', cursor: 'pointer' },
loading: { padding: '3rem', textAlign: 'center' as const, color: '#888' },
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: '450px', 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' },
modalFooter: { display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', padding: '1rem 1.25rem', borderTop: '1px solid #eee' },
closeBtn: { background: 'none', border: 'none', fontSize: '1.2rem', cursor: 'pointer', color: '#888' },
label: { display: 'block', fontSize: '0.8rem', fontWeight: 600, color: '#555', marginTop: '0.75rem', marginBottom: '0.25rem' },
input: { width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' as const },
viewToggleBtn: { padding: '0.4rem 0.75rem', border: '1px solid #ddd', background: 'white', cursor: 'pointer', fontSize: '0.8rem', color: '#666' },
viewToggleBtnActive: { background: '#e94560', color: 'white', borderColor: '#e94560' },
advancedFilterPanel: { background: '#fafafa', border: '1px solid #eee', borderRadius: '8px', padding: '1rem', marginBottom: '1rem' },
listTable: { width: '100%', borderCollapse: 'collapse' as const, background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
listTh: { padding: '0.6rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0', background: '#fafafa', fontSize: '0.8rem', fontWeight: 600, color: '#555' },
listTr: { borderBottom: '1px solid #f0f0f0' },
listTd: { padding: '0.5rem 0.75rem', fontSize: '0.85rem' },
}

View file

@ -0,0 +1,718 @@
import { useState, useRef, useCallback, Fragment } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useAuth } from '../App'
// ============ Types ============
interface MatchedItem {
date: string
supplier: string
ref: string
amount: string
flash_id: number | null
match_source?: string // "rule" or "ai"
}
interface DiscrepancyItem {
date: string
supplier: string
ref: string
flash_amount: string
xero_amount: string
difference: string
differs: string[]
flash_id: number | null
flash_date: string | null
xero_date: string | null
flash_ref: string | null
xero_ref: string | null
flash_supplier: string | null
xero_description: string | null
match_source?: string // "rule" or "ai"
amount_insight: string | null
}
interface FlashOnlyItem {
date: string
supplier: string
ref: string
net_stock: string
flash_id: number
}
interface XeroOnlyItem {
date: string
description: string
ref: string
net: string
is_expected_external: boolean
}
interface ReconcileResult {
period_start: string
period_end: string
flash_total: string
xero_total: string
difference: string
matched_count: number
discrepancy_count: number
flash_only_count: number
xero_only_count: number
non_stock_excluded_count: number
non_stock_excluded_total: string
matched: MatchedItem[]
discrepancies: DiscrepancyItem[]
flash_only: FlashOnlyItem[]
xero_only: XeroOnlyItem[]
llm_matches_attempted: boolean
}
// ============ Component ============
export default function ReconcilePurchases() {
const { token } = useAuth()
const fileInputRef = useRef<HTMLInputElement>(null)
const [dragOver, setDragOver] = useState(false)
const [result, setResult] = useState<ReconcileResult | null>(null)
const [matchedCollapsed, setMatchedCollapsed] = useState(false)
const [externalCollapsed, setExternalCollapsed] = useState(true)
const [expandedDisc, setExpandedDisc] = useState<Set<number>>(new Set())
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/reports/purchases/reconcile', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Upload failed' }))
throw new Error(err.detail || 'Upload failed')
}
return res.json() as Promise<ReconcileResult>
},
onSuccess: (data) => {
setResult(data)
// Auto-collapse matched if >20 items
setMatchedCollapsed(data.matched_count > 20)
},
})
const handleFile = useCallback((file: File) => {
if (!file.name.toLowerCase().endsWith('.xlsx')) {
alert('Please upload an XLSX file')
return
}
uploadMutation.mutate(file)
}, [uploadMutation])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file) handleFile(file)
}, [handleFile])
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
e.target.value = '' // reset for re-upload
}, [handleFile])
// CSV download helper
const downloadCSV = (filename: string, headers: string[], rows: string[][]) => {
const csvContent = [headers.join(','), ...rows.map(r => r.map(c => `"${c}"`).join(','))].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
const formatDate = (iso: string) => {
const d = new Date(iso + 'T00:00:00')
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}
const r = result
// Split xero-only into expected external vs real exceptions
const xeroExternal = r?.xero_only.filter(x => x.is_expected_external) || []
const xeroExceptions = r?.xero_only.filter(x => !x.is_expected_external) || []
return (
<div style={styles.container}>
<h2 style={styles.pageTitle}>Xero Reconciliation</h2>
{/* Upload area */}
{!uploadMutation.isPending && (
<div
style={{ ...styles.dropZone, ...(dragOver ? styles.dropZoneActive : {}) }}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".xlsx"
style={{ display: 'none' }}
onChange={handleFileInput}
/>
<div style={styles.dropIcon}>📄</div>
<div style={styles.dropText}>
{r ? 'Drop a new Xero XLSX to re-run' : 'Drop Xero Account Transactions XLSX here'}
</div>
<div style={styles.dropSubtext}>or click to browse</div>
</div>
)}
{uploadMutation.isPending && (
<div style={styles.loading}>Parsing XLSX and reconciling against Flash invoices...</div>
)}
{uploadMutation.isError && (
<div style={styles.error}>{(uploadMutation.error as Error).message}</div>
)}
{r && (
<>
{/* Summary header bar */}
<div style={styles.summaryBar}>
<div style={styles.summaryPeriod}>
Period: {formatDate(r.period_start)} – {formatDate(r.period_end)}
</div>
<div style={styles.summaryGrid}>
<div style={styles.summaryItem}>
<div style={styles.summaryLabel}>Flash Total (net stock)</div>
<div style={styles.summaryValue}>{r.flash_total}</div>
</div>
<div style={styles.summaryItem}>
<div style={styles.summaryLabel}>Xero Total (net)</div>
<div style={styles.summaryValue}>{r.xero_total}</div>
</div>
<div style={styles.summaryItem}>
<div style={styles.summaryLabel}>Difference</div>
<div style={{ ...styles.summaryValue, color: r.difference === '£0.00' ? '#16a34a' : '#dc2626' }}>
{r.difference}
</div>
</div>
</div>
<div style={styles.summaryCounts}>
<span style={styles.countBadgeGreen}>✅ {r.matched_count} matched</span>
<span style={styles.countBadgeAmber}>⚠️ {r.discrepancy_count} discrepancies</span>
<span style={styles.countBadgeBlue}>❓ {r.flash_only_count} Flash-only</span>
<span style={styles.countBadgeBlue}>❓ {r.xero_only_count} Xero-only</span>
</div>
{r.non_stock_excluded_count > 0 && (
<div style={styles.footnote}>
{r.non_stock_excluded_count} non-stock Flash entries excluded ({r.non_stock_excluded_total}) — these post to other Xero accounts
</div>
)}
{r.llm_matches_attempted && (
<div style={styles.footnote}>
AI-assisted matching was used for some discrepancies
</div>
)}
</div>
{/* Section 1: Matched */}
<div style={styles.section}>
<div style={styles.sectionHeader} onClick={() => setMatchedCollapsed(!matchedCollapsed)}>
<span>
<span style={styles.collapseIcon}>{matchedCollapsed ? '▸' : '▾'}</span>
✅ Matched ({r.matched_count})
</span>
<button
onClick={(e) => {
e.stopPropagation()
downloadCSV(
'reconciliation_matched.csv',
['Date', 'Supplier', 'Ref', 'Amount'],
r.matched.map(m => [m.date, m.supplier, m.ref, m.amount])
)
}}
style={styles.csvBtn}
>Download CSV</button>
</div>
{!matchedCollapsed && r.matched.length > 0 && (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Date</th>
<th style={styles.th}>Supplier</th>
<th style={styles.th}>Ref</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Amount</th>
<th style={{ ...styles.th, textAlign: 'center', width: '40px' }}>✓</th>
</tr>
</thead>
<tbody>
{r.matched.map((m, idx) => (
<tr key={idx} style={styles.tr}>
<td style={styles.td}>{formatDate(m.date)}</td>
<td style={styles.td}>{m.supplier}</td>
<td style={styles.td}>{m.ref}</td>
<td style={{ ...styles.td, textAlign: 'right' }}>{m.amount}</td>
<td style={{ ...styles.td, textAlign: 'center' }}>
<span style={{ color: '#16a34a' }}>✓</span>
{m.match_source === 'ai' && <span style={styles.aiBadge}>AI</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
{!matchedCollapsed && r.matched.length === 0 && (
<div style={styles.emptyMsg}>No exact matches found.</div>
)}
</div>
{/* Section 2: Discrepancies — always expanded */}
<div style={{ ...styles.section, borderColor: '#f59e0b' }}>
<div style={styles.sectionHeaderAmber}>
<span>⚠️ Discrepancies ({r.discrepancy_count})</span>
<button
onClick={() => downloadCSV(
'reconciliation_discrepancies.csv',
['Date', 'Supplier', 'Ref', 'Flash Amount', 'Xero Amount', 'Difference', 'Differs'],
r.discrepancies.map(d => [d.date, d.supplier, d.ref, d.flash_amount, d.xero_amount, d.difference, d.differs.join('; ')])
)}
style={styles.csvBtn}
>Download CSV</button>
</div>
{r.discrepancies.length > 0 ? (
<table style={styles.table}>
<thead>
<tr>
<th style={{ ...styles.th, width: '30px' }}></th>
<th style={styles.th}>Date</th>
<th style={styles.th}>Supplier</th>
<th style={styles.th}>Ref</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Flash</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Xero</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Diff</th>
<th style={styles.th}>What differs</th>
</tr>
</thead>
<tbody>
{r.discrepancies.map((d, idx) => {
const isExpanded = expandedDisc.has(idx)
return (
<Fragment key={idx}>
<tr
style={{ ...styles.tr, cursor: 'pointer' }}
onClick={() => setExpandedDisc(prev => {
const next = new Set(prev)
if (next.has(idx)) next.delete(idx); else next.add(idx)
return next
})}
>
<td style={{ ...styles.td, color: '#999', fontSize: '0.75rem' }}>{isExpanded ? '▾' : '▸'}</td>
<td style={styles.td}>
{d.differs.includes('date') ? (
<span>
<span style={styles.amberHighlight}>{d.flash_date ? formatDate(d.flash_date) : '—'}</span>
<span style={{ color: '#999', margin: '0 0.25rem' }}>/</span>
<span style={styles.amberHighlight}>{d.xero_date ? formatDate(d.xero_date) : '—'}</span>
</span>
) : formatDate(d.date)}
</td>
<td style={styles.td}>
{d.flash_id ? (
<span
title="Open invoice"
onClick={(e) => { e.stopPropagation(); window.open(`/invoice/${d.flash_id}`, '_blank') }}
style={{ cursor: 'pointer', color: '#3b82f6', textDecoration: 'underline dotted', textUnderlineOffset: '3px' }}
>{d.supplier}</span>
) : d.supplier}
</td>
<td style={styles.td}>
{d.differs.includes('ref') ? (
<span>
<span style={styles.amberHighlight}>{d.flash_ref || '—'}</span>
<span style={{ color: '#999', margin: '0 0.25rem' }}>/</span>
<span style={styles.amberHighlight}>{d.xero_ref || '—'}</span>
</span>
) : d.ref}
</td>
<td style={{ ...styles.td, textAlign: 'right', ...(d.differs.includes('amount') ? { background: '#fef3c7' } : {}) }}>
{d.flash_amount}
</td>
<td style={{ ...styles.td, textAlign: 'right', ...(d.differs.includes('amount') ? { background: '#fef3c7' } : {}) }}>
{d.xero_amount}
</td>
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: '#dc2626' }}>
{d.difference}
</td>
<td style={styles.td}>
{d.differs.map(f => (
<span key={f} style={styles.differBadge}>{f}</span>
))}
{d.match_source === 'ai' && <span style={styles.aiBadge}>AI</span>}
</td>
</tr>
{isExpanded && (
<tr style={{ background: '#f8f9fa' }}>
<td colSpan={8} style={{ padding: 0 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr>
<th style={styles.detailTh}>Source</th>
<th style={styles.detailTh}>Date</th>
<th style={styles.detailTh}>Supplier / Description</th>
<th style={styles.detailTh}>Ref</th>
<th style={{ ...styles.detailTh, textAlign: 'right' }}>Net Amount</th>
</tr>
</thead>
<tbody>
<tr style={styles.detailRow}>
<td style={styles.detailTd}><span style={styles.flashBadge}>Flash</span></td>
<td style={{ ...styles.detailTd, ...(d.differs.includes('date') ? { background: '#fef3c7' } : {}) }}>
{d.flash_date ? formatDate(d.flash_date) : '—'}
</td>
<td style={styles.detailTd}>{d.flash_supplier || d.supplier}</td>
<td style={{ ...styles.detailTd, ...(d.differs.includes('ref') ? { background: '#fef3c7' } : {}) }}>
{d.flash_ref || '—'}
</td>
<td style={{ ...styles.detailTd, textAlign: 'right', ...(d.differs.includes('amount') ? { background: '#fef3c7' } : {}) }}>
{d.flash_amount}
</td>
</tr>
<tr style={styles.detailRow}>
<td style={styles.detailTd}><span style={styles.xeroBadge}>Xero</span></td>
<td style={{ ...styles.detailTd, ...(d.differs.includes('date') ? { background: '#fef3c7' } : {}) }}>
{d.xero_date ? formatDate(d.xero_date) : '—'}
</td>
<td style={styles.detailTd}>{d.xero_description || d.supplier}</td>
<td style={{ ...styles.detailTd, ...(d.differs.includes('ref') ? { background: '#fef3c7' } : {}) }}>
{d.xero_ref || '—'}
</td>
<td style={{ ...styles.detailTd, textAlign: 'right', ...(d.differs.includes('amount') ? { background: '#fef3c7' } : {}) }}>
{d.xero_amount}
</td>
</tr>
</tbody>
</table>
{d.amount_insight && (
<div style={styles.insightBar}>
<span style={styles.insightIcon}>💡</span>
{d.amount_insight}
</div>
)}
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
) : (
<div style={styles.emptyMsg}>No discrepancies found — all matches are exact.</div>
)}
</div>
{/* Section 3: Flash-only */}
<div style={styles.section}>
<div style={styles.sectionHeader}>
<span>❓ In Flash but not in Xero ({r.flash_only_count})</span>
<button
onClick={() => downloadCSV(
'reconciliation_flash_only.csv',
['Date', 'Supplier', 'Ref', 'Net Stock'],
r.flash_only.map(f => [f.date, f.supplier, f.ref, f.net_stock])
)}
style={styles.csvBtn}
>Download CSV</button>
</div>
{r.flash_only.length > 0 ? (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Date</th>
<th style={styles.th}>Supplier</th>
<th style={styles.th}>Ref</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Flash Net Stock</th>
</tr>
</thead>
<tbody>
{r.flash_only.map((f, idx) => (
<tr key={idx} style={styles.tr}>
<td style={styles.td}>{formatDate(f.date)}</td>
<td style={styles.td}>
<span
title="Open invoice"
onClick={() => window.open(`/invoice/${f.flash_id}`, '_blank')}
style={{ cursor: 'pointer', color: '#3b82f6', textDecoration: 'underline dotted', textUnderlineOffset: '3px' }}
>{f.supplier}</span>
</td>
<td style={styles.td}>{f.ref}</td>
<td style={{ ...styles.td, textAlign: 'right' }}>{f.net_stock}</td>
</tr>
))}
</tbody>
</table>
) : (
<div style={styles.emptyMsg}>All Flash invoices matched.</div>
)}
</div>
{/* Section 4: Xero-only */}
<div style={styles.section}>
<div style={styles.sectionHeader}>
<span>❓ In Xero but not in Flash ({xeroExceptions.length}{xeroExternal.length > 0 ? ` + ${xeroExternal.length} expected` : ''})</span>
<button
onClick={() => downloadCSV(
'reconciliation_xero_only.csv',
['Date', 'Description', 'Ref', 'Net', 'Expected External'],
r.xero_only.map(x => [x.date, x.description, x.ref, x.net, x.is_expected_external ? 'Yes' : 'No'])
)}
style={styles.csvBtn}
>Download CSV</button>
</div>
{xeroExceptions.length > 0 ? (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Date</th>
<th style={styles.th}>Description</th>
<th style={styles.th}>Ref</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Xero Net</th>
</tr>
</thead>
<tbody>
{xeroExceptions.map((x, idx) => (
<tr key={idx} style={styles.tr}>
<td style={styles.td}>{formatDate(x.date)}</td>
<td style={styles.td}>{x.description}</td>
<td style={styles.td}>{x.ref}</td>
<td style={{ ...styles.td, textAlign: 'right' }}>{x.net}</td>
</tr>
))}
</tbody>
</table>
) : (
<div style={styles.emptyMsg}>No unexpected Xero-only entries.</div>
)}
{/* Expected external sub-section */}
{xeroExternal.length > 0 && (
<div style={styles.externalSection}>
<div
style={styles.externalHeader}
onClick={() => setExternalCollapsed(!externalCollapsed)}
>
<span style={styles.collapseIcon}>{externalCollapsed ? '▸' : '▾'}</span>
Expected external ({xeroExternal.length}) — Tesco, JVs, petty cash etc.
</div>
{!externalCollapsed && (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Date</th>
<th style={styles.th}>Description</th>
<th style={styles.th}>Ref</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Xero Net</th>
</tr>
</thead>
<tbody>
{xeroExternal.map((x, idx) => (
<tr key={idx} style={{ ...styles.tr, opacity: 0.6 }}>
<td style={styles.td}>{formatDate(x.date)}</td>
<td style={styles.td}>{x.description}</td>
<td style={styles.td}>{x.ref}</td>
<td style={{ ...styles.td, textAlign: 'right' }}>{x.net}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
</>
)}
</div>
)
}
// ============ Styles ============
const styles: Record<string, React.CSSProperties> = {
container: { maxWidth: '1100px', margin: '0 auto', padding: '1.5rem' },
pageTitle: { fontSize: '1.4rem', fontWeight: 700, marginBottom: '1rem' },
// Upload
dropZone: {
border: '2px dashed #ddd',
borderRadius: '12px',
padding: '2.5rem',
textAlign: 'center',
cursor: 'pointer',
marginBottom: '1.5rem',
background: '#fafafa',
transition: 'all 0.2s',
},
dropZoneActive: {
borderColor: '#e94560',
background: '#fff5f7',
},
dropIcon: { fontSize: '2.5rem', marginBottom: '0.5rem' },
dropText: { fontSize: '1rem', fontWeight: 600, color: '#333' },
dropSubtext: { fontSize: '0.8rem', color: '#999', marginTop: '0.3rem' },
loading: { padding: '2rem', textAlign: 'center', color: '#888' },
error: { padding: '1rem', color: '#dc3545', background: '#fde8e8', borderRadius: '6px', marginBottom: '1rem' },
// Summary bar
summaryBar: {
background: '#1a1a2e',
color: 'white',
padding: '1.25rem',
borderRadius: '10px',
marginBottom: '1.5rem',
},
summaryPeriod: { fontSize: '0.85rem', color: '#aaa', marginBottom: '0.75rem' },
summaryGrid: { display: 'flex', gap: '2rem', marginBottom: '0.75rem', flexWrap: 'wrap' as const },
summaryItem: {},
summaryLabel: { fontSize: '0.7rem', textTransform: 'uppercase' as const, color: '#888', fontWeight: 600 },
summaryValue: { fontSize: '1.3rem', fontWeight: 700 },
summaryCounts: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' as const, marginTop: '0.5rem' },
countBadgeGreen: { fontSize: '0.8rem', background: 'rgba(22,163,74,0.2)', color: '#4ade80', padding: '0.2rem 0.6rem', borderRadius: '4px' },
countBadgeAmber: { fontSize: '0.8rem', background: 'rgba(245,158,11,0.2)', color: '#fbbf24', padding: '0.2rem 0.6rem', borderRadius: '4px' },
countBadgeBlue: { fontSize: '0.8rem', background: 'rgba(59,130,246,0.2)', color: '#60a5fa', padding: '0.2rem 0.6rem', borderRadius: '4px' },
footnote: { fontSize: '0.75rem', color: '#888', marginTop: '0.5rem', fontStyle: 'italic' as const },
// Sections
section: {
marginBottom: '1rem',
border: '1px solid #e0e0e0',
borderRadius: '8px',
overflow: 'hidden',
},
sectionHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem 1rem',
background: '#f8f9fa',
cursor: 'pointer',
fontWeight: 700,
fontSize: '0.95rem',
},
sectionHeaderAmber: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem 1rem',
background: '#fffbeb',
fontWeight: 700,
fontSize: '0.95rem',
},
collapseIcon: { marginRight: '0.5rem', fontSize: '0.85rem' },
csvBtn: {
padding: '0.25rem 0.65rem',
background: 'transparent',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '0.75rem',
cursor: 'pointer',
color: '#666',
},
emptyMsg: { padding: '1rem', textAlign: 'center', color: '#999', fontSize: '0.85rem' },
// Tables
table: { width: '100%', borderCollapse: 'collapse' as const },
th: {
padding: '0.5rem 0.75rem',
textAlign: 'left' as const,
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' },
// Discrepancy highlights
amberHighlight: { background: '#fef3c7', padding: '0.1rem 0.3rem', borderRadius: '3px' },
differBadge: {
display: 'inline-block',
background: '#fef3c7',
color: '#92400e',
padding: '0.1rem 0.4rem',
borderRadius: '3px',
fontSize: '0.7rem',
fontWeight: 600,
marginRight: '0.3rem',
},
// External sub-section
externalSection: { borderTop: '1px solid #e0e0e0', marginTop: '0.5rem' },
externalHeader: {
padding: '0.6rem 1rem',
fontSize: '0.8rem',
color: '#888',
cursor: 'pointer',
fontStyle: 'italic' as const,
},
// Detail comparison rows
detailTh: {
padding: '0.35rem 0.75rem',
textAlign: 'left' as const,
fontSize: '0.7rem',
fontWeight: 600,
color: '#888',
borderBottom: '1px solid #e0e0e0',
background: '#f0f0f0',
},
detailRow: { borderBottom: '1px solid #eee' },
detailTd: { padding: '0.35rem 0.75rem', fontSize: '0.8rem' },
flashBadge: {
display: 'inline-block',
background: '#dbeafe',
color: '#1d4ed8',
padding: '0.1rem 0.4rem',
borderRadius: '3px',
fontSize: '0.65rem',
fontWeight: 700,
},
xeroBadge: {
display: 'inline-block',
background: '#e0e7ff',
color: '#4338ca',
padding: '0.1rem 0.4rem',
borderRadius: '3px',
fontSize: '0.65rem',
fontWeight: 700,
},
insightBar: {
padding: '0.5rem 0.75rem',
background: '#f0fdf4',
borderTop: '1px solid #bbf7d0',
fontSize: '0.8rem',
color: '#166534',
lineHeight: 1.4,
},
insightIcon: {
marginRight: '0.4rem',
},
aiBadge: {
display: 'inline-block',
background: '#f3e8ff',
color: '#7c3aed',
padding: '0.1rem 0.35rem',
borderRadius: '3px',
fontSize: '0.6rem',
fontWeight: 700,
marginLeft: '0.3rem',
verticalAlign: 'middle',
},
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,448 @@
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<Set<string>>(new Set())
// Mapping modal
const [mappingItem, setMappingItem] = useState<SalesGPItem | null>(null)
const [recipeSearch, setRecipeSearch] = useState('')
// Fetch report data
const { data: report, isLoading, error } = useQuery<SalesGPResponse>({
queryKey: ['sales-gp', submittedFrom, submittedTo],
queryFn: async () => {
const res = await fetch(`/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<DishRecipe[]>({
queryKey: ['recipes-for-mapping', recipeSearch],
queryFn: async () => {
const url = `/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(`/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 (
<div style={styles.container}>
<h2 style={styles.pageTitle}>Sales GP% Report</h2>
{/* Date range selector */}
<div style={styles.dateBar}>
<div style={styles.dateGroup}>
<label style={styles.dateLabel}>From</label>
<input type="date" value={fromDate} onChange={(e) => setFromDate(e.target.value)} style={styles.dateInput} />
</div>
<div style={styles.dateGroup}>
<label style={styles.dateLabel}>To</label>
<input type="date" value={toDate} onChange={(e) => setToDate(e.target.value)} style={styles.dateInput} />
</div>
<button onClick={handleGenerate} style={styles.generateBtn}>Generate</button>
</div>
{isLoading && <div style={styles.loading}>Loading sales data from SambaPOS...</div>}
{error && <div style={styles.error}>{(error as Error).message}</div>}
{report && (
<>
{/* Summary banner */}
<div style={styles.summaryBanner}>
<div style={styles.summaryMain}>
<div style={styles.summaryLabel}>Estimated Sales GP%</div>
<div style={{ ...styles.summaryValue, color: gpColor(report.mapped_gp_percent) }}>
{fmtPct(report.mapped_gp_percent)}
</div>
<div style={styles.summarySubtext}>mapped items only</div>
</div>
<div style={styles.summaryStats}>
<div style={styles.statBox}>
<div style={styles.statLabel}>Mapped Revenue</div>
<div style={styles.statValue}>&pound;{fmt(report.mapped_revenue_net)}</div>
</div>
<div style={styles.statBox}>
<div style={styles.statLabel}>Mapped Cost</div>
<div style={styles.statValue}>&pound;{fmt(report.mapped_total_cost)}</div>
</div>
<div style={styles.statBox}>
<div style={styles.statLabel}>Total Revenue</div>
<div style={styles.statValue}>&pound;{fmt(report.total_all_revenue_net)}</div>
</div>
</div>
{/* Coverage bar */}
<div style={styles.coverageSection}>
<div style={styles.coverageLabel}>
Coverage: {Number(report.mapped_revenue_percent).toFixed(1)}% of food sales revenue is costed
<span style={{ color: '#888', marginLeft: '0.5rem' }}>
({report.mapped_item_count} mapped, {report.unmapped_item_count} unmapped)
</span>
</div>
<div style={styles.coverageBarBg}>
<div style={{ ...styles.coverageBarFill, width: `${Math.min(Number(report.mapped_revenue_percent), 100)}%` }} />
</div>
</div>
</div>
{/* Course sections */}
{report.courses.map(course => (
<div key={course.course_name} style={styles.courseSection}>
<div style={styles.courseHeader} onClick={() => toggleCourse(course.course_name)}>
<div style={styles.courseTitle}>
<span style={styles.collapseIcon}>{collapsedCourses.has(course.course_name) ? '▸' : '▾'}</span>
{course.course_name}
</div>
<div style={styles.courseStats}>
<span style={{ marginRight: '1.5rem' }}>Revenue: &pound;{fmt(course.course_revenue)}</span>
<span style={{ marginRight: '1.5rem' }}>Cost: &pound;{fmt(course.course_cost)}</span>
<span style={{ fontWeight: 700, color: gpColor(course.course_gp_percent) }}>
GP: {fmtPct(course.course_gp_percent)}
</span>
</div>
</div>
{!collapsedCourses.has(course.course_name) && (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Item</th>
<th style={styles.th}>Portion</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Qty</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Net Revenue</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Cost/Portion</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Total Cost</th>
<th style={{ ...styles.th, textAlign: 'right' }}>GP%</th>
</tr>
</thead>
<tbody>
{course.items.map((item, idx) => (
<tr key={idx} style={styles.tr}>
<td style={styles.td}>
{item.recipe_id ? (
<span
title={`Recipe: ${item.recipe_name}`}
onClick={() => navigate(`/dishes/${item.recipe_id}`)}
style={{ cursor: 'pointer', color: '#3b82f6', textDecoration: 'underline dotted', textUnderlineOffset: '3px' }}
>{item.menu_item_name}</span>
) : item.menu_item_name}
</td>
<td style={{ ...styles.td, color: item.portion_name === 'Normal' ? '#ccc' : '#555' }}>
{item.portion_name === 'Normal' ? '—' : item.portion_name}
</td>
<td style={{ ...styles.td, textAlign: 'right' }}>
{item.total_qty}
{item.dbb_qty > 0 && (
<span
title={`${item.dbb_qty} of ${item.total_qty} sold as DBB package — original price used for GP calculation`}
style={styles.dbbBadge}
>
{item.dbb_qty} DBB
</span>
)}
</td>
<td style={{ ...styles.td, textAlign: 'right' }}>&pound;{fmt(item.total_revenue_net)}</td>
<td style={{ ...styles.td, textAlign: 'right' }}>
{item.cost_per_portion != null ? `\u00A3${fmt(item.cost_per_portion)}` : '—'}
</td>
<td style={{ ...styles.td, textAlign: 'right' }}>
{item.total_cost != null ? `\u00A3${fmt(item.total_cost)}` : '—'}
</td>
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: gpColor(item.item_gp_percent) }}>
{fmtPct(item.item_gp_percent)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
))}
{/* Unmapped items section */}
{report.unmapped_items.length > 0 && (
<div style={styles.unmappedSection}>
<div style={styles.unmappedHeader}>
<span>Unmapped Items</span>
<span style={styles.unmappedSubtext}>
&pound;{fmt(report.unmapped_revenue_net)} unmapped ({(100 - Number(report.mapped_revenue_percent)).toFixed(1)}% of sales)
</span>
</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Item</th>
<th style={styles.th}>Portion</th>
<th style={styles.th}>Category</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Qty</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Net Revenue</th>
<th style={{ ...styles.th, textAlign: 'center' }}>Action</th>
</tr>
</thead>
<tbody>
{report.unmapped_items.map((item, idx) => (
<tr key={idx} style={styles.tr}>
<td style={styles.td}>{item.menu_item_name}</td>
<td style={{ ...styles.td, color: item.portion_name === 'Normal' ? '#ccc' : '#555' }}>
{item.portion_name === 'Normal' ? '—' : item.portion_name}
</td>
<td style={{ ...styles.td, color: '#888' }}>{item.category}</td>
<td style={{ ...styles.td, textAlign: 'right' }}>
{item.total_qty}
{item.dbb_qty > 0 && (
<span
title={`${item.dbb_qty} of ${item.total_qty} sold as DBB package — original price used`}
style={styles.dbbBadge}
>
{item.dbb_qty} DBB
</span>
)}
</td>
<td style={{ ...styles.td, textAlign: 'right' }}>&pound;{fmt(item.total_revenue_net)}</td>
<td style={{ ...styles.td, textAlign: 'center' }}>
<button
onClick={() => { setMappingItem(item); setRecipeSearch('') }}
style={styles.mapBtn}
>Map</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
{/* Recipe mapping modal */}
{mappingItem && (
<div style={styles.overlay}>
<div style={styles.modal}>
<div style={styles.modalHeader}>
<h3 style={{ margin: 0 }}>
Map: {mappingItem.menu_item_name}
{mappingItem.portion_name !== 'Normal' && ` (${mappingItem.portion_name})`}
</h3>
<button onClick={() => { setMappingItem(null); setRecipeSearch('') }} style={styles.closeBtn}>✕</button>
</div>
<div style={styles.modalBody}>
<input
value={recipeSearch}
onChange={(e) => setRecipeSearch(e.target.value)}
style={{ ...styles.searchInput, marginBottom: '0.75rem' }}
placeholder="Search dish recipes..."
autoFocus
/>
{!dishRecipes ? (
<div style={{ color: '#888', textAlign: 'center', padding: '1rem' }}>Loading recipes...</div>
) : dishRecipes.length === 0 ? (
<div style={{ color: '#888', textAlign: 'center', padding: '1rem' }}>No dish recipes found.</div>
) : (
<div style={{ maxHeight: '350px', overflow: 'auto' }}>
{dishRecipes.map(r => (
<div
key={r.id}
onClick={() => 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')}
>
<div>
<div style={{ fontWeight: 600 }}>{r.name}</div>
<div style={{ fontSize: '0.75rem', color: '#888' }}>
{r.menu_section_name || 'No course'}
{r.cost_per_portion != null && ` \u2022 Cost: \u00A3${Number(r.cost_per_portion).toFixed(2)}`}
</div>
</div>
{r.kds_menu_item_name && (
<div style={{ fontSize: '0.7rem', color: '#999' }}>
Already mapped: {r.kds_menu_item_name}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
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' },
}

View file

@ -0,0 +1,945 @@
/**
* Product Definitions Search Page
* Search and browse product definitions with portion info.
*/
import { useState, useMemo, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
import {
useDebounce,
usePersistedState,
formatQuantity,
formatCurrency,
SEARCH_STORAGE_KEYS,
} from '../utils/searchHelpers'
import * as pdfjsLib from 'pdfjs-dist'
// Configure PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'
interface ProductDefinition {
id: number
product_code: string | null
description_pattern: string | null
supplier_id: number | null
supplier_name: string | null
pack_quantity: number | null
unit_size: number | null
unit_size_type: string | null
portions_per_unit: number | null
portion_description: string | null
source_invoice_id: number | null
source_invoice_number: string | null
most_recent_price: number | null
updated_at: string | null
}
interface SearchResponse {
items: ProductDefinition[]
total_count: number
}
interface Supplier {
id: number
name: string
}
interface LineItem {
id: number
product_code: string | null
description: string | null
line_number: number
unit_price: number | null
quantity: number | null
unit: string | null
net_total: number | null
}
interface OcrData {
raw_json: any
raw_text: string
}
const keys = SEARCH_STORAGE_KEYS.definitions
export default function SearchDefinitions() {
const { token } = useAuth()
const queryClient = useQueryClient()
// Persisted search state
const [searchInput, setSearchInput] = usePersistedState(keys.query, '')
const [supplierId, setSupplierId] = usePersistedState<string>(keys.supplier, '')
const [hasPortions, setHasPortions] = usePersistedState<string>(keys.hasPortions, '')
const [sortColumn, setSortColumn] = usePersistedState<string>(keys.sortColumn, '')
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>(keys.sortDirection, 'asc')
// Edit modal state
const [editingDef, setEditingDef] = useState<ProductDefinition | null>(null)
const [editFormData, setEditFormData] = useState<{
pack_quantity: number | null
unit_size: number | null
unit_size_type: string | null
portions_per_unit: number | null
portion_description: string
}>({
pack_quantity: null,
unit_size: null,
unit_size_type: null,
portions_per_unit: null,
portion_description: '',
})
// Image/PDF state for edit modal
const [invoiceImageUrl, setInvoiceImageUrl] = useState<string | null>(null)
const [isPDF, setIsPDF] = useState(false)
const [pdfCanvas, setPdfCanvas] = useState<HTMLCanvasElement | null>(null)
const [croppedImageUrl, setCroppedImageUrl] = useState<string | null>(null)
const [matchingLineItem, setMatchingLineItem] = useState<LineItem | null>(null)
const imageRef = useRef<HTMLImageElement>(null)
// Debounce search input
const debouncedSearch = useDebounce(searchInput, 300)
// Fetch suppliers
const { data: suppliers } = useQuery<Supplier[]>({
queryKey: ['suppliers'],
queryFn: async () => {
const res = await fetch('/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json()
},
})
// Search definitions
const { data, isLoading, error } = useQuery<SearchResponse>({
queryKey: ['search-definitions', debouncedSearch, supplierId, hasPortions],
queryFn: async () => {
const params = new URLSearchParams()
if (debouncedSearch) params.set('q', debouncedSearch)
if (supplierId) params.set('supplier_id', supplierId)
if (hasPortions === 'yes') params.set('has_portions', 'true')
if (hasPortions === 'no') params.set('has_portions', 'false')
params.set('limit', '200')
const res = await fetch(`/api/search/definitions?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Search failed')
return res.json()
},
enabled: !!token,
})
// Fetch invoice line items when editing (to find matching line item for bounding box)
const { data: lineItems } = useQuery<LineItem[]>({
queryKey: ['invoice-line-items', editingDef?.source_invoice_id],
queryFn: async () => {
const res = await fetch(`/api/invoices/${editingDef!.source_invoice_id}/line-items`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch line items')
return res.json()
},
enabled: !!editingDef?.source_invoice_id,
})
// Fetch OCR data for bounding box
const { data: ocrData } = useQuery<OcrData>({
queryKey: ['invoice-ocr-data', editingDef?.source_invoice_id],
queryFn: async () => {
const res = await fetch(`/api/invoices/${editingDef!.source_invoice_id}/ocr-data`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch OCR data')
return res.json()
},
enabled: !!editingDef?.source_invoice_id,
})
// Update definition mutation
const updateMutation = useMutation({
mutationFn: async (data: { id: number; updates: typeof editFormData }) => {
const res = await fetch(`/api/search/definitions/${data.id}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data.updates),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || 'Failed to update definition')
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['search-definitions'] })
setEditingDef(null)
},
})
// Load invoice image when editing
useEffect(() => {
if (!editingDef?.source_invoice_id) {
setInvoiceImageUrl(null)
setIsPDF(false)
setPdfCanvas(null)
setCroppedImageUrl(null)
return
}
const url = `/api/invoices/${editingDef.source_invoice_id}/image`
setInvoiceImageUrl(url)
// Check if PDF by fetching headers
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((res) => {
const contentType = res.headers.get('content-type') || ''
setIsPDF(contentType.includes('pdf'))
if (contentType.includes('pdf')) {
return res.arrayBuffer()
}
return null
})
.then(async (arrayBuffer) => {
if (arrayBuffer) {
// Render PDF to canvas
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise
const page = await pdf.getPage(1)
const scale = 2
const viewport = page.getViewport({ scale })
const canvas = document.createElement('canvas')
canvas.width = viewport.width
canvas.height = viewport.height
const ctx = canvas.getContext('2d')
if (ctx) {
await page.render({ canvas, canvasContext: ctx, viewport }).promise
setPdfCanvas(canvas)
}
}
})
.catch(console.error)
}, [editingDef?.source_invoice_id, token])
// Find matching line item and crop bounding box
useEffect(() => {
if (!editingDef || !lineItems || !ocrData?.raw_json) {
setCroppedImageUrl(null)
setMatchingLineItem(null)
return
}
// Find the line item that matches this definition
const matchingIdx = lineItems.findIndex((item) => {
if (editingDef.product_code && item.product_code) {
return item.product_code === editingDef.product_code
}
if (editingDef.description_pattern && item.description) {
return item.description.toLowerCase().includes(editingDef.description_pattern.toLowerCase())
}
return false
})
if (matchingIdx < 0) {
setCroppedImageUrl(null)
setMatchingLineItem(null)
return
}
// Store the matching line item
setMatchingLineItem(lineItems[matchingIdx])
// Get bounding box from OCR data
const bboxRegion = ocrData.raw_json?.documents?.[0]?.fields?.Items?.value?.[matchingIdx]?.bounding_regions?.[0]
if (!bboxRegion?.polygon) {
setCroppedImageUrl(null)
return
}
const polygon = bboxRegion.polygon
const pageNumber = bboxRegion.page_number || 1
const pageInfo = ocrData.raw_json.pages?.[pageNumber - 1]
const pageWidth = pageInfo?.width || 8.5
const pageHeight = pageInfo?.height || 11
const xs = polygon.map((p: number[]) => p[0])
const ys = polygon.map((p: number[]) => p[1])
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
// Convert to percentages
const bboxPercent = {
x: (minX / pageWidth) * 100,
y: (minY / pageHeight) * 100,
width: ((maxX - minX) / pageWidth) * 100,
height: ((maxY - minY) / pageHeight) * 100,
}
// Create cropped image
const sourceCanvas = isPDF ? pdfCanvas : null
const sourceImage = !isPDF ? imageRef.current : null
if (!sourceCanvas && !sourceImage) return
const sourceWidth = sourceCanvas?.width || sourceImage?.naturalWidth || 0
const sourceHeight = sourceCanvas?.height || sourceImage?.naturalHeight || 0
if (sourceWidth === 0 || sourceHeight === 0) return
const cropX = (bboxPercent.x / 100) * sourceWidth
const cropY = (bboxPercent.y / 100) * sourceHeight
const cropW = (bboxPercent.width / 100) * sourceWidth
const cropH = (bboxPercent.height / 100) * sourceHeight
const horzPadding = 40
const vertPadding = 20
const startX = Math.max(0, cropX - horzPadding)
const startY = Math.max(0, cropY - vertPadding)
const endX = Math.min(sourceWidth, cropX + cropW + horzPadding)
const endY = Math.min(sourceHeight, cropY + cropH + vertPadding)
const finalW = endX - startX
const finalH = endY - startY
const croppedCanvas = document.createElement('canvas')
croppedCanvas.width = finalW
croppedCanvas.height = finalH
const ctx = croppedCanvas.getContext('2d')
if (ctx) {
if (sourceCanvas) {
ctx.drawImage(sourceCanvas, startX, startY, finalW, finalH, 0, 0, finalW, finalH)
} else if (sourceImage) {
ctx.drawImage(sourceImage, startX, startY, finalW, finalH, 0, 0, finalW, finalH)
}
setCroppedImageUrl(croppedCanvas.toDataURL())
}
}, [editingDef, lineItems, ocrData, isPDF, pdfCanvas])
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDirection('asc')
}
}
const getSortIndicator = (column: string) => {
if (sortColumn !== column) return null
return sortDirection === 'asc' ? ' ▲' : ' ▼'
}
// Sort items
const sortedItems = useMemo(() => {
if (!data?.items || !sortColumn) return data?.items || []
const sorted = [...data.items].sort((a, b) => {
let aVal: any
let bVal: any
switch (sortColumn) {
case 'code':
aVal = a.product_code || ''
bVal = b.product_code || ''
break
case 'description':
aVal = a.description_pattern || ''
bVal = b.description_pattern || ''
break
case 'supplier':
aVal = a.supplier_name || ''
bVal = b.supplier_name || ''
break
case 'pack_quantity':
aVal = a.pack_quantity ?? -Infinity
bVal = b.pack_quantity ?? -Infinity
break
case 'portions_per_unit':
aVal = a.portions_per_unit ?? -Infinity
bVal = b.portions_per_unit ?? -Infinity
break
case 'portion_cost':
aVal = (a.portions_per_unit && a.pack_quantity && a.most_recent_price)
? (typeof a.most_recent_price === 'string' ? parseFloat(a.most_recent_price) : a.most_recent_price) / (a.pack_quantity * a.portions_per_unit)
: -Infinity
bVal = (b.portions_per_unit && b.pack_quantity && b.most_recent_price)
? (typeof b.most_recent_price === 'string' ? parseFloat(b.most_recent_price) : b.most_recent_price) / (b.pack_quantity * b.portions_per_unit)
: -Infinity
break
case 'source':
aVal = a.source_invoice_number || ''
bVal = b.source_invoice_number || ''
break
default:
return 0
}
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortDirection === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal)
} else {
return sortDirection === 'asc'
? aVal - bVal
: bVal - aVal
}
})
return sorted
}, [data?.items, sortColumn, sortDirection])
// Group by supplier for summary
const supplierSummary = useMemo(() => {
if (!sortedItems) return null
const summary: Record<string, { count: number; withPortions: number }> = {}
for (const item of sortedItems) {
const name = item.supplier_name || 'Unknown'
if (!summary[name]) summary[name] = { count: 0, withPortions: 0 }
summary[name].count++
if (item.portions_per_unit) summary[name].withPortions++
}
return summary
}, [sortedItems])
const openEditModal = (def: ProductDefinition) => {
setEditingDef(def)
setEditFormData({
pack_quantity: def.pack_quantity,
unit_size: def.unit_size,
unit_size_type: def.unit_size_type,
portions_per_unit: def.portions_per_unit,
portion_description: def.portion_description || '',
})
// Clear previous modal state
setCroppedImageUrl(null)
setMatchingLineItem(null)
}
const handleSave = () => {
if (!editingDef) return
updateMutation.mutate({
id: editingDef.id,
updates: editFormData,
})
}
return (
<div style={{ padding: '20px', maxWidth: '1400px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '20px' }}>Search Product Definitions</h1>
{/* Search Controls */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
marginBottom: '20px',
padding: '16px',
backgroundColor: '#f8fafc',
borderRadius: '8px',
}}
>
<input
type="text"
placeholder="Search product code, description..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{
flex: '1 1 300px',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px',
}}
/>
<select
value={supplierId}
onChange={(e) => setSupplierId(e.target.value)}
style={{ padding: '8px', border: '1px solid #d1d5db', borderRadius: '4px' }}
>
<option value="">All Suppliers</option>
{suppliers?.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<select
value={hasPortions}
onChange={(e) => setHasPortions(e.target.value)}
style={{ padding: '8px', border: '1px solid #d1d5db', borderRadius: '4px' }}
>
<option value="">All Definitions</option>
<option value="yes">Has Portions Defined</option>
<option value="no">No Portions Defined</option>
</select>
</div>
{/* Legend */}
<div
style={{
display: 'flex',
gap: '16px',
marginBottom: '16px',
fontSize: '13px',
color: '#6b7280',
}}
>
<span>
<span style={{ color: '#8b5cf6' }}>📦</span> Has portions defined
</span>
<span>
<span style={{ color: '#9ca3af' }}>○</span> No portions defined
</span>
</div>
{/* Results */}
{isLoading && <p>Loading...</p>}
{error && <p style={{ color: 'red' }}>Error: {(error as Error).message}</p>}
{data && (
<div>
<p style={{ marginBottom: '12px', color: '#6b7280' }}>
Found {data.total_count} definition{data.total_count !== 1 ? 's' : ''}
</p>
{/* Supplier Summary */}
{supplierSummary && Object.keys(supplierSummary).length > 1 && (
<div
style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: '#f1f5f9',
borderRadius: '8px',
display: 'flex',
flexWrap: 'wrap',
gap: '16px',
}}
>
{Object.entries(supplierSummary).map(([name, stats]) => (
<div key={name} style={{ fontSize: '13px' }}>
<strong>{name}:</strong> {stats.count} ({stats.withPortions} with portions)
</div>
))}
</div>
)}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '900px' }}>
<thead>
<tr style={{ backgroundColor: '#f1f5f9' }}>
<th style={thStyleClickable} onClick={() => handleSort('code')}>
Code{getSortIndicator('code')}
</th>
<th style={{ ...thStyleClickable, minWidth: '280px' }} onClick={() => handleSort('description')}>
Description{getSortIndicator('description')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('supplier')}>
Supplier{getSortIndicator('supplier')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('pack_quantity')}>
Pack/Unit{getSortIndicator('pack_quantity')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('portions_per_unit')}>
Portions/Unit{getSortIndicator('portions_per_unit')}
</th>
<th style={thStyle}>Portion Desc</th>
<th style={{ ...thStyleClickable, textAlign: 'right' }} onClick={() => handleSort('portion_cost')}>
Portion Cost{getSortIndicator('portion_cost')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('source')}>
Source{getSortIndicator('source')}
</th>
<th style={{ ...thStyle, width: '60px' }}></th>
</tr>
</thead>
<tbody>
{sortedItems.map((def) => (
<tr key={def.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={tdStyle}>{def.product_code || '-'}</td>
<td style={tdStyle}>
{def.description_pattern || '-'}
{def.portions_per_unit ? (
<span style={{ marginLeft: '6px', color: '#8b5cf6' }}>📦</span>
) : (
<span style={{ marginLeft: '6px', color: '#9ca3af' }}>○</span>
)}
</td>
<td style={tdStyle}>{def.supplier_name || '-'}</td>
<td style={tdStyle}>
{def.pack_quantity || def.unit_size ? (
<>
{def.pack_quantity && `${def.pack_quantity}x `}
{def.unit_size && `${def.unit_size}${def.unit_size_type || ''}`}
</>
) : '-'}
</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
{formatQuantity(def.portions_per_unit)}
</td>
<td style={tdStyle}>
{def.portion_description ? (
<span
title={def.portion_description}
style={{
maxWidth: '150px',
display: 'inline-block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{def.portion_description}
</span>
) : (
'-'
)}
</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
{def.portions_per_unit && def.pack_quantity && def.most_recent_price ? (
<span style={{ color: '#16a34a', fontWeight: 600 }}>
{formatCurrency(
(typeof def.most_recent_price === 'string'
? parseFloat(def.most_recent_price)
: def.most_recent_price) / (def.pack_quantity * def.portions_per_unit)
)}
</span>
) : (
'-'
)}
</td>
<td style={tdStyle}>
{def.source_invoice_number ? (
<a
href={`/invoice/${def.source_invoice_id}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#2563eb', textDecoration: 'none' }}
>
{def.source_invoice_number}
</a>
) : (
'-'
)}
</td>
<td style={tdStyle}>
<button
onClick={() => openEditModal(def)}
style={{
padding: '4px 8px',
background: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
}}
title="Edit definition"
>
✏️
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Edit Modal */}
{editingDef && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
onClick={() => setEditingDef(null)}
>
<div
style={{
background: 'white',
padding: '24px',
borderRadius: '12px',
maxWidth: '700px',
width: '95%',
maxHeight: '90vh',
overflowY: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
<h2 style={{ marginBottom: '16px' }}>Edit Definition</h2>
{/* Product Info */}
<div style={{ marginBottom: '16px', padding: '12px', backgroundColor: '#f8fafc', borderRadius: '8px' }}>
<div><strong>Code:</strong> {editingDef.product_code || '-'}</div>
<div><strong>Description:</strong> {editingDef.description_pattern || '-'}</div>
<div><strong>Supplier:</strong> {editingDef.supplier_name || '-'}</div>
{editingDef.source_invoice_number && (
<div>
<strong>Source Invoice:</strong>{' '}
<a
href={`/invoice/${editingDef.source_invoice_id}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#2563eb' }}
>
{editingDef.source_invoice_number}
</a>
</div>
)}
</div>
{/* Cropped Invoice Image */}
{croppedImageUrl && (
<div style={{ marginBottom: '16px' }}>
<label style={{ fontWeight: 600, display: 'block', marginBottom: '8px' }}>
Line Item from Invoice:
</label>
<div
style={{
background: '#fff',
borderRadius: '4px',
padding: '8px',
border: '1px solid #ddd',
display: 'flex',
justifyContent: 'center',
}}
>
<img
src={croppedImageUrl}
alt="Line item from invoice"
style={{ maxWidth: '100%', height: 'auto', borderRadius: '2px' }}
/>
</div>
</div>
)}
{/* Hidden image for non-PDF sources */}
{invoiceImageUrl && !isPDF && (
<img
ref={imageRef}
src={invoiceImageUrl}
alt=""
style={{ display: 'none' }}
crossOrigin="anonymous"
onLoad={() => {
// Trigger re-render to crop image
setCroppedImageUrl(null)
setTimeout(() => {
if (imageRef.current && editingDef && lineItems && ocrData) {
// Re-trigger effect
setEditingDef({ ...editingDef })
}
}, 100)
}}
/>
)}
{/* Line Item Details & Cost Calculations */}
{matchingLineItem && (
<div style={{ marginBottom: '16px', padding: '12px', backgroundColor: '#f0fdf4', borderRadius: '8px', border: '1px solid #bbf7d0' }}>
<label style={{ fontWeight: 600, display: 'block', marginBottom: '8px' }}>
Invoice Line Item Details:
</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', fontSize: '14px' }}>
<div>
<span style={{ color: '#6b7280' }}>Unit Price:</span>{' '}
<strong>{formatCurrency(matchingLineItem.unit_price)}</strong>
</div>
<div>
<span style={{ color: '#6b7280' }}>Qty:</span>{' '}
<strong>{formatQuantity(matchingLineItem.quantity, 0)}</strong>
</div>
{matchingLineItem.unit && (
<div>
<span style={{ color: '#6b7280' }}>Unit:</span>{' '}
<strong>{matchingLineItem.unit}</strong>
</div>
)}
<div>
<span style={{ color: '#6b7280' }}>Net Total:</span>{' '}
<strong>{formatCurrency(matchingLineItem.net_total)}</strong>
</div>
</div>
{/* Portion Cost Calculations */}
{editFormData.portions_per_unit && editFormData.pack_quantity && matchingLineItem.unit_price && (
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid #bbf7d0' }}>
<label style={{ fontWeight: 600, display: 'block', marginBottom: '8px' }}>
Portion Cost Breakdown:
</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', fontSize: '14px' }}>
<div>
<span style={{ color: '#6b7280' }}>Pack Qty:</span>{' '}
<strong>{editFormData.pack_quantity}</strong>
</div>
<div>
<span style={{ color: '#6b7280' }}>Portions per Unit:</span>{' '}
<strong>{editFormData.portions_per_unit}</strong>
</div>
<div>
<span style={{ color: '#6b7280' }}>Cost per Portion:</span>{' '}
<strong style={{ color: '#16a34a' }}>
{formatCurrency(
(typeof matchingLineItem.unit_price === 'string'
? parseFloat(matchingLineItem.unit_price)
: matchingLineItem.unit_price) / (editFormData.pack_quantity * editFormData.portions_per_unit)
)}
</strong>
</div>
{editFormData.portion_description && (
<div>
<span style={{ color: '#6b7280' }}>Portion Size:</span>{' '}
<strong>{editFormData.portion_description}</strong>
</div>
)}
</div>
</div>
)}
</div>
)}
{/* Edit Fields */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', marginBottom: '20px' }}>
<div style={{ flex: '1 1 100px' }}>
<label style={{ fontWeight: 600, display: 'block', marginBottom: '4px' }}>Pack Qty</label>
<input
type="number"
value={editFormData.pack_quantity ?? ''}
onChange={(e) => setEditFormData({ ...editFormData, pack_quantity: e.target.value ? parseInt(e.target.value) : null })}
style={inputStyle}
placeholder="e.g., 120"
/>
</div>
<div style={{ flex: '1 1 120px' }}>
<label style={{ fontWeight: 600, display: 'block', marginBottom: '4px' }}>Unit Size</label>
<div style={{ display: 'flex', gap: '4px' }}>
<input
type="number"
step="0.1"
value={editFormData.unit_size ?? ''}
onChange={(e) => setEditFormData({ ...editFormData, unit_size: e.target.value ? parseFloat(e.target.value) : null })}
style={{ ...inputStyle, width: '70px' }}
placeholder="e.g., 15"
/>
<select
value={editFormData.unit_size_type || ''}
onChange={(e) => setEditFormData({ ...editFormData, unit_size_type: e.target.value || null })}
style={{ ...inputStyle, width: '70px' }}
>
<option value="">—</option>
<option value="each">each</option>
<option value="g">g</option>
<option value="kg">kg</option>
<option value="ml">ml</option>
<option value="ltr">ltr</option>
<option value="oz">oz</option>
<option value="cl">cl</option>
</select>
</div>
</div>
<div style={{ flex: '1 1 100px' }}>
<label style={{ fontWeight: 600, display: 'block', marginBottom: '4px' }}>Portions/Unit</label>
<input
type="number"
value={editFormData.portions_per_unit ?? ''}
onChange={(e) => setEditFormData({ ...editFormData, portions_per_unit: e.target.value ? parseInt(e.target.value) : null })}
style={inputStyle}
placeholder="—"
min="1"
/>
</div>
<div style={{ flex: '1 1 120px' }}>
<label style={{ fontWeight: 600, display: 'block', marginBottom: '4px' }}>Portion Desc</label>
<input
type="text"
value={editFormData.portion_description}
onChange={(e) => setEditFormData({ ...editFormData, portion_description: e.target.value })}
style={inputStyle}
placeholder="e.g., 250ml"
/>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={() => setEditingDef(null)}
style={{
padding: '8px 16px',
background: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={handleSave}
disabled={updateMutation.isPending}
style={{
padding: '8px 16px',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
opacity: updateMutation.isPending ? 0.7 : 1,
}}
>
{updateMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
{updateMutation.isError && (
<p style={{ color: 'red', marginTop: '12px' }}>
Error: {(updateMutation.error as Error).message}
</p>
)}
</div>
</div>
)}
</div>
)
}
const thStyle: React.CSSProperties = {
padding: '12px',
textAlign: 'left',
fontWeight: 600,
fontSize: '14px',
}
const thStyleClickable: React.CSSProperties = {
...thStyle,
cursor: 'pointer',
userSelect: 'none',
}
const tdStyle: React.CSSProperties = {
padding: '12px',
fontSize: '14px',
}
const inputStyle: React.CSSProperties = {
padding: '8px',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px',
width: '100%',
}

View file

@ -0,0 +1,509 @@
/**
* Invoice Search Page
* Search and filter invoices with optional line item content search.
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../App'
import {
useDebounce,
usePersistedState,
getDefaultDateRange,
formatDateForDisplay,
formatCurrency,
SEARCH_STORAGE_KEYS,
} from '../utils/searchHelpers'
interface InvoiceSearchItem {
id: number
invoice_number: string | null
invoice_date: string | null
total: number | null
net_total: number | null
supplier_id: number | null
supplier_name: string | null
vendor_name: string | null
status: string
document_type: string | null
}
interface GroupSummary {
name: string
count: number
total: number | null
}
interface SearchResponse {
items: InvoiceSearchItem[]
total_count: number
grouped_by: string | null
groups: GroupSummary[] | null
}
interface Supplier {
id: number
name: string
}
const keys = SEARCH_STORAGE_KEYS.invoices
export default function SearchInvoices() {
const { token } = useAuth()
const defaultDates = useMemo(() => getDefaultDateRange(), [])
// Persisted search state
const [searchInput, setSearchInput] = usePersistedState(keys.query, '')
const [includeLineItems, setIncludeLineItems] = usePersistedState(keys.includeLineItems, false)
const [supplierId, setSupplierId] = usePersistedState<string>(keys.supplier, '')
const [status, setStatus] = usePersistedState<string>(keys.status, '')
const [dateFrom, setDateFrom] = usePersistedState(keys.dateFrom, defaultDates.from)
const [dateTo, setDateTo] = usePersistedState(keys.dateTo, defaultDates.to)
const [groupBy, setGroupBy] = usePersistedState<string>(keys.groupBy, '')
const [sortColumn, setSortColumn] = usePersistedState<string>(keys.sortColumn, '')
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>(keys.sortDirection, 'asc')
// Collapsed groups state
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
// Debounce search input for live search
const debouncedSearch = useDebounce(searchInput, 300)
// Fetch suppliers for dropdown
const { data: suppliers } = useQuery<Supplier[]>({
queryKey: ['suppliers'],
queryFn: async () => {
const res = await fetch('/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json()
},
})
// Search invoices
const { data, isLoading, error } = useQuery<SearchResponse>({
queryKey: [
'search-invoices',
debouncedSearch,
includeLineItems,
supplierId,
status,
dateFrom,
dateTo,
groupBy,
],
queryFn: async () => {
const params = new URLSearchParams()
if (debouncedSearch) params.set('q', debouncedSearch)
if (includeLineItems) params.set('include_line_items', 'true')
if (supplierId) params.set('supplier_id', supplierId)
if (status) params.set('status', status)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (groupBy) params.set('group_by', groupBy)
params.set('limit', '200')
const res = await fetch(`/api/search/invoices?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Search failed')
return res.json()
},
enabled: !!token,
})
const toggleGroup = (name: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev)
if (next.has(name)) {
next.delete(name)
} else {
next.add(name)
}
return next
})
}
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDirection('asc')
}
}
const getSortIndicator = (column: string) => {
if (sortColumn !== column) return null
return sortDirection === 'asc' ? ' ▲' : ' ▼'
}
const getStatusBadge = (status: string) => {
const colors: Record<string, string> = {
pending: '#f59e0b',
processed: '#3b82f6',
reviewed: '#8b5cf6',
confirmed: '#22c55e',
}
return (
<span
style={{
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
backgroundColor: colors[status] || '#9ca3af',
color: 'white',
}}
>
{status}
</span>
)
}
// Helper to get net/gross total, filling in missing values
const getNetTotal = (inv: InvoiceSearchItem) => {
if (inv.net_total !== null) return inv.net_total
if (inv.total !== null) return inv.total // Assume no VAT if net is missing
return null
}
const getGrossTotal = (inv: InvoiceSearchItem) => {
if (inv.total !== null) return inv.total
if (inv.net_total !== null) return inv.net_total // Assume no VAT if gross is missing
return null
}
// Sort items
const sortedItems = useMemo(() => {
if (!data?.items || !sortColumn) return data?.items || []
const sorted = [...data.items].sort((a, b) => {
let aVal: any
let bVal: any
switch (sortColumn) {
case 'invoice_number':
aVal = a.invoice_number || ''
bVal = b.invoice_number || ''
break
case 'supplier':
aVal = a.supplier_name || a.vendor_name || ''
bVal = b.supplier_name || b.vendor_name || ''
break
case 'date':
aVal = a.invoice_date || ''
bVal = b.invoice_date || ''
break
case 'net_total':
aVal = getNetTotal(a) ?? -Infinity
bVal = getNetTotal(b) ?? -Infinity
break
case 'gross_total':
aVal = getGrossTotal(a) ?? -Infinity
bVal = getGrossTotal(b) ?? -Infinity
break
case 'status':
aVal = a.status || ''
bVal = b.status || ''
break
default:
return 0
}
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortDirection === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal)
} else {
return sortDirection === 'asc'
? aVal - bVal
: bVal - aVal
}
})
return sorted
}, [data?.items, sortColumn, sortDirection])
// Group items by the groupBy field
const groupedItems = useMemo(() => {
if (!sortedItems || !groupBy || !data?.groups) return null
const grouped: Record<string, InvoiceSearchItem[]> = {}
for (const item of sortedItems) {
let key: string
if (groupBy === 'supplier') {
key = item.supplier_name || 'Unknown'
} else if (groupBy === 'month') {
key = item.invoice_date?.substring(0, 7) || 'Unknown'
} else {
key = 'All'
}
if (!grouped[key]) grouped[key] = []
grouped[key].push(item)
}
return grouped
}, [sortedItems, groupBy, data?.groups])
return (
<div style={{ padding: '20px', maxWidth: '1400px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '20px' }}>Search Invoices</h1>
{/* Search Controls */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
marginBottom: '20px',
padding: '16px',
backgroundColor: '#f8fafc',
borderRadius: '8px',
}}
>
<input
type="text"
placeholder="Search invoice number, vendor..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{
flex: '1 1 250px',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px',
}}
/>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '14px',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={includeLineItems}
onChange={(e) => setIncludeLineItems(e.target.checked)}
/>
Include line items
</label>
<select
value={supplierId}
onChange={(e) => setSupplierId(e.target.value)}
style={{ padding: '8px', border: '1px solid #d1d5db', borderRadius: '4px' }}
>
<option value="">All Suppliers</option>
{suppliers?.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
style={{ padding: '8px', border: '1px solid #d1d5db', borderRadius: '4px' }}
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="processed">Processed</option>
<option value="reviewed">Reviewed</option>
<option value="confirmed">Confirmed</option>
</select>
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
style={{ padding: '8px', border: '1px solid #d1d5db', borderRadius: '4px' }}
>
<option value="">No Grouping</option>
<option value="supplier">Group by Supplier</option>
<option value="month">Group by Month</option>
</select>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<label style={{ fontSize: '14px' }}>From:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px' }}
/>
<label style={{ fontSize: '14px' }}>To:</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px' }}
/>
</div>
</div>
{/* Results */}
{isLoading && <p>Loading...</p>}
{error && <p style={{ color: 'red' }}>Error: {(error as Error).message}</p>}
{data && (
<div>
<p style={{ marginBottom: '12px', color: '#6b7280' }}>
Found {data.total_count} invoice{data.total_count !== 1 ? 's' : ''}
</p>
{groupBy && data.groups ? (
// Grouped view
<div>
{data.groups.map((group) => (
<div
key={group.name}
style={{
marginBottom: '16px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<div
onClick={() => toggleGroup(group.name)}
style={{
padding: '12px 16px',
backgroundColor: '#f1f5f9',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontWeight: 600 }}>
{collapsedGroups.has(group.name) ? '▸' : '▾'} {group.name}
</span>
<span style={{ color: '#6b7280' }}>
{group.count} invoice{group.count !== 1 ? 's' : ''}
{group.total !== null && ` • ${formatCurrency(group.total)}`}
</span>
</div>
{!collapsedGroups.has(group.name) && groupedItems?.[group.name] && (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: '#f8fafc' }}>
<th style={thStyleClickable} onClick={() => handleSort('invoice_number')}>
Invoice #{getSortIndicator('invoice_number')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('supplier')}>
Supplier{getSortIndicator('supplier')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('date')}>
Date{getSortIndicator('date')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('net_total')}>
Net Total{getSortIndicator('net_total')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('gross_total')}>
Gross Total{getSortIndicator('gross_total')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('status')}>
Status{getSortIndicator('status')}
</th>
</tr>
</thead>
<tbody>
{groupedItems[group.name].map((inv) => (
<tr key={inv.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={tdStyle}>
<a
href={`/invoice/${inv.id}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#2563eb', textDecoration: 'none' }}
>
{inv.invoice_number || '-'}
</a>
</td>
<td style={tdStyle}>{inv.supplier_name || inv.vendor_name || '-'}</td>
<td style={tdStyle}>{formatDateForDisplay(inv.invoice_date)}</td>
<td style={tdStyle}>{formatCurrency(getNetTotal(inv))}</td>
<td style={tdStyle}>{formatCurrency(getGrossTotal(inv))}</td>
<td style={tdStyle}>{getStatusBadge(inv.status)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
))}
</div>
) : (
// Flat table view
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: '#f1f5f9' }}>
<th style={thStyleClickable} onClick={() => handleSort('invoice_number')}>
Invoice #{getSortIndicator('invoice_number')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('supplier')}>
Supplier{getSortIndicator('supplier')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('date')}>
Date{getSortIndicator('date')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('net_total')}>
Net Total{getSortIndicator('net_total')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('gross_total')}>
Gross Total{getSortIndicator('gross_total')}
</th>
<th style={thStyleClickable} onClick={() => handleSort('status')}>
Status{getSortIndicator('status')}
</th>
</tr>
</thead>
<tbody>
{sortedItems.map((inv) => (
<tr key={inv.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={tdStyle}>
<a
href={`/invoice/${inv.id}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#2563eb', textDecoration: 'none' }}
>
{inv.invoice_number || '-'}
</a>
</td>
<td style={tdStyle}>{inv.supplier_name || inv.vendor_name || '-'}</td>
<td style={tdStyle}>{formatDateForDisplay(inv.invoice_date)}</td>
<td style={tdStyle}>{formatCurrency(getNetTotal(inv))}</td>
<td style={tdStyle}>{formatCurrency(getGrossTotal(inv))}</td>
<td style={tdStyle}>{getStatusBadge(inv.status)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
)
}
const thStyle: React.CSSProperties = {
padding: '12px',
textAlign: 'left',
fontWeight: 600,
fontSize: '14px',
}
const thStyleClickable: React.CSSProperties = {
...thStyle,
cursor: 'pointer',
userSelect: 'none',
}
const tdStyle: React.CSSProperties = {
padding: '12px',
fontSize: '14px',
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,741 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../App'
interface Supplier {
id: number
name: string
aliases: string[]
template_config: Record<string, unknown>
identifier_config: Record<string, unknown>
skip_dext: boolean
order_email: string | null
account_number: string | null
created_at: string
}
export default function Suppliers() {
const { token } = useAuth()
const queryClient = useQueryClient()
const [showAddModal, setShowAddModal] = useState(false)
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null)
const [newName, setNewName] = useState('')
const [newAliases, setNewAliases] = useState<string[]>([])
const [aliasInput, setAliasInput] = useState('')
const [skipDext, setSkipDext] = useState(false)
const [orderEmail, setOrderEmail] = useState('')
const [accountNumber, setAccountNumber] = useState('')
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
const { data: suppliers, isLoading } = useQuery<Supplier[]>({
queryKey: ['suppliers'],
queryFn: async () => {
const res = await fetch('/api/suppliers/', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch suppliers')
return res.json()
},
})
const createMutation = useMutation({
mutationFn: async ({ name, aliases, skip_dext, order_email, account_number }: { name: string; aliases: string[]; skip_dext: boolean; order_email: string; account_number: string }) => {
const res = await fetch('/api/suppliers/', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, aliases, skip_dext, order_email: order_email || null, account_number: account_number || null }),
})
if (!res.ok) throw new Error('Failed to create supplier')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['suppliers'] })
setShowAddModal(false)
setNewName('')
setNewAliases([])
setAliasInput('')
setSkipDext(false)
setOrderEmail('')
setAccountNumber('')
},
})
const updateMutation = useMutation({
mutationFn: async ({ id, name, aliases, skip_dext, order_email, account_number }: { id: number; name: string; aliases: string[]; skip_dext: boolean; order_email: string; account_number: string }) => {
const res = await fetch(`/api/suppliers/${id}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, aliases, skip_dext, order_email: order_email || null, account_number: account_number || null }),
})
if (!res.ok) throw new Error('Failed to update supplier')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['suppliers'] })
setEditingSupplier(null)
setNewName('')
setNewAliases([])
setAliasInput('')
setSkipDext(false)
setOrderEmail('')
setAccountNumber('')
},
})
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/suppliers/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to delete supplier')
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['suppliers'] })
setDeleteConfirm(null)
},
})
const handleAdd = () => {
if (newName.trim()) {
createMutation.mutate({ name: newName.trim(), aliases: newAliases, skip_dext: skipDext, order_email: orderEmail, account_number: accountNumber })
}
}
const handleUpdate = () => {
if (editingSupplier && newName.trim()) {
updateMutation.mutate({ id: editingSupplier.id, name: newName.trim(), aliases: newAliases, skip_dext: skipDext, order_email: orderEmail, account_number: accountNumber })
}
}
const startEdit = (supplier: Supplier) => {
setEditingSupplier(supplier)
setNewName(supplier.name)
setNewAliases(supplier.aliases || [])
setAliasInput('')
setSkipDext(supplier.skip_dext || false)
setOrderEmail(supplier.order_email || '')
setAccountNumber(supplier.account_number || '')
}
const addAlias = () => {
const trimmed = aliasInput.trim()
if (trimmed && !newAliases.includes(trimmed)) {
setNewAliases([...newAliases, trimmed])
setAliasInput('')
}
}
const removeAlias = (alias: string) => {
setNewAliases(newAliases.filter(a => a !== alias))
}
const handleAliasKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
addAlias()
}
}
const closeAddModal = () => {
setShowAddModal(false)
setNewName('')
setNewAliases([])
setAliasInput('')
setSkipDext(false)
setOrderEmail('')
setAccountNumber('')
}
const closeEditModal = () => {
setEditingSupplier(null)
setNewName('')
setNewAliases([])
setAliasInput('')
setSkipDext(false)
setOrderEmail('')
setAccountNumber('')
}
if (isLoading) {
return <div style={styles.loading}>Loading suppliers...</div>
}
return (
<div>
<div style={styles.header}>
<h2 style={styles.title}>Suppliers</h2>
<button onClick={() => setShowAddModal(true)} style={styles.addBtn}>
+ Add Supplier
</button>
</div>
<p style={styles.hint}>
Define your suppliers here. Add aliases for suppliers that appear with different names
on invoices (e.g., "US Foods", "USF", "U.S. Foods Inc").
</p>
{suppliers && suppliers.length === 0 ? (
<div style={styles.empty}>
<p>No suppliers defined yet.</p>
<p style={styles.emptyHint}>
Add your first supplier to start organizing invoices by vendor.
</p>
</div>
) : (
<div style={styles.list}>
{suppliers?.map((supplier) => (
<div key={supplier.id} style={styles.card}>
<div style={styles.cardMain}>
<span style={styles.supplierName}>
{supplier.name}
{supplier.skip_dext && (
<span style={styles.skipDextBadge}>Skip Dext</span>
)}
</span>
{supplier.aliases && supplier.aliases.length > 0 && (
<div style={styles.aliasesList}>
<span style={styles.aliasesLabel}>Also known as: </span>
{supplier.aliases.map((alias, idx) => (
<span key={alias} style={styles.aliasTag}>
{alias}{idx < supplier.aliases.length - 1 ? ', ' : ''}
</span>
))}
</div>
)}
{(supplier.order_email || supplier.account_number) && (
<div style={styles.poDetails}>
{supplier.order_email && <span>Order email: {supplier.order_email}</span>}
{supplier.account_number && <span>Account #: {supplier.account_number}</span>}
</div>
)}
<span style={styles.date}>
Added: {new Date(supplier.created_at).toLocaleDateString()}
</span>
</div>
<div style={styles.cardActions}>
<button onClick={() => startEdit(supplier)} style={styles.editBtn}>
Edit
</button>
<button
onClick={() => setDeleteConfirm(supplier.id)}
style={styles.deleteBtn}
>
Delete
</button>
</div>
</div>
))}
</div>
)}
{/* Add Modal */}
{showAddModal && (
<div style={styles.modalOverlay} onClick={closeAddModal}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h3>Add Supplier</h3>
<label style={styles.label}>
Primary Name
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
style={styles.input}
placeholder="e.g., Sysco"
autoFocus
/>
</label>
<label style={styles.label}>
Aliases (alternative names on invoices)
<div style={styles.aliasInputRow}>
<input
type="text"
value={aliasInput}
onChange={(e) => setAliasInput(e.target.value)}
onKeyDown={handleAliasKeyDown}
style={{ ...styles.input, flex: 1 }}
placeholder="e.g., SYSCO FOODS"
/>
<button type="button" onClick={addAlias} style={styles.addAliasBtn}>
Add
</button>
</div>
{newAliases.length > 0 && (
<div style={styles.aliasTagsContainer}>
{newAliases.map((alias) => (
<span key={alias} style={styles.aliasTagEditable}>
{alias}
<button
type="button"
onClick={() => removeAlias(alias)}
style={styles.removeAliasBtn}
>
x
</button>
</span>
))}
</div>
)}
</label>
<label style={styles.checkboxLabel}>
<input
type="checkbox"
checked={skipDext}
onChange={(e) => setSkipDext(e.target.checked)}
style={styles.checkbox}
/>
<span>Don't Forward to Dext</span>
<span style={styles.checkboxHint}>
Invoices from this supplier won't be sent to Dext
</span>
</label>
<div style={styles.poFieldsSection}>
<h4 style={styles.poFieldsTitle}>Purchase Order Details</h4>
<label style={styles.label}>
Order Email
<input
type="email"
value={orderEmail}
onChange={(e) => setOrderEmail(e.target.value)}
style={styles.input}
placeholder="orders@supplier.com"
/>
</label>
<label style={styles.label}>
Account Number
<input
type="text"
value={accountNumber}
onChange={(e) => setAccountNumber(e.target.value)}
style={styles.input}
placeholder="e.g., ACC-12345"
/>
</label>
</div>
<div style={styles.modalActions}>
<button onClick={closeAddModal} style={styles.cancelBtn}>
Cancel
</button>
<button
onClick={handleAdd}
style={styles.saveBtn}
disabled={!newName.trim() || createMutation.isPending}
>
{createMutation.isPending ? 'Adding...' : 'Add Supplier'}
</button>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingSupplier && (
<div style={styles.modalOverlay} onClick={closeEditModal}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h3>Edit Supplier</h3>
<label style={styles.label}>
Primary Name
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
style={styles.input}
autoFocus
/>
</label>
<label style={styles.label}>
Aliases (alternative names on invoices)
<div style={styles.aliasInputRow}>
<input
type="text"
value={aliasInput}
onChange={(e) => setAliasInput(e.target.value)}
onKeyDown={handleAliasKeyDown}
style={{ ...styles.input, flex: 1 }}
placeholder="Add an alias..."
/>
<button type="button" onClick={addAlias} style={styles.addAliasBtn}>
Add
</button>
</div>
{newAliases.length > 0 && (
<div style={styles.aliasTagsContainer}>
{newAliases.map((alias) => (
<span key={alias} style={styles.aliasTagEditable}>
{alias}
<button
type="button"
onClick={() => removeAlias(alias)}
style={styles.removeAliasBtn}
>
x
</button>
</span>
))}
</div>
)}
</label>
<label style={styles.checkboxLabel}>
<input
type="checkbox"
checked={skipDext}
onChange={(e) => setSkipDext(e.target.checked)}
style={styles.checkbox}
/>
<span>Don't Forward to Dext</span>
<span style={styles.checkboxHint}>
Invoices from this supplier won't be sent to Dext
</span>
</label>
<div style={styles.poFieldsSection}>
<h4 style={styles.poFieldsTitle}>Purchase Order Details</h4>
<label style={styles.label}>
Order Email
<input
type="email"
value={orderEmail}
onChange={(e) => setOrderEmail(e.target.value)}
style={styles.input}
placeholder="orders@supplier.com"
/>
</label>
<label style={styles.label}>
Account Number
<input
type="text"
value={accountNumber}
onChange={(e) => setAccountNumber(e.target.value)}
style={styles.input}
placeholder="e.g., ACC-12345"
/>
</label>
</div>
<div style={styles.modalActions}>
<button onClick={closeEditModal} style={styles.cancelBtn}>
Cancel
</button>
<button
onClick={handleUpdate}
style={styles.saveBtn}
disabled={!newName.trim() || updateMutation.isPending}
>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteConfirm && (
<div style={styles.modalOverlay} onClick={() => setDeleteConfirm(null)}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h3>Delete Supplier?</h3>
<p>Are you sure you want to delete this supplier?</p>
<p style={styles.warningText}>
Invoices linked to this supplier will have their supplier cleared.
</p>
<div style={styles.modalActions}>
<button onClick={() => setDeleteConfirm(null)} style={styles.cancelBtn}>
Cancel
</button>
<button
onClick={() => deleteMutation.mutate(deleteConfirm)}
style={styles.confirmDeleteBtn}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
loading: {
padding: '2rem',
textAlign: 'center',
color: '#666',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1rem',
},
title: {
color: '#1a1a2e',
margin: 0,
},
addBtn: {
padding: '0.75rem 1.5rem',
background: '#e94560',
color: 'white',
border: 'none',
borderRadius: '8px',
fontWeight: 'bold',
cursor: 'pointer',
},
hint: {
color: '#666',
marginBottom: '1.5rem',
background: 'white',
padding: '1rem',
borderRadius: '8px',
},
empty: {
background: 'white',
padding: '3rem',
borderRadius: '12px',
textAlign: 'center',
color: '#666',
},
emptyHint: {
marginTop: '0.5rem',
fontSize: '0.9rem',
color: '#999',
},
list: {
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
},
card: {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
background: 'white',
padding: '1rem 1.5rem',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
},
cardMain: {
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
flex: 1,
},
supplierName: {
fontWeight: 'bold',
fontSize: '1.1rem',
color: '#1a1a2e',
},
aliasesList: {
fontSize: '0.85rem',
color: '#666',
marginTop: '0.25rem',
},
aliasesLabel: {
color: '#999',
},
aliasTag: {
color: '#1a1a2e',
},
date: {
fontSize: '0.8rem',
color: '#999',
marginTop: '0.25rem',
},
cardActions: {
display: 'flex',
gap: '0.5rem',
marginLeft: '1rem',
},
editBtn: {
padding: '0.5rem 1rem',
background: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.85rem',
},
deleteBtn: {
padding: '0.5rem 1rem',
background: 'white',
border: '1px solid #dc3545',
color: '#dc3545',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.85rem',
},
modalOverlay: {
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',
padding: '2rem',
borderRadius: '12px',
maxWidth: '500px',
width: '90%',
},
label: {
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
color: '#333',
fontWeight: '500',
marginTop: '1rem',
},
input: {
padding: '0.75rem',
borderRadius: '6px',
border: '1px solid #ddd',
fontSize: '1rem',
},
aliasInputRow: {
display: 'flex',
gap: '0.5rem',
},
addAliasBtn: {
padding: '0.75rem 1rem',
background: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.9rem',
},
aliasTagsContainer: {
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginTop: '0.5rem',
},
aliasTagEditable: {
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
background: '#e9ecef',
borderRadius: '4px',
fontSize: '0.85rem',
},
removeAliasBtn: {
background: 'none',
border: 'none',
color: '#999',
cursor: 'pointer',
padding: '0 0.25rem',
fontSize: '0.9rem',
lineHeight: 1,
},
modalActions: {
display: 'flex',
gap: '1rem',
marginTop: '1.5rem',
},
cancelBtn: {
flex: 1,
padding: '0.75rem',
background: '#f0f0f0',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
},
saveBtn: {
flex: 1,
padding: '0.75rem',
background: '#1a1a2e',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 'bold',
},
confirmDeleteBtn: {
flex: 1,
padding: '0.75rem',
background: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 'bold',
},
warningText: {
color: '#856404',
background: '#fff3cd',
padding: '0.75rem',
borderRadius: '6px',
fontSize: '0.9rem',
marginTop: '0.5rem',
},
checkboxLabel: {
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '0.5rem',
marginTop: '1.25rem',
padding: '1rem',
background: '#f8f9fa',
borderRadius: '8px',
cursor: 'pointer',
},
checkbox: {
width: '18px',
height: '18px',
cursor: 'pointer',
},
checkboxHint: {
width: '100%',
fontSize: '0.8rem',
color: '#666',
marginLeft: '26px',
},
skipDextBadge: {
display: 'inline-block',
fontSize: '0.7rem',
padding: '0.2rem 0.5rem',
background: '#28a745',
color: 'white',
borderRadius: '4px',
marginLeft: '0.5rem',
fontWeight: 'normal',
},
poDetails: {
display: 'flex',
gap: '1.5rem',
fontSize: '0.8rem',
color: '#666',
marginTop: '0.25rem',
},
poFieldsSection: {
marginTop: '1.25rem',
padding: '1rem',
background: '#f8f9fa',
borderRadius: '8px',
},
poFieldsTitle: {
margin: '0 0 0.75rem 0',
fontSize: '0.9rem',
color: '#555',
fontWeight: 600,
},
}

View file

@ -0,0 +1,344 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import html2canvas from 'html2canvas'
import { useAuth } from '../App'
interface SupportEnabledResponse {
enabled: boolean
}
export default function SupportButton() {
const { token } = useAuth()
const [isModalOpen, setIsModalOpen] = useState(false)
const [description, setDescription] = useState('')
const [screenshot, setScreenshot] = useState<string | null>(null)
const [isCapturing, setIsCapturing] = useState(false)
// Check if support is enabled
const { data: supportStatus } = useQuery<SupportEnabledResponse>({
queryKey: ['support-enabled'],
queryFn: async () => {
const res = await fetch('/api/support/enabled', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return { enabled: false }
return res.json()
},
enabled: !!token,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
})
// Submit support request
const submitMutation = useMutation({
mutationFn: async (data: { description: string; screenshot: string }) => {
const res = await fetch('/api/support/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
description: data.description,
screenshot: data.screenshot,
page_url: window.location.href,
browser_info: navigator.userAgent,
}),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.detail || 'Failed to submit request')
}
return res.json()
},
onSuccess: () => {
setIsModalOpen(false)
setDescription('')
setScreenshot(null)
alert('Support request sent successfully!')
},
onError: (error: Error) => {
alert(`Failed to send: ${error.message}`)
},
})
// Capture screenshot when modal opens
const captureScreenshot = async () => {
setIsCapturing(true)
try {
// Small delay to ensure modal is hidden
await new Promise(resolve => setTimeout(resolve, 100))
const canvas = await html2canvas(document.body, {
useCORS: true,
allowTaint: true,
backgroundColor: '#f0f2f5',
scale: 1, // Lower scale to reduce file size
logging: false,
ignoreElements: (element: Element) => {
// Ignore the support button and modal
return element.classList.contains('support-button-container') ||
element.classList.contains('support-modal-overlay')
},
} as Parameters<typeof html2canvas>[1])
const dataUrl = canvas.toDataURL('image/png', 0.8)
setScreenshot(dataUrl)
} catch (error) {
console.error('Failed to capture screenshot:', error)
} finally {
setIsCapturing(false)
}
}
const handleOpenModal = () => {
setIsModalOpen(true)
}
// Capture screenshot after modal state updates
useEffect(() => {
if (isModalOpen && !screenshot && !isCapturing) {
// Use requestAnimationFrame to ensure DOM is updated
requestAnimationFrame(() => {
captureScreenshot()
})
}
}, [isModalOpen])
const handleSubmit = () => {
if (!description.trim()) {
alert('Please describe the issue')
return
}
if (!screenshot) {
alert('Screenshot not ready, please wait')
return
}
submitMutation.mutate({ description, screenshot })
}
const handleClose = () => {
setIsModalOpen(false)
setDescription('')
setScreenshot(null)
}
// Don't render if support is not enabled
if (!supportStatus?.enabled) {
return null
}
return (
<>
{/* Floating button */}
<div className="support-button-container" style={styles.buttonContainer}>
<button
onClick={handleOpenModal}
style={styles.floatingButton}
title="Report an issue"
>
?
</button>
</div>
{/* Modal */}
{isModalOpen && (
<div className="support-modal-overlay" style={styles.overlay}>
<div style={styles.modal}>
<div style={styles.header}>
<h3 style={styles.title}>Report an Issue</h3>
<button onClick={handleClose} style={styles.closeButton}>
X
</button>
</div>
<div style={styles.content}>
{/* Screenshot preview */}
<div style={styles.screenshotSection}>
<label style={styles.label}>Page Screenshot</label>
{isCapturing ? (
<div style={styles.screenshotPlaceholder}>
Capturing screenshot...
</div>
) : screenshot ? (
<img
src={screenshot}
alt="Page screenshot"
style={styles.screenshotPreview}
/>
) : (
<div style={styles.screenshotPlaceholder}>
Screenshot failed - please try again
</div>
)}
</div>
{/* Description field */}
<div style={styles.field}>
<label style={styles.label}>Describe the issue *</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What went wrong? What were you trying to do?"
style={styles.textarea}
rows={4}
/>
</div>
<p style={styles.hint}>
This will send the screenshot and your description to the support team.
</p>
</div>
<div style={styles.footer}>
<button onClick={handleClose} style={styles.cancelButton}>
Cancel
</button>
<button
onClick={handleSubmit}
style={styles.submitButton}
disabled={submitMutation.isPending || !screenshot || !description.trim()}
>
{submitMutation.isPending ? 'Sending...' : 'Send Report'}
</button>
</div>
</div>
</div>
)}
</>
)
}
const styles: Record<string, React.CSSProperties> = {
buttonContainer: {
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: 9998,
},
floatingButton: {
width: '50px',
height: '50px',
borderRadius: '50%',
background: '#e94560',
color: 'white',
border: 'none',
fontSize: '1.5rem',
fontWeight: 'bold',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(233, 69, 96, 0.4)',
transition: 'transform 0.2s, box-shadow 0.2s',
},
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: 9999,
},
modal: {
background: 'white',
borderRadius: '12px',
width: '90%',
maxWidth: '600px',
maxHeight: '90vh',
overflow: 'auto',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.2)',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1rem 1.5rem',
borderBottom: '1px solid #eee',
},
title: {
margin: 0,
color: '#1a1a2e',
},
closeButton: {
background: 'none',
border: 'none',
fontSize: '1.2rem',
cursor: 'pointer',
color: '#666',
padding: '0.5rem',
},
content: {
padding: '1.5rem',
},
screenshotSection: {
marginBottom: '1.5rem',
},
label: {
display: 'block',
fontWeight: '600',
marginBottom: '0.5rem',
color: '#333',
},
screenshotPreview: {
width: '100%',
maxHeight: '200px',
objectFit: 'contain',
border: '1px solid #ddd',
borderRadius: '6px',
background: '#f5f5f5',
},
screenshotPlaceholder: {
width: '100%',
height: '150px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '6px',
color: '#666',
},
field: {
marginBottom: '1rem',
},
textarea: {
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '1rem',
resize: 'vertical',
fontFamily: 'inherit',
},
hint: {
fontSize: '0.85rem',
color: '#666',
margin: 0,
},
footer: {
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
padding: '1rem 1.5rem',
borderTop: '1px solid #eee',
},
cancelButton: {
padding: '0.75rem 1.5rem',
background: '#f0f0f0',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.9rem',
},
submitButton: {
padding: '0.75rem 1.5rem',
background: '#e94560',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.9rem',
fontWeight: '600',
},
}

View file

@ -0,0 +1,865 @@
import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../App'
import { useQueryClient } from '@tanstack/react-query'
import { PDFDocument } from 'pdf-lib'
import imageCompression from 'browser-image-compression'
interface PageImage {
id: string
file: File
preview: string
compressed?: Blob
}
interface QueueItem {
id: string
filename: string
status: 'uploading' | 'processing' | 'complete' | 'error'
error?: string
invoiceId?: number
}
export default function Upload() {
const { token } = useAuth()
const navigate = useNavigate()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
const addMoreInputRef = useRef<HTMLInputElement>(null)
const [pages, setPages] = useState<PageImage[]>([])
const [isDragging, setIsDragging] = useState(false)
const [processing, setProcessing] = useState(false)
const [error, setError] = useState('')
const [status, setStatus] = useState('')
const [queue, setQueue] = useState<QueueItem[]>([])
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
// Listen for PWA install prompt
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e)
}
window.addEventListener('beforeinstallprompt', handler)
return () => window.removeEventListener('beforeinstallprompt', handler)
}, [])
// Compression options - balance quality vs size
const compressionOptions = {
maxSizeMB: 1, // Target max 1MB per image
maxWidthOrHeight: 2000, // Good for OCR
useWebWorker: true,
fileType: 'image/jpeg' as const,
initialQuality: 0.85, // 85% JPEG quality
}
const generateId = () => Math.random().toString(36).substr(2, 9)
const handleFiles = async (files: FileList | File[]) => {
const fileArray = Array.from(files)
const newPages: PageImage[] = []
for (const file of fileArray) {
const isImage = file.type.startsWith('image/')
const isPDF = file.type === 'application/pdf'
if (!isImage && !isPDF) {
setError('Please upload image or PDF files only')
continue
}
if (isPDF) {
// For PDFs, upload directly without conversion (don't wait)
uploadFile(file)
continue
}
// Create preview for image
const preview = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = (e) => resolve(e.target?.result as string)
reader.readAsDataURL(file)
})
newPages.push({
id: generateId(),
file,
preview,
})
}
if (newPages.length > 0) {
setPages(prev => [...prev, ...newPages])
}
setError('')
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (e.dataTransfer.files.length > 0) {
handleFiles(e.dataTransfer.files)
}
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFiles(e.target.files)
}
// Reset input so same file can be selected again
e.target.value = ''
}
const removePage = (id: string) => {
setPages(prev => prev.filter(p => p.id !== id))
}
const movePage = (id: string, direction: 'up' | 'down') => {
setPages(prev => {
const idx = prev.findIndex(p => p.id === id)
if (idx === -1) return prev
if (direction === 'up' && idx === 0) return prev
if (direction === 'down' && idx === prev.length - 1) return prev
const newPages = [...prev]
const swapIdx = direction === 'up' ? idx - 1 : idx + 1
;[newPages[idx], newPages[swapIdx]] = [newPages[swapIdx], newPages[idx]]
return newPages
})
}
const generateQueueId = () => Math.random().toString(36).substr(2, 9)
const uploadFile = async (file: File, fromPages: boolean = false) => {
const queueId = generateQueueId()
const filename = file.name || 'invoice.pdf'
// Add to queue as uploading
setQueue(prev => [...prev, {
id: queueId,
filename,
status: 'uploading'
}])
// Clear pages if this was from multi-page upload
if (fromPages) {
setPages([])
setProcessing(false)
setStatus('')
}
try {
const formData = new FormData()
formData.append('file', file)
// Update to processing (upload complete, OCR starting)
setQueue(prev => prev.map(item =>
item.id === queueId ? { ...item, status: 'processing' as const } : item
))
const res = await fetch('/api/invoices/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'Upload failed')
}
const result = await res.json()
// Update to complete
setQueue(prev => prev.map(item =>
item.id === queueId ? { ...item, status: 'complete' as const, invoiceId: result.id } : item
))
// Refresh invoice list
queryClient.invalidateQueries({ queryKey: ['invoices'] })
} catch (err) {
// Update to error
setQueue(prev => prev.map(item =>
item.id === queueId ? {
...item,
status: 'error' as const,
error: err instanceof Error ? err.message : 'Upload failed'
} : item
))
}
}
const removeFromQueue = (id: string) => {
setQueue(prev => prev.filter(item => item.id !== id))
}
const clearCompletedFromQueue = () => {
setQueue(prev => prev.filter(item => item.status !== 'complete' && item.status !== 'error'))
}
const processAndUpload = async () => {
if (pages.length === 0) {
setError('Please add at least one page')
return
}
setProcessing(true)
setError('')
try {
// Step 1: Compress images
setStatus(`Compressing ${pages.length} image(s)...`)
const compressedImages: { data: ArrayBuffer; width: number; height: number }[] = []
for (let i = 0; i < pages.length; i++) {
setStatus(`Compressing page ${i + 1} of ${pages.length}...`)
const compressed = await imageCompression(pages[i].file, compressionOptions)
// Get image dimensions
const img = new Image()
const imgUrl = URL.createObjectURL(compressed)
await new Promise<void>((resolve) => {
img.onload = () => {
URL.revokeObjectURL(imgUrl)
resolve()
}
img.src = imgUrl
})
const arrayBuffer = await compressed.arrayBuffer()
compressedImages.push({
data: arrayBuffer,
width: img.width,
height: img.height,
})
}
// Step 2: Create PDF
setStatus('Creating PDF...')
const pdfDoc = await PDFDocument.create()
for (let i = 0; i < compressedImages.length; i++) {
setStatus(`Adding page ${i + 1} to PDF...`)
const { data, width, height } = compressedImages[i]
// Embed the JPEG image
const jpegImage = await pdfDoc.embedJpg(data)
// Create page with image dimensions (or scale to fit)
const page = pdfDoc.addPage([width, height])
page.drawImage(jpegImage, {
x: 0,
y: 0,
width: width,
height: height,
})
}
// Step 3: Save PDF
setStatus('Finalizing PDF...')
const pdfBytes = await pdfDoc.save()
const pdfBlob = new Blob([pdfBytes as BlobPart], { type: 'application/pdf' })
const pdfFile = new File([pdfBlob], `invoice-${pages.length}pages.pdf`, { type: 'application/pdf' })
// Step 4: Upload (don't wait - let it run in background)
uploadFile(pdfFile, true)
} catch (err) {
console.error('Processing error:', err)
setError(err instanceof Error ? err.message : 'Failed to process images')
setProcessing(false)
setStatus('')
}
}
const clearAll = () => {
setPages([])
setError('')
setStatus('')
}
const isWorking = processing || queue.some(item => item.status === 'uploading' || item.status === 'processing')
return (
<div>
<h2 style={styles.title}>Upload Invoice</h2>
{error && <div style={styles.error}>{error}</div>}
{/* Processing Queue */}
{queue.length > 0 && (
<div style={styles.queueSection}>
<div style={styles.queueHeader}>
<h3 style={styles.queueTitle}>Processing Queue</h3>
{queue.some(item => item.status === 'complete' || item.status === 'error') && (
<button onClick={clearCompletedFromQueue} style={styles.clearQueueBtn}>
Clear Completed
</button>
)}
</div>
<div style={styles.queueList}>
{queue.map(item => (
<div key={item.id} style={{
...styles.queueItem,
...(item.status === 'complete' ? styles.queueItemComplete : {}),
...(item.status === 'error' ? styles.queueItemError : {}),
}}>
<div style={styles.queueItemInfo}>
<span style={styles.queueItemName}>{item.filename}</span>
<span style={{
...styles.queueItemStatus,
color: item.status === 'complete' ? '#28a745' :
item.status === 'error' ? '#dc3545' :
item.status === 'processing' ? '#0066cc' : '#666'
}}>
{item.status === 'uploading' && '⬆️ Uploading...'}
{item.status === 'processing' && '⏳ Processing OCR...'}
{item.status === 'complete' && '✓ Complete'}
{item.status === 'error' && `✗ ${item.error || 'Failed'}`}
</span>
</div>
<div style={styles.queueItemActions}>
{item.status === 'complete' && item.invoiceId && (
<button
onClick={() => navigate(`/invoice/${item.invoiceId}`)}
style={styles.queueViewBtn}
>
View
</button>
)}
{(item.status === 'complete' || item.status === 'error') && (
<button
onClick={() => removeFromQueue(item.id)}
style={styles.queueRemoveBtn}
>
×
</button>
)}
{(item.status === 'uploading' || item.status === 'processing') && (
<div style={styles.queueSpinner}></div>
)}
</div>
</div>
))}
</div>
</div>
)}
{pages.length === 0 ? (
// Initial drop zone
<div
style={{
...styles.dropzone,
...(isDragging ? styles.dropzoneActive : {}),
}}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<div style={styles.uploadPrompt}>
<div style={styles.uploadIcon}>📄</div>
<p>Drag and drop invoice images</p>
<p style={styles.subtext}>or click to select files</p>
<p style={styles.formats}>Supports: JPG, PNG, WebP, HEIC, PDF</p>
<p style={styles.multiPageHint}>
Add multiple images for multi-page invoices
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*,application/pdf"
multiple
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
</div>
) : (
// Pages preview
<div style={styles.pagesContainer}>
<div style={styles.pagesHeader}>
<h3>{pages.length} Page{pages.length !== 1 ? 's' : ''} Ready</h3>
<div style={styles.headerActions}>
<button
onClick={() => addMoreInputRef.current?.click()}
style={styles.addMoreBtn}
disabled={isWorking}
>
+ Add More Pages
</button>
<button
onClick={clearAll}
style={styles.clearBtn}
disabled={isWorking}
>
Clear All
</button>
</div>
<input
ref={addMoreInputRef}
type="file"
accept="image/*"
multiple
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
</div>
<div style={styles.pagesGrid}>
{pages.map((page, idx) => (
<div key={page.id} style={styles.pageCard}>
<div style={styles.pageNumber}>Page {idx + 1}</div>
<img src={page.preview} alt={`Page ${idx + 1}`} style={styles.pagePreview} />
<div style={styles.pageActions}>
<button
onClick={() => movePage(page.id, 'up')}
disabled={idx === 0 || isWorking}
style={styles.moveBtn}
title="Move up"
>
↑
</button>
<button
onClick={() => movePage(page.id, 'down')}
disabled={idx === pages.length - 1 || isWorking}
style={styles.moveBtn}
title="Move down"
>
↓
</button>
<button
onClick={() => removePage(page.id)}
disabled={isWorking}
style={styles.removeBtn}
title="Remove page"
>
×
</button>
</div>
</div>
))}
</div>
{processing && (
<div style={styles.processingOverlay}>
<div style={styles.spinner}></div>
<p>{status || 'Processing...'}</p>
</div>
)}
<button
onClick={processAndUpload}
disabled={isWorking}
style={{
...styles.uploadBtn,
...(isWorking ? styles.uploadBtnDisabled : {}),
}}
>
{isWorking ? status || 'Processing...' : `Create PDF & Upload (${pages.length} page${pages.length !== 1 ? 's' : ''})`}
</button>
</div>
)}
<div style={styles.cameraSection}>
<p><strong>Take photos with camera:</strong></p>
<p style={styles.cameraHint}>
For multi-page invoices, take each page separately then click "Add More Pages"
</p>
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleFileSelect}
style={styles.cameraInput}
/>
</div>
{/* PWA Install / Mobile App */}
<div style={styles.pwaSection}>
<div style={styles.pwaContent}>
<div>
<strong>Upload App</strong>
<p style={styles.pwaHint}>Install the upload app for quick camera access on mobile</p>
</div>
{deferredPrompt ? (
<button
onClick={async () => {
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'accepted') setDeferredPrompt(null)
}}
style={styles.installBtn}
>
Install App
</button>
) : (
<a href="/upload-app" style={styles.openAppLink}>Open Upload App</a>
)}
</div>
</div>
<div style={styles.infoSection}>
<h4>How it works:</h4>
<ol style={styles.infoList}>
<li>Add one or more invoice page images</li>
<li>Reorder pages if needed using the arrows</li>
<li>Click "Create PDF & Upload" to combine and process</li>
<li>Images are compressed to save space while maintaining OCR quality</li>
</ol>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
title: {
marginBottom: '1.5rem',
color: '#1a1a2e',
},
error: {
background: '#fee',
color: '#c00',
padding: '1rem',
borderRadius: '8px',
marginBottom: '1rem',
},
dropzone: {
background: 'white',
border: '3px dashed #ddd',
borderRadius: '12px',
padding: '3rem',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
minHeight: '300px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
dropzoneActive: {
borderColor: '#e94560',
background: '#fff5f7',
},
uploadPrompt: {
color: '#666',
},
uploadIcon: {
fontSize: '4rem',
marginBottom: '1rem',
},
subtext: {
color: '#999',
fontSize: '0.9rem',
marginTop: '0.5rem',
},
formats: {
marginTop: '1rem',
fontSize: '0.8rem',
color: '#999',
},
multiPageHint: {
marginTop: '1.5rem',
padding: '0.5rem 1rem',
background: '#e8f4fd',
borderRadius: '6px',
fontSize: '0.85rem',
color: '#0066cc',
},
pagesContainer: {
background: 'white',
borderRadius: '12px',
padding: '1.5rem',
position: 'relative',
},
pagesHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1rem',
flexWrap: 'wrap',
gap: '0.5rem',
},
headerActions: {
display: 'flex',
gap: '0.5rem',
},
addMoreBtn: {
padding: '0.5rem 1rem',
background: '#e94560',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.9rem',
},
clearBtn: {
padding: '0.5rem 1rem',
background: '#f0f0f0',
color: '#666',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.9rem',
},
pagesGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: '1rem',
marginBottom: '1.5rem',
},
pageCard: {
background: '#f8f9fa',
borderRadius: '8px',
padding: '0.5rem',
position: 'relative',
},
pageNumber: {
position: 'absolute',
top: '0.75rem',
left: '0.75rem',
background: 'rgba(0,0,0,0.7)',
color: 'white',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 'bold',
},
pagePreview: {
width: '100%',
height: '180px',
objectFit: 'cover',
borderRadius: '6px',
},
pageActions: {
display: 'flex',
justifyContent: 'center',
gap: '0.5rem',
marginTop: '0.5rem',
},
moveBtn: {
width: '32px',
height: '32px',
background: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
},
removeBtn: {
width: '32px',
height: '32px',
background: '#fee',
border: '1px solid #fcc',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1.2rem',
color: '#c00',
},
processingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(255,255,255,0.9)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '12px',
zIndex: 10,
},
spinner: {
width: '40px',
height: '40px',
border: '4px solid #ddd',
borderTopColor: '#e94560',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: '1rem',
},
uploadBtn: {
width: '100%',
padding: '1rem',
background: '#1a1a2e',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '1rem',
fontWeight: 'bold',
},
uploadBtnDisabled: {
background: '#999',
cursor: 'wait',
},
cameraSection: {
marginTop: '2rem',
padding: '1.5rem',
background: 'white',
borderRadius: '12px',
textAlign: 'center',
},
cameraHint: {
fontSize: '0.85rem',
color: '#666',
marginTop: '0.5rem',
marginBottom: '1rem',
},
cameraInput: {
marginTop: '0.5rem',
},
infoSection: {
marginTop: '1.5rem',
padding: '1.5rem',
background: '#f8f9fa',
borderRadius: '12px',
},
infoList: {
margin: '0.5rem 0 0 1.5rem',
color: '#666',
lineHeight: '1.8',
},
queueSection: {
background: 'white',
borderRadius: '12px',
padding: '1rem 1.5rem',
marginBottom: '1.5rem',
},
queueHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.75rem',
},
queueTitle: {
margin: 0,
fontSize: '1rem',
color: '#1a1a2e',
},
clearQueueBtn: {
padding: '0.35rem 0.75rem',
background: '#f0f0f0',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.8rem',
color: '#666',
},
queueList: {
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
},
queueItem: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem 1rem',
background: '#f8f9fa',
borderRadius: '8px',
borderLeft: '4px solid #0066cc',
},
queueItemComplete: {
borderLeftColor: '#28a745',
background: '#f0fff4',
},
queueItemError: {
borderLeftColor: '#dc3545',
background: '#fff5f5',
},
queueItemInfo: {
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
},
queueItemName: {
fontWeight: '500',
fontSize: '0.9rem',
},
queueItemStatus: {
fontSize: '0.8rem',
},
queueItemActions: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
},
queueViewBtn: {
padding: '0.35rem 0.75rem',
background: '#1a1a2e',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.8rem',
},
queueRemoveBtn: {
width: '24px',
height: '24px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
fontSize: '1.2rem',
color: '#999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
queueSpinner: {
width: '20px',
height: '20px',
border: '2px solid #ddd',
borderTopColor: '#0066cc',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
},
pwaSection: {
marginTop: '1.5rem',
padding: '1.25rem 1.5rem',
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
borderRadius: '12px',
},
pwaContent: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem',
color: 'white',
},
pwaHint: {
fontSize: '0.8rem',
color: 'rgba(255,255,255,0.6)',
marginTop: '0.25rem',
},
installBtn: {
padding: '0.6rem 1.25rem',
background: '#e94560',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '0.9rem',
fontWeight: 'bold',
cursor: 'pointer',
whiteSpace: 'nowrap',
},
openAppLink: {
padding: '0.6rem 1.25rem',
background: 'rgba(255,255,255,0.1)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '8px',
fontSize: '0.9rem',
textDecoration: 'none',
whiteSpace: 'nowrap',
},
}

View file

@ -0,0 +1,413 @@
import { useState } from 'react'
import { useAuth } from '../App'
// ============ Types ============
interface UsageVarianceItem {
ingredient_id: number
ingredient_name: string
category: string | null
standard_unit: string
theoretical_qty: number
theoretical_value: number
dishes_using: number
actual_qty: number | null
actual_value: number | null
invoice_count: number
variance_qty: number | null
variance_pct: number | null
variance_value: number | null
}
interface UnmappedSaleItem {
menu_item_name: string
portion_name: string
total_qty: number
category: string | null
}
interface UsageVarianceResponse {
from_date: string
to_date: string
items: UsageVarianceItem[]
total_theoretical_value: number
total_actual_value: number
total_variance_value: number
mapped_dish_count: number
unmapped_dish_count: number
ingredients_with_purchases: number
ingredients_without_purchases: number
unmapped_sales: UnmappedSaleItem[]
}
// ============ Helpers ============
function fmtQty(qty: number, unit: string): string {
if (unit === 'g' && Math.abs(qty) >= 1000) return `${(qty / 1000).toFixed(2)} kg`
if (unit === 'ml' && Math.abs(qty) >= 1000) return `${(qty / 1000).toFixed(2)} ltr`
if (unit === 'g') return `${qty.toFixed(0)} g`
if (unit === 'ml') return `${qty.toFixed(0)} ml`
if (unit === 'kg') return `${qty.toFixed(2)} kg`
if (unit === 'ltr') return `${qty.toFixed(2)} ltr`
if (unit === 'each') return `${qty.toFixed(1)}`
return `${qty.toFixed(2)} ${unit}`
}
function fmt(val: number | null | undefined): string {
if (val == null) return '—'
return `£${Number(val).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function fmtPct(val: number | null | undefined): string {
if (val == null) return '—'
const v = Number(val)
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
}
function varianceColor(pct: number | null, absVal: number | null): string {
if (pct == null && absVal == null) return '#666'
const absPct = Math.abs(pct ?? 0)
const absValue = Math.abs(absVal ?? 0)
if (absPct > 50 || absValue > 50) return '#dc2626' // red
if (absPct > 15 || absValue > 15) return '#d97706' // amber
return '#16a34a' // green
}
function varianceBg(pct: number | null, absVal: number | null): string {
if (pct == null && absVal == null) return 'transparent'
const absPct = Math.abs(pct ?? 0)
const absValue = Math.abs(absVal ?? 0)
if (absPct > 50 || absValue > 50) return '#fef2f2'
if (absPct > 15 || absValue > 15) return '#fffbeb'
return 'transparent'
}
// ============ Component ============
type SortKey = 'variance_value' | 'variance_pct' | 'name'
export default function UsageVarianceReport() {
const { token } = useAuth()
const today = new Date().toISOString().slice(0, 10)
const monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)
const [fromDate, setFromDate] = useState(monthAgo)
const [toDate, setToDate] = useState(today)
const [result, setResult] = useState<UsageVarianceResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [showAll, setShowAll] = useState(true)
const [sortKey, setSortKey] = useState<SortKey>('variance_value')
const [unmappedCollapsed, setUnmappedCollapsed] = useState(true)
const fetchReport = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(
`/api/reports/usage-variance?from_date=${fromDate}&to_date=${toDate}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Request failed' }))
throw new Error(err.detail || 'Request failed')
}
const data: UsageVarianceResponse = await res.json()
setResult(data)
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}
const formatDate = (iso: string) => {
const d = new Date(iso + 'T00:00:00')
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}
// Filter + sort items
let items = result?.items ?? []
if (search) {
const s = search.toLowerCase()
items = items.filter(i => i.ingredient_name.toLowerCase().includes(s) || (i.category || '').toLowerCase().includes(s))
}
if (!showAll) {
items = items.filter(i => Math.abs(i.variance_pct ?? 0) > 10 || Math.abs(i.variance_value ?? 0) > 5)
}
items = [...items].sort((a, b) => {
if (sortKey === 'name') return a.ingredient_name.localeCompare(b.ingredient_name)
if (sortKey === 'variance_pct') return Math.abs(b.variance_pct ?? 0) - Math.abs(a.variance_pct ?? 0)
return Math.abs(b.variance_value ?? 0) - Math.abs(a.variance_value ?? 0)
})
const r = result
return (
<div style={styles.container}>
<h2 style={styles.pageTitle}>Theoretical vs Actual Usage</h2>
{/* Date range selector */}
<div style={styles.dateBar}>
<label style={styles.dateLabel}>
From
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} style={styles.dateInput} />
</label>
<label style={styles.dateLabel}>
To
<input type="date" value={toDate} onChange={e => setToDate(e.target.value)} style={styles.dateInput} />
</label>
<button onClick={fetchReport} disabled={loading} style={styles.generateBtn}>
{loading ? 'Loading...' : 'Generate'}
</button>
</div>
{error && <div style={styles.error}>{error}</div>}
{r && (
<>
{/* Summary banner */}
<div style={styles.summaryBar}>
<div style={styles.summaryPeriod}>
{formatDate(r.from_date)} – {formatDate(r.to_date)}
</div>
<div style={styles.summaryGrid}>
<div style={styles.summaryItem}>
<div style={styles.summaryLabel}>Theoretical Cost</div>
<div style={styles.summaryValue}>{fmt(r.total_theoretical_value)}</div>
</div>
<div style={styles.summaryItem}>
<div style={styles.summaryLabel}>Actual Purchased</div>
<div style={styles.summaryValue}>{fmt(r.total_actual_value)}</div>
</div>
<div style={styles.summaryItem}>
<div style={styles.summaryLabel}>Variance</div>
<div style={{
...styles.summaryValue,
color: r.total_variance_value > 50 ? '#f87171' : r.total_variance_value < -50 ? '#fbbf24' : '#4ade80',
}}>
{fmt(r.total_variance_value)}
</div>
</div>
</div>
<div style={styles.summaryCounts}>
<span style={styles.countBadgeGreen}>{r.mapped_dish_count} dishes mapped</span>
{r.unmapped_dish_count > 0 && (
<span style={styles.countBadgeAmber}>{r.unmapped_dish_count} unmapped</span>
)}
<span style={styles.countBadgeBlue}>{r.ingredients_with_purchases} ingredients with purchases</span>
{r.ingredients_without_purchases > 0 && (
<span style={styles.countBadgeBlue}>{r.ingredients_without_purchases} theoretical only</span>
)}
</div>
<div style={styles.footnote}>
Variance = Actual - Theoretical. Positive = over-purchased. This report estimates usage from recipes and may not account for stock carried forward, staff meals, or wastage.
</div>
</div>
{/* Controls */}
<div style={styles.controlsBar}>
<input
type="text"
placeholder="Search ingredient..."
value={search}
onChange={e => setSearch(e.target.value)}
style={styles.searchInput}
/>
<label style={styles.toggleLabel}>
<input
type="checkbox"
checked={!showAll}
onChange={e => setShowAll(!e.target.checked)}
/>
{' '}Variances only
</label>
<select
value={sortKey}
onChange={e => setSortKey(e.target.value as SortKey)}
style={styles.sortSelect}
>
<option value="variance_value">Sort: Variance £</option>
<option value="variance_pct">Sort: Variance %</option>
<option value="name">Sort: Name</option>
</select>
</div>
{/* Main table */}
<div style={styles.tableWrap}>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Ingredient</th>
<th style={styles.th}>Category</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Theo. Qty</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Theo. £</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Actual Qty</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Actual £</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Variance £</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Variance %</th>
<th style={{ ...styles.th, textAlign: 'center' }}>Dishes</th>
<th style={{ ...styles.th, textAlign: 'center' }}>Invoices</th>
</tr>
</thead>
<tbody>
{items.map(item => {
const bg = varianceBg(item.variance_pct, item.variance_value)
const vc = varianceColor(item.variance_pct, item.variance_value)
return (
<tr key={item.ingredient_id} style={{ ...styles.tr, background: bg }}>
<td style={styles.td}>
<span style={{ fontWeight: 600 }}>{item.ingredient_name}</span>
</td>
<td style={{ ...styles.td, color: '#888', fontSize: '0.8rem' }}>
{item.category || '—'}
</td>
<td style={{ ...styles.td, textAlign: 'right' }}>
{item.theoretical_qty > 0 ? fmtQty(item.theoretical_qty, item.standard_unit) : '—'}
</td>
<td style={{ ...styles.td, textAlign: 'right' }}>
{item.theoretical_value > 0 ? fmt(item.theoretical_value) : '—'}
</td>
<td style={{ ...styles.td, textAlign: 'right' }}
title={item.actual_qty == null ? 'No mapped purchases in period' : undefined}
>
{item.actual_qty != null ? fmtQty(item.actual_qty, item.standard_unit) : '—'}
</td>
<td style={{ ...styles.td, textAlign: 'right' }}>
{item.actual_value != null ? fmt(item.actual_value) : '—'}
</td>
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: vc }}>
{item.variance_value != null ? fmt(item.variance_value) : '—'}
</td>
<td style={{ ...styles.td, textAlign: 'right', fontWeight: 600, color: vc }}>
{fmtPct(item.variance_pct)}
</td>
<td style={{ ...styles.td, textAlign: 'center', color: '#888', fontSize: '0.8rem' }}>
{item.dishes_using}
</td>
<td style={{ ...styles.td, textAlign: 'center', color: '#888', fontSize: '0.8rem' }}>
{item.invoice_count || '—'}
</td>
</tr>
)
})}
{items.length === 0 && (
<tr><td colSpan={10} style={{ ...styles.td, textAlign: 'center', color: '#999' }}>
{search || !showAll ? 'No items match filters.' : 'No data.'}
</td></tr>
)}
</tbody>
</table>
</div>
{/* Unmapped sales */}
{r.unmapped_sales.length > 0 && (
<div style={styles.section}>
<div
style={styles.sectionHeader}
onClick={() => setUnmappedCollapsed(!unmappedCollapsed)}
>
<span>
<span style={styles.collapseIcon}>{unmappedCollapsed ? '▸' : '▾'}</span>
Unmapped Sales Items ({r.unmapped_sales.length}) — no recipe, can't calculate theoretical usage
</span>
</div>
{!unmappedCollapsed && (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Menu Item</th>
<th style={styles.th}>Portion</th>
<th style={styles.th}>Category</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Qty Sold</th>
</tr>
</thead>
<tbody>
{r.unmapped_sales.map((u, idx) => (
<tr key={idx} style={styles.tr}>
<td style={styles.td}>{u.menu_item_name}</td>
<td style={{ ...styles.td, color: '#888' }}>
{u.portion_name !== 'Normal' ? u.portion_name : '—'}
</td>
<td style={{ ...styles.td, color: '#888' }}>{u.category || '—'}</td>
<td style={{ ...styles.td, textAlign: 'right' }}>{u.total_qty}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</>
)}
</div>
)
}
// ============ Styles ============
const styles: Record<string, React.CSSProperties> = {
container: { maxWidth: '1200px', margin: '0 auto', padding: '1.5rem' },
pageTitle: { fontSize: '1.4rem', fontWeight: 700, marginBottom: '1rem' },
// Date bar
dateBar: { display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1.5rem', flexWrap: 'wrap' as const },
dateLabel: { fontSize: '0.8rem', fontWeight: 600, color: '#666', display: 'flex', flexDirection: 'column' as const, gap: '0.25rem' },
dateInput: { padding: '0.4rem 0.5rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.85rem' },
generateBtn: {
padding: '0.45rem 1.2rem', background: '#e94560', color: 'white', border: 'none',
borderRadius: '6px', fontWeight: 600, cursor: 'pointer', fontSize: '0.85rem',
},
error: { padding: '1rem', color: '#dc3545', background: '#fde8e8', borderRadius: '6px', marginBottom: '1rem' },
// Summary bar
summaryBar: {
background: '#1a1a2e', color: 'white', padding: '1.25rem', borderRadius: '10px', marginBottom: '1.5rem',
},
summaryPeriod: { fontSize: '0.85rem', color: '#aaa', marginBottom: '0.75rem' },
summaryGrid: { display: 'flex', gap: '2rem', marginBottom: '0.75rem', flexWrap: 'wrap' as const },
summaryItem: {},
summaryLabel: { fontSize: '0.7rem', textTransform: 'uppercase' as const, color: '#888', fontWeight: 600 },
summaryValue: { fontSize: '1.3rem', fontWeight: 700 },
summaryCounts: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' as const, marginTop: '0.5rem' },
countBadgeGreen: { fontSize: '0.8rem', background: 'rgba(22,163,74,0.2)', color: '#4ade80', padding: '0.2rem 0.6rem', borderRadius: '4px' },
countBadgeAmber: { fontSize: '0.8rem', background: 'rgba(245,158,11,0.2)', color: '#fbbf24', padding: '0.2rem 0.6rem', borderRadius: '4px' },
countBadgeBlue: { fontSize: '0.8rem', background: 'rgba(59,130,246,0.2)', color: '#60a5fa', padding: '0.2rem 0.6rem', borderRadius: '4px' },
footnote: { fontSize: '0.72rem', color: '#777', marginTop: '0.75rem', fontStyle: 'italic' as const, lineHeight: 1.5 },
// Controls
controlsBar: {
display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' as const,
},
searchInput: {
padding: '0.4rem 0.6rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.85rem', width: '200px',
},
toggleLabel: { fontSize: '0.85rem', color: '#555', cursor: 'pointer', userSelect: 'none' as const },
sortSelect: {
padding: '0.35rem 0.5rem', border: '1px solid #ccc', borderRadius: '4px', fontSize: '0.8rem', color: '#555',
},
// Table
tableWrap: { overflowX: 'auto' as const },
table: { width: '100%', borderCollapse: 'collapse' as const },
th: {
padding: '0.5rem 0.75rem', textAlign: 'left' as const, borderBottom: '2px solid #e0e0e0',
fontSize: '0.72rem', fontWeight: 600, color: '#666', background: '#fafafa',
whiteSpace: 'nowrap' as const,
},
tr: { borderBottom: '1px solid #f0f0f0' },
td: { padding: '0.4rem 0.75rem', fontSize: '0.83rem', whiteSpace: 'nowrap' as const },
// Unmapped section
section: {
marginTop: '1.5rem', border: '1px solid #e0e0e0', borderRadius: '8px', overflow: 'hidden',
},
sectionHeader: {
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0.75rem 1rem', background: '#f8f9fa', cursor: 'pointer',
fontWeight: 600, fontSize: '0.9rem', color: '#888',
},
collapseIcon: { marginRight: '0.5rem', fontSize: '0.85rem' },
}

264
frontend/src/index.css Normal file
View file

@ -0,0 +1,264 @@
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--success: #16a34a;
--warning: #d97706;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--app-primary: #0d9488; /* kitchen teal — D5 */
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--body-bg);
color: var(--text-dark);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
}
/* ─── App shell ─────────────────────────────────────────────────────────── */
.app-shell {
display: grid;
grid-template-columns: 220px 1fr;
grid-template-rows: auto 1fr;
min-height: 100vh;
}
.sidebar {
grid-column: 1;
grid-row: 1 / -1;
background: var(--navy);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 16px 16px;
font-size: 15px;
font-weight: 600;
color: var(--text);
border-bottom: 1px solid var(--surface);
}
.sidebar-nav {
flex: 1;
padding: 8px 8px;
display: flex;
flex-direction: column;
}
.sidebar-section {
margin-top: 4px;
}
.sidebar-section-label {
padding: 8px 10px 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: 7px;
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
transition: background 0.15s, color 0.15s;
}
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
.sidebar-nav a.active { background: rgba(201,168,76,0.15); color: var(--gold); }
.sidebar-user {
padding: 12px 16px;
font-size: 11px;
color: var(--text-muted);
border-top: 1px solid var(--surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ─── Top bar (mobile fallback) ─────────────────────────────────────────── */
.top-bar { display: none; }
.page-content {
grid-column: 2;
grid-row: 1 / -1;
min-width: 0;
padding: 24px;
background: var(--body-bg);
}
/* ─── Cards ──────────────────────────────────────────────────────────────── */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--card-border);
font-size: 14px;
font-weight: 600;
color: var(--text-dark);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-body { padding: 20px; }
/* ─── Buttons ────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s, opacity 0.15s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--gold); color: var(--navy); }
.btn-primary:hover:not(:disabled) { background: var(--gold-light); }
.btn-secondary { background: var(--navy); color: var(--text); }
.btn-secondary:hover:not(:disabled) { background: var(--navy-dark); }
.btn-outline { background: transparent; color: var(--text-dark); border: 1px solid var(--card-border); }
.btn-outline:hover:not(:disabled) { background: var(--body-bg); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover:not(:disabled) { opacity: 0.85; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* ─── Forms ──────────────────────────────────────────────────────────────── */
input, select, textarea {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 7px;
padding: 7px 10px;
font-size: 13px;
color: var(--text-dark);
width: 100%;
outline: none;
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus { border-color: var(--gold); }
/* ─── Badges ─────────────────────────────────────────────────────────────── */
.badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 500; }
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
.badge-danger { background: #fee2e2; color: #dc2626; }
.badge-info { background: #dbeafe; color: #2563eb; }
.badge-neutral { background: #f1f5f9; color: #64748b; }
/* ─── Tables ─────────────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 8px 12px; background: #f8fafc; border-bottom: 1px solid var(--card-border); font-weight: 600; color: var(--text-mid); white-space: nowrap; }
td { padding: 8px 12px; border-bottom: 1px solid #f1f5f9; color: var(--text-dark); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8fafc; }
/* ─── Page header ────────────────────────────────────────────────────────── */
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; gap: 16px; }
.page-title { font-size: 20px; font-weight: 700; color: var(--text-dark); }
.page-subtitle { font-size: 13px; color: var(--text-mid); margin-top: 2px; }
/* ─── Sub-nav tabs ───────────────────────────────────────────────────────── */
.sub-nav { display: flex; gap: 4px; border-bottom: 1px solid var(--card-border); margin-bottom: 24px; overflow-x: auto; }
.sub-nav-item { padding: 8px 14px; font-size: 13px; font-weight: 500; color: var(--text-mid); cursor: pointer; border-bottom: 2px solid transparent; white-space: nowrap; background: none; border-left: none; border-right: none; border-top: none; transition: color 0.15s, border-color 0.15s; }
.sub-nav-item:hover { color: var(--text-dark); }
.sub-nav-item.active { color: var(--gold); border-bottom-color: var(--gold); }
/* ─── Status + spinner ───────────────────────────────────────────────────── */
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.status-dot.green { background: var(--success); }
.status-dot.yellow { background: var(--warning); }
.status-dot.red { background: var(--danger); }
.status-dot.grey { background: #94a3b8; }
.spinner { width: 20px; height: 20px; border: 2px solid var(--card-border); border-top-color: var(--gold); border-radius: 50%; animation: spin 0.7s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-state { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 60px 20px; color: var(--text-mid); font-size: 13px; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-mid); font-size: 13px; }
/* ─── Grid helpers ───────────────────────────────────────────────────────── */
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
/* ─── Stat card ──────────────────────────────────────────────────────────── */
.stat-card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); padding: 16px 20px; box-shadow: var(--shadow-sm); }
.stat-label { font-size: 12px; color: var(--text-mid); margin-bottom: 4px; }
.stat-value { font-size: 24px; font-weight: 700; color: var(--text-dark); }
.stat-delta { font-size: 12px; margin-top: 4px; }
.stat-delta.positive { color: var(--success); }
.stat-delta.negative { color: var(--danger); }
.stat-delta.neutral { color: var(--text-mid); }
/* ─── Responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.app-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
.sidebar { display: none; }
.top-bar {
display: flex;
align-items: center;
gap: 12px;
background: var(--navy);
padding: 0 16px;
height: 52px;
grid-column: 1;
grid-row: 1;
overflow-x: auto;
}
.top-bar-title { font-size: 14px; font-weight: 600; color: var(--text); white-space: nowrap; margin-right: 8px; }
.top-bar-nav { display: flex; gap: 4px; }
.top-bar-nav a { color: var(--text-muted); text-decoration: none; font-size: 12.5px; padding: 6px 10px; border-radius: 6px; white-space: nowrap; }
.top-bar-nav a:hover { color: var(--text); background: var(--surface); }
.top-bar-nav a.active { color: var(--gold); }
.page-content { grid-column: 1; grid-row: 2; padding: 16px; }
}
/* ─── Sidebar scrollbar ──────────────────────────────────────────────────── */
.sidebar::-webkit-scrollbar,
.sidebar-nav::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-track,
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.sidebar::-webkit-scrollbar-thumb,
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
.sidebar::-webkit-scrollbar-thumb:hover,
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
.sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }

20
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,20 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const qc = new QueryClient({
defaultOptions: { queries: { staleTime: 5 * 60 * 1000, retry: 1 } },
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter basename="/kitchen">
<QueryClientProvider client={qc}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,620 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../App'
interface Settings {
currency_symbol: string
}
interface RevenueByAccount {
gl_name: string
amount: number
}
interface CalendarDay {
date: string
has_data: boolean
is_forecast: boolean
total_rooms: number | null
occupied_rooms: number | null
occupancy_percentage: number | null
total_guests: number | null
breakfast_allocation_qty: number | null
breakfast_allocation_netvalue: number | null
dinner_allocation_qty: number | null
dinner_allocation_netvalue: number | null
total_revenue: number | null
revenue_by_account: RevenueByAccount[] | null
}
interface CalendarData {
year: number
month: number
days: CalendarDay[]
}
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
]
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
export default function NewbookData() {
const { token } = useAuth()
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth() + 1)
const [selectedDay, setSelectedDay] = useState<CalendarDay | null>(null)
// Fetch display settings for currency
const { data: settings } = useQuery<Settings>({
queryKey: ['settings'],
queryFn: async () => {
const res = await fetch('/api/settings/', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch settings')
return res.json()
},
enabled: !!token,
})
const currencySymbol = settings?.currency_symbol || '£'
const { data: calendarData, isLoading } = useQuery<CalendarData>({
queryKey: ['newbook-calendar', year, month],
queryFn: async () => {
const res = await fetch(`/api/newbook/calendar/${year}/${month}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch calendar data')
return res.json()
},
enabled: !!token,
})
const goToPrevMonth = () => {
if (month === 1) {
setMonth(12)
setYear(year - 1)
} else {
setMonth(month - 1)
}
}
const goToNextMonth = () => {
if (month === 12) {
setMonth(1)
setYear(year + 1)
} else {
setMonth(month + 1)
}
}
const goToToday = () => {
setYear(today.getFullYear())
setMonth(today.getMonth() + 1)
}
// Calculate calendar grid
const firstDayOfMonth = new Date(year, month - 1, 1).getDay()
const daysInMonth = new Date(year, month, 0).getDate()
// Create array for calendar grid
const calendarGrid: (CalendarDay | null)[] = []
// Add empty cells for days before the first day of the month
for (let i = 0; i < firstDayOfMonth; i++) {
calendarGrid.push(null)
}
// Add days from the data
if (calendarData) {
for (const day of calendarData.days) {
calendarGrid.push(day)
}
} else {
// Placeholder days when loading
for (let i = 1; i <= daysInMonth; i++) {
calendarGrid.push(null)
}
}
const formatCurrency = (amount: number | null) => {
if (amount === null) return '-'
// Use currency symbol from settings
const formatted = new Intl.NumberFormat('en-GB', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
return `${currencySymbol}${formatted}`
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('en-AU', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
return (
<div style={styles.container}>
<div style={styles.titleRow}>
<a href="/settings" style={styles.backLink}>&larr; Back to Settings</a>
<h1 style={styles.title}>Newbook Data</h1>
</div>
{/* Calendar Header */}
<div style={styles.calendarHeader}>
<button onClick={goToPrevMonth} style={styles.navBtn}>&lt; Prev</button>
<div style={styles.monthTitle}>
<h2 style={styles.monthName}>{MONTH_NAMES[month - 1]} {year}</h2>
<button onClick={goToToday} style={styles.todayBtn}>Today</button>
</div>
<button onClick={goToNextMonth} style={styles.navBtn}>Next &gt;</button>
</div>
{/* Legend */}
<div style={styles.legend}>
<div style={styles.legendItem}>
<span style={{ ...styles.legendDot, background: '#4ade80' }}></span>
<span>Historical (locked)</span>
</div>
<div style={styles.legendItem}>
<span style={{ ...styles.legendDot, background: '#fbbf24' }}></span>
<span>Forecast (updates on sync)</span>
</div>
<div style={styles.legendItem}>
<span style={{ ...styles.legendDot, background: '#e5e7eb' }}></span>
<span>No data</span>
</div>
</div>
{isLoading ? (
<div style={styles.loading}>Loading calendar data...</div>
) : (
<>
{/* Calendar Grid */}
<div style={styles.calendar}>
{/* Day headers */}
{DAY_NAMES.map((day) => (
<div key={day} style={styles.dayHeader}>{day}</div>
))}
{/* Calendar cells */}
{calendarGrid.map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} style={styles.emptyCell}></div>
}
const dayNum = new Date(day.date).getDate()
const isToday = day.date === today.toISOString().split('T')[0]
let cellStyle: React.CSSProperties = { ...styles.dayCell }
if (day.has_data) {
if (day.is_forecast) {
cellStyle = { ...cellStyle, ...styles.forecastCell }
} else {
cellStyle = { ...cellStyle, ...styles.historicalCell }
}
}
if (isToday) {
cellStyle = { ...cellStyle, ...styles.todayCell }
}
return (
<div
key={day.date}
style={cellStyle}
onClick={() => day.has_data && setSelectedDay(day)}
>
<div style={styles.dayNumber}>{dayNum}</div>
{day.has_data && (
<div style={styles.dayData}>
{day.total_revenue !== null && (
<div style={styles.revenueTag}>
{currencySymbol}{Math.round(day.total_revenue)} net
</div>
)}
{day.occupied_rooms !== null && (
<div style={styles.occupancyTag}>
{day.occupied_rooms}/{day.total_rooms} ({day.total_guests ?? '-'}) {Math.round(day.occupancy_percentage ?? 0)}%
</div>
)}
</div>
)}
</div>
)
})}
</div>
</>
)}
{/* Day Detail Modal */}
{selectedDay && (
<div style={styles.modalOverlay} onClick={() => setSelectedDay(null)}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<h3 style={styles.modalTitle}>{formatDate(selectedDay.date)}</h3>
<span style={{
...styles.badge,
background: selectedDay.is_forecast ? '#fbbf24' : '#4ade80',
color: selectedDay.is_forecast ? '#78350f' : '#14532d',
}}>
{selectedDay.is_forecast ? 'Forecast' : 'Historical'}
</span>
</div>
<div style={styles.modalContent}>
{/* Occupancy Section */}
<div style={styles.section}>
<h4 style={styles.sectionTitle}>Occupancy</h4>
<div style={styles.dataGrid}>
<div style={styles.dataItem}>
<span style={styles.dataLabel}>Occupancy Rate</span>
<span style={styles.dataValue}>
{selectedDay.occupancy_percentage !== null
? `${selectedDay.occupancy_percentage}%`
: '-'}
</span>
</div>
<div style={styles.dataItem}>
<span style={styles.dataLabel}>Rooms</span>
<span style={styles.dataValue}>
{selectedDay.occupied_rooms !== null && selectedDay.total_rooms !== null
? `${selectedDay.occupied_rooms} / ${selectedDay.total_rooms}`
: '-'}
</span>
</div>
<div style={styles.dataItem}>
<span style={styles.dataLabel}>Total Guests</span>
<span style={styles.dataValue}>
{selectedDay.total_guests ?? '-'}
</span>
</div>
</div>
</div>
{/* Meal Allocations Section */}
<div style={styles.section}>
<h4 style={styles.sectionTitle}>Meal Allocations</h4>
<div style={styles.dataGrid}>
<div style={styles.dataItem}>
<span style={styles.dataLabel}>Breakfast</span>
<span style={styles.dataValue}>
{selectedDay.breakfast_allocation_qty !== null
? `${selectedDay.breakfast_allocation_qty} pax`
: '-'}
</span>
{/* Only show estimated value for forecast data */}
{selectedDay.is_forecast && selectedDay.breakfast_allocation_netvalue !== null && (
<span style={styles.dataSubValue}>
{formatCurrency(selectedDay.breakfast_allocation_netvalue)} net (est)
</span>
)}
</div>
<div style={styles.dataItem}>
<span style={styles.dataLabel}>Dinner</span>
<span style={styles.dataValue}>
{selectedDay.dinner_allocation_qty !== null
? `${selectedDay.dinner_allocation_qty} pax`
: '-'}
</span>
{/* Only show estimated value for forecast data */}
{selectedDay.is_forecast && selectedDay.dinner_allocation_netvalue !== null && (
<span style={styles.dataSubValue}>
{formatCurrency(selectedDay.dinner_allocation_netvalue)} net (est)
</span>
)}
</div>
</div>
</div>
{/* Revenue Section */}
<div style={styles.section}>
<h4 style={styles.sectionTitle}>Revenue (net)</h4>
<div style={styles.revenueTotal}>
<span>Total</span>
<span style={styles.revenueTotalValue}>
{formatCurrency(selectedDay.total_revenue)}
</span>
</div>
{selectedDay.revenue_by_account && selectedDay.revenue_by_account.length > 0 && (
<div style={styles.revenueBreakdown}>
{selectedDay.revenue_by_account.map((item, idx) => (
<div key={idx} style={styles.revenueItem}>
<span style={styles.revenueGLName}>{item.gl_name}</span>
<span style={styles.revenueAmount}>
{formatCurrency(item.amount)}
</span>
</div>
))}
</div>
)}
</div>
</div>
<button onClick={() => setSelectedDay(null)} style={styles.closeBtn}>
Close
</button>
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
container: {
padding: '1rem',
},
titleRow: {
marginBottom: '1rem',
},
backLink: {
display: 'inline-block',
color: '#6b7280',
textDecoration: 'none',
fontSize: '0.875rem',
marginBottom: '0.5rem',
},
title: {
fontSize: '1.75rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: '#1a1a2e',
margin: 0,
},
calendarHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1rem',
},
monthTitle: {
display: 'flex',
alignItems: 'center',
gap: '1rem',
},
monthName: {
fontSize: '1.5rem',
fontWeight: '600',
color: '#1a1a2e',
margin: 0,
},
navBtn: {
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: '500',
},
todayBtn: {
padding: '0.25rem 0.75rem',
background: '#e94560',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.875rem',
},
legend: {
display: 'flex',
gap: '1.5rem',
marginBottom: '1rem',
padding: '0.75rem 1rem',
background: '#f9fafb',
borderRadius: '8px',
},
legendItem: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.875rem',
color: '#4b5563',
},
legendDot: {
width: '16px',
height: '16px',
borderRadius: '4px',
},
loading: {
textAlign: 'center',
padding: '3rem',
color: '#6b7280',
},
calendar: {
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '4px',
background: '#e5e7eb',
borderRadius: '12px',
padding: '4px',
},
dayHeader: {
padding: '0.75rem',
textAlign: 'center',
fontWeight: '600',
color: '#374151',
background: '#f9fafb',
},
emptyCell: {
background: '#f9fafb',
minHeight: '100px',
},
dayCell: {
background: 'white',
minHeight: '100px',
padding: '0.5rem',
cursor: 'default',
position: 'relative',
borderRadius: '4px',
},
historicalCell: {
background: '#dcfce7',
cursor: 'pointer',
},
forecastCell: {
background: '#fef3c7',
cursor: 'pointer',
},
todayCell: {
boxShadow: 'inset 0 0 0 2px #e94560',
},
dayNumber: {
fontWeight: '600',
fontSize: '0.875rem',
color: '#374151',
},
dayData: {
marginTop: '0.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
},
revenueTag: {
fontSize: '0.75rem',
color: '#047857',
fontWeight: '500',
},
occupancyTag: {
fontSize: '0.75rem',
color: '#6366f1',
fontWeight: '500',
},
// Modal styles
modalOverlay: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
},
modal: {
background: 'white',
borderRadius: '12px',
padding: '1.5rem',
width: '90%',
maxWidth: '500px',
maxHeight: '80vh',
overflow: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
},
modalHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
paddingBottom: '1rem',
borderBottom: '1px solid #e5e7eb',
},
modalTitle: {
margin: 0,
fontSize: '1.25rem',
fontWeight: '600',
color: '#1a1a2e',
},
badge: {
padding: '0.25rem 0.75rem',
borderRadius: '9999px',
fontSize: '0.75rem',
fontWeight: '600',
},
modalContent: {
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
},
section: {
background: '#f9fafb',
borderRadius: '8px',
padding: '1rem',
},
sectionTitle: {
margin: '0 0 0.75rem 0',
fontSize: '0.875rem',
fontWeight: '600',
color: '#6b7280',
textTransform: 'uppercase',
letterSpacing: '0.05em',
},
dataGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
gap: '1rem',
},
dataItem: {
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
},
dataLabel: {
fontSize: '0.75rem',
color: '#6b7280',
},
dataValue: {
fontSize: '1.125rem',
fontWeight: '600',
color: '#1a1a2e',
},
dataSubValue: {
fontSize: '0.875rem',
color: '#6b7280',
},
revenueTotal: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem',
background: 'white',
borderRadius: '6px',
marginBottom: '0.75rem',
},
revenueTotalValue: {
fontSize: '1.25rem',
fontWeight: '700',
color: '#047857',
},
revenueBreakdown: {
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
},
revenueItem: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.5rem 0.75rem',
background: 'white',
borderRadius: '4px',
fontSize: '0.875rem',
},
revenueGLName: {
color: '#4b5563',
},
revenueAmount: {
fontWeight: '500',
color: '#1a1a2e',
},
closeBtn: {
width: '100%',
padding: '0.75rem',
marginTop: '1.5rem',
background: '#1a1a2e',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontWeight: '500',
},
}

View file

@ -0,0 +1,514 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../App'
interface RestaurantBooking {
has_booking: boolean
time?: string
people?: number
table_name?: string
opening_hour_name?: string
is_group_match?: boolean // True if matched via group/exclude field (faded icon)
}
interface BookingSegment {
booking_id: string | null
bookings_group_id?: string | null
check_in: string
check_out: string
nights: string[]
restaurant_bookings: Record<string, RestaurantBooking>
is_dbb?: boolean
is_package?: boolean
}
interface RoomRow {
room_number: string | null
bookings: BookingSegment[]
}
interface ChartData {
date_range: {
start_date: string
end_date: string
dates: string[]
}
rooms: RoomRow[]
summary: {
total_rooms: number
total_bookings: number
total_room_nights: number
nights_with_restaurant: number
coverage_percentage: number
}
}
// Single color scheme for all bookings - like Newbook UI
const BOOKING_COLORS = {
// Normal bookings (no group)
normal: {
bg: '#e8e5f5', // Soft purple background
border: '#667eea' // Purple border
},
// Group member bookings (part of a booking group)
groupMember: {
bg: '#e8e5f5', // Same background
border: '#0ea5e9' // Sky blue border to indicate group membership
}
}
export default function ResidentsTableChart() {
const { token } = useAuth()
const [startDate, setStartDate] = useState(() => new Date().toISOString().split('T')[0])
const [selectedBooking, setSelectedBooking] = useState<{ room: RoomRow, booking: BookingSegment } | null>(null)
const [hoveredGroupId, setHoveredGroupId] = useState<string | null>(null)
const { data, isLoading } = useQuery<ChartData>({
queryKey: ['residents-table-chart', 'v2', startDate], // v2 to invalidate old cache
queryFn: async () => {
const res = await fetch(`/api/residents-table-chart?start_date=${startDate}`, {
headers: { Authorization: `Bearer ${token}` }
})
if (!res.ok) throw new Error('Failed to fetch chart data')
return res.json()
},
staleTime: 2 * 60 * 1000
})
const prevWeek = () => {
const date = new Date(startDate)
date.setDate(date.getDate() - 7)
setStartDate(date.toISOString().split('T')[0])
}
const nextWeek = () => {
const date = new Date(startDate)
date.setDate(date.getDate() + 7)
setStartDate(date.toISOString().split('T')[0])
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
return `${days[date.getDay()]} ${date.getDate()}`
}
if (isLoading) {
return (
<div style={styles.container}>
<h1>Residents Table Chart</h1>
<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>
</div>
)
}
if (!data || !data.rooms) {
return (
<div style={styles.container}>
<h1>Residents Table Chart</h1>
<div style={{ padding: '2rem', textAlign: 'center' }}>
{!data ? 'No data available' : 'Loading new data structure...'}
</div>
</div>
)
}
return (
<div style={styles.container}>
<h1>Residents Table Chart</h1>
{/* Date Selector */}
<div style={styles.header}>
<button onClick={prevWeek} style={styles.navButton}>◀ Prev Week</button>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
style={styles.dateInput}
/>
<button onClick={nextWeek} style={styles.navButton}>Next Week ▶</button>
</div>
{/* Summary */}
<div style={styles.summary}>
<strong>{data.summary.total_rooms}</strong> Rooms
<span style={{ margin: '0 1rem', color: '#ddd' }}>|</span>
<strong>{data.summary.total_bookings}</strong> Bookings
<span style={{ margin: '0 1rem', color: '#ddd' }}>|</span>
<strong>{data.summary.total_room_nights}</strong> Room Nights
<span style={{ margin: '0 1rem', color: '#ddd' }}>|</span>
<strong>{data.summary.nights_with_restaurant}</strong> with Restaurant Table
<span style={{ margin: '0 1rem', color: '#ddd' }}>|</span>
<strong>{data.summary.coverage_percentage}%</strong> Coverage
</div>
{/* Chart */}
<div style={styles.chartContainer}>
<div style={styles.grid}>
{/* Header Row */}
<div style={{ ...styles.headerCell, borderLeft: 'none' }}>Room</div>
{data.date_range.dates.map((date) => (
<div key={date} style={styles.headerCell}>
{formatDate(date)}
</div>
))}
{/* Room Rows */}
{data.rooms.map((room, roomIdx) => (
<div key={room.room_number || `room-${roomIdx}`} style={styles.row}>
{/* Room Cell */}
<div style={styles.roomCell}>
<div style={styles.roomNumber}>{room.room_number || 'Unknown Room'}</div>
</div>
{/* Day Cells - check all bookings for this room */}
{data.date_range.dates.map((date) => {
// Find booking that covers this date (should only be one per room per night)
const bookingsOnDate = room.bookings.filter(b => b.nights.includes(date))
const booking = bookingsOnDate[0] // Only one booking can occupy a room per night
if (!booking) return <div key={date} style={styles.dayCell} />
const isFirstNight = booking.check_in === date
const isLastNight = booking.nights[booking.nights.length - 1] === date
const restaurantBooking = booking.restaurant_bookings[date]
// Check if this booking is part of a group
const hasValidGroupId = booking.bookings_group_id && booking.bookings_group_id.trim() !== ''
const isGroupHovered = hasValidGroupId && hoveredGroupId === booking.bookings_group_id
// Use group member color if part of a group, otherwise normal color
const colors = hasValidGroupId ? BOOKING_COLORS.groupMember : BOOKING_COLORS.normal
return (
<div key={date} style={styles.dayCell}>
{/* Hotel Stay Bar */}
<div
style={{
position: 'absolute',
top: '12px',
height: '36px',
background: colors.bg,
cursor: 'pointer',
transition: 'all 0.2s ease',
zIndex: isGroupHovered ? 15 : 10,
left: isFirstNight ? '0.5rem' : 'calc(-0.5rem + 1px)',
right: isLastNight ? '0.5rem' : 'calc(-0.5rem + 1px)',
borderRadius: isFirstNight && isLastNight ? '8px' :
isFirstNight ? '8px 0 0 8px' :
isLastNight ? '0 8px 8px 0' : '0',
// Keep borders consistent - hide on middle segments only when hovered
borderTop: `2px solid ${colors.border}`,
borderBottom: `2px solid ${colors.border}`,
borderLeft: isFirstNight ? `2px solid ${colors.border}` : 'none',
borderRight: isLastNight ? `2px solid ${colors.border}` : 'none',
// Use inset box-shadows to create directional highlights ONLY on sides that don't adjoin other segments
boxShadow: isGroupHovered
? (
// Inset shadows only on non-adjoining sides
isFirstNight && isLastNight
? `inset 0 3px 0 ${colors.border}, inset 0 -3px 0 ${colors.border}, inset 3px 0 0 ${colors.border}, inset -3px 0 0 ${colors.border}` // Single night - all 4 sides
: isFirstNight
? `inset 0 3px 0 ${colors.border}, inset 0 -3px 0 ${colors.border}, inset 3px 0 0 ${colors.border}` // First night - top, bottom, LEFT only (right adjoins next night)
: isLastNight
? `inset 0 3px 0 ${colors.border}, inset 0 -3px 0 ${colors.border}, inset -3px 0 0 ${colors.border}` // Last night - top, bottom, RIGHT only (left adjoins previous night)
: `inset 0 3px 0 ${colors.border}, inset 0 -3px 0 ${colors.border}` // Middle nights - top and bottom ONLY (both sides adjoin other nights)
)
: (isFirstNight || isLastNight ? '0 1px 3px rgba(0,0,0,0.05)' : 'none')
}}
onMouseEnter={() => hasValidGroupId && setHoveredGroupId(booking.bookings_group_id!)}
onMouseLeave={() => setHoveredGroupId(null)}
onClick={() => setSelectedBooking({ room, booking })}
title={`Room ${room.room_number} - Booking ${booking.booking_id}\n${booking.check_in} to ${booking.check_out}${hasValidGroupId ? `\nGroup: ${booking.bookings_group_id}` : ''}`}
/>
{/* Restaurant Table Icon - positioned relative to cell, not bar */}
{restaurantBooking?.has_booking && (
<div
style={{
position: 'absolute',
top: '12px',
left: '50%',
transform: 'translateX(-50%)',
height: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.3rem',
cursor: 'pointer',
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))',
zIndex: 20,
pointerEvents: 'none',
opacity: restaurantBooking.is_group_match ? 0.5 : 1 // Faded for group matches
}}
title={`${restaurantBooking.opening_hour_name || 'Dining'} - ${restaurantBooking.time || ''}\n${restaurantBooking.people} people${restaurantBooking.table_name ? `\n${restaurantBooking.table_name}` : ''}${restaurantBooking.is_group_match ? '\n(Group booking - via another room)' : ''}`}
>
🍽️
</div>
)}
</div>
)
})}
</div>
))}
</div>
</div>
{/* Booking Detail Modal */}
{selectedBooking && (
<div style={styles.modalOverlay} onClick={() => setSelectedBooking(null)}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<h2>Room {selectedBooking.room.room_number || 'Unknown'}</h2>
<button onClick={() => setSelectedBooking(null)} style={styles.closeBtn}>×</button>
</div>
<div style={styles.modalContent}>
<div style={styles.modalRow}>
<strong>Booking ID:</strong> {selectedBooking.booking.booking_id || 'Not specified'}
</div>
<div style={styles.modalRow}>
<strong>Stay:</strong> {selectedBooking.booking.check_in} to {selectedBooking.booking.check_out} ({selectedBooking.booking.nights.length} nights)
</div>
{selectedBooking.booking.is_dbb && (
<div style={styles.modalRow}>
<span style={styles.badge}>Dinner, Bed & Breakfast</span>
</div>
)}
{selectedBooking.booking.is_package && (
<div style={styles.modalRow}>
<span style={styles.badge}>Package Deal</span>
</div>
)}
<h3 style={{ marginTop: '1.5rem', marginBottom: '0.75rem' }}>Restaurant Bookings</h3>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Date</th>
<th style={styles.th}>Time</th>
<th style={styles.th}>Service</th>
<th style={styles.th}>Covers</th>
<th style={styles.th}>Table</th>
</tr>
</thead>
<tbody>
{selectedBooking.booking.nights.map((night) => {
const rb = selectedBooking.booking.restaurant_bookings[night]
return (
<tr key={night} style={rb?.has_booking ? {} : styles.noBookingRow}>
<td style={styles.td}>{night}</td>
<td style={styles.td}>{rb?.has_booking ? rb.time : '-'}</td>
<td style={styles.td}>{rb?.has_booking ? rb.opening_hour_name : '-'}</td>
<td style={styles.td}>{rb?.has_booking ? rb.people : '-'}</td>
<td style={styles.td}>{rb?.has_booking ? rb.table_name : '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
container: {
padding: '2rem',
background: '#f5f5f5',
minHeight: '100vh'
},
header: {
background: 'white',
padding: '1.5rem',
borderRadius: '12px',
marginBottom: '1rem',
display: 'flex',
gap: '1rem',
alignItems: 'center',
justifyContent: 'center'
},
navButton: {
padding: '0.75rem 1.5rem',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '1rem',
fontWeight: '500'
},
dateInput: {
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '8px',
fontSize: '1rem'
},
summary: {
background: 'white',
padding: '1rem 1.5rem',
borderRadius: '8px',
marginBottom: '1rem',
fontSize: '0.95rem',
color: '#666',
textAlign: 'center' as const
},
chartContainer: {
background: 'white',
borderRadius: '12px',
overflow: 'auto',
scrollbarGutter: 'stable',
padding: '1rem',
paddingRight: '0.5rem'
},
grid: {
display: 'grid',
gridTemplateColumns: '150px repeat(7, 1fr)',
gap: '0',
minWidth: '1000px'
},
headerCell: {
padding: '0.75rem',
borderBottom: '2px solid #333',
borderLeft: '1px solid #eee',
fontWeight: 'bold' as const,
textAlign: 'center' as const,
background: '#f8f8f8',
fontSize: '0.9rem'
},
row: {
display: 'contents'
},
roomCell: {
padding: '0.75rem',
borderRight: '1px solid #ddd',
borderBottom: '1px solid #eee',
fontWeight: '500' as const,
background: '#fafafa',
display: 'flex',
flexDirection: 'column' as const,
gap: '0.25rem'
},
roomNumber: {
fontSize: '1rem',
fontWeight: 'bold' as const,
color: '#333'
},
guestName: {
fontSize: '0.85rem',
color: '#666'
},
badge: {
display: 'inline-block',
padding: '0.15rem 0.5rem',
background: '#e0e7ff',
color: '#4338ca',
borderRadius: '4px',
fontSize: '0.7rem',
fontWeight: '600' as const,
marginTop: '0.25rem'
},
dayCell: {
position: 'relative' as const,
padding: '0.5rem',
borderLeft: '1px solid #eee',
borderBottom: '1px solid #eee',
minHeight: '70px',
background: 'white'
},
bar: {
position: 'absolute' as const,
top: '12px',
left: '0',
right: '0',
height: '36px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'transform 0.2s, box-shadow 0.2s'
},
barStart: {
borderTopLeftRadius: '8px',
borderBottomLeftRadius: '8px'
},
barEnd: {
borderTopRightRadius: '8px',
borderBottomRightRadius: '8px'
},
tableIcon: {
position: 'absolute' as const,
bottom: '8px',
left: '50%',
transform: 'translateX(-50%)',
fontSize: '1.5rem',
cursor: 'pointer',
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))',
zIndex: 20
},
modalOverlay: {
position: 'fixed' as const,
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: '12px',
width: '90%',
maxWidth: '700px',
maxHeight: '90vh',
overflow: 'auto',
padding: '24px'
},
modalHeader: {
display: 'flex',
justifyContent: 'space-between',
marginBottom: '1rem',
paddingBottom: '1rem',
borderBottom: '1px solid #eee'
},
closeBtn: {
background: 'none',
border: 'none',
fontSize: '2rem',
cursor: 'pointer',
color: '#999'
},
modalContent: {
fontSize: '0.95rem'
},
modalRow: {
marginBottom: '0.75rem',
lineHeight: '1.6'
},
table: {
width: '100%',
borderCollapse: 'collapse' as const,
marginTop: '0.5rem'
},
th: {
padding: '0.75rem',
textAlign: 'left' as const,
borderBottom: '2px solid #ddd',
fontWeight: '600' as const,
fontSize: '0.9rem'
},
td: {
padding: '0.75rem',
borderBottom: '1px solid #eee'
},
noBookingRow: {
background: '#f9f9f9',
color: '#999'
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,655 @@
import { useState, useRef, useEffect } from 'react'
import { useAuth } from '../App'
import { PDFDocument } from 'pdf-lib'
import imageCompression from 'browser-image-compression'
interface QueueItem {
id: string
status: 'compressing' | 'uploading' | 'processing' | 'complete' | 'error'
preview?: string
error?: string
}
export default function UploadApp() {
const { token, user, login, logout } = useAuth()
const cameraRef = useRef<HTMLInputElement>(null)
const fileRef = useRef<HTMLInputElement>(null)
// Mini login state
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loginError, setLoginError] = useState('')
const [loginLoading, setLoginLoading] = useState(false)
// Upload state
const [queue, setQueue] = useState<QueueItem[]>([])
const [captures, setCaptures] = useState<{ id: string; file: File; preview: string }[]>([])
// Override viewport for mobile-friendly layout on this page
useEffect(() => {
const viewport = document.querySelector('meta[name="viewport"]')
const original = viewport?.getAttribute('content') || ''
if (viewport) {
viewport.setAttribute('content', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no')
}
return () => {
if (viewport && original) viewport.setAttribute('content', original)
}
}, [])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoginError('')
setLoginLoading(true)
try {
const res = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'Login failed')
}
const data = await res.json()
login(data.access_token)
} catch (err) {
setLoginError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoginLoading(false)
}
}
if (!token || !user) {
return (
<div style={styles.container}>
<div style={styles.loginCard}>
<h1 style={styles.brand}>Invoice Upload</h1>
<p style={styles.subtitle}>Sign in to upload invoices</p>
{loginError && <div style={styles.error}>{loginError}</div>}
<form onSubmit={handleLogin} style={styles.form}>
<input
type="email"
placeholder="Email"
value={email}
onChange={e => setEmail(e.target.value)}
style={styles.input}
required
autoFocus
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
style={styles.input}
required
/>
<button type="submit" style={styles.loginBtn} disabled={loginLoading}>
{loginLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
)
}
const generateId = () => Math.random().toString(36).substr(2, 9)
const handleCapture = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return
const files = Array.from(e.target.files)
for (const file of files) {
if (file.type === 'application/pdf') {
// PDF: upload directly
uploadFile(file)
continue
}
if (!file.type.startsWith('image/')) continue
const preview = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = (ev) => resolve(ev.target?.result as string)
reader.readAsDataURL(file)
})
setCaptures(prev => [...prev, { id: generateId(), file, preview }])
}
e.target.value = ''
}
const removeCapture = (id: string) => {
setCaptures(prev => prev.filter(c => c.id !== id))
}
const uploadFile = async (file: File) => {
const queueId = generateId()
setQueue(prev => [...prev, { id: queueId, status: 'uploading' }])
try {
const formData = new FormData()
formData.append('file', file)
setQueue(prev => prev.map(q =>
q.id === queueId ? { ...q, status: 'processing' as const } : q
))
const res = await fetch('/api/invoices/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'Upload failed')
}
setQueue(prev => prev.map(q =>
q.id === queueId ? { ...q, status: 'complete' as const } : q
))
// Auto-remove completed after 3 seconds
setTimeout(() => {
setQueue(prev => prev.filter(q => q.id !== queueId))
}, 3000)
} catch (err) {
setQueue(prev => prev.map(q =>
q.id === queueId ? {
...q,
status: 'error' as const,
error: err instanceof Error ? err.message : 'Upload failed',
} : q
))
}
}
const processAndUpload = async () => {
if (captures.length === 0) return
const queueId = generateId()
const preview = captures[0]?.preview
const pageCount = captures.length
setQueue(prev => [...prev, { id: queueId, status: 'compressing', preview }])
setCaptures([])
try {
// Compress images
const compressed: { data: ArrayBuffer; width: number; height: number }[] = []
for (const cap of captures) {
const blob = await imageCompression(cap.file, {
maxSizeMB: 1,
maxWidthOrHeight: 2000,
useWebWorker: true,
fileType: 'image/jpeg' as const,
initialQuality: 0.85,
})
const img = new Image()
const url = URL.createObjectURL(blob)
await new Promise<void>((resolve) => {
img.onload = () => { URL.revokeObjectURL(url); resolve() }
img.src = url
})
compressed.push({ data: await blob.arrayBuffer(), width: img.width, height: img.height })
}
// Create PDF
setQueue(prev => prev.map(q =>
q.id === queueId ? { ...q, status: 'uploading' as const } : q
))
const pdfDoc = await PDFDocument.create()
for (const { data, width, height } of compressed) {
const jpegImage = await pdfDoc.embedJpg(data)
const page = pdfDoc.addPage([width, height])
page.drawImage(jpegImage, { x: 0, y: 0, width, height })
}
const pdfBytes = await pdfDoc.save()
const pdfFile = new File(
[new Blob([pdfBytes as BlobPart], { type: 'application/pdf' })],
`invoice-${pageCount}pages.pdf`,
{ type: 'application/pdf' }
)
// Upload
setQueue(prev => prev.map(q =>
q.id === queueId ? { ...q, status: 'processing' as const } : q
))
const formData = new FormData()
formData.append('file', pdfFile)
const res = await fetch('/api/invoices/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'Upload failed')
}
setQueue(prev => prev.map(q =>
q.id === queueId ? { ...q, status: 'complete' as const } : q
))
setTimeout(() => {
setQueue(prev => prev.filter(q => q.id !== queueId))
}, 3000)
} catch (err) {
setQueue(prev => prev.map(q =>
q.id === queueId ? {
...q,
status: 'error' as const,
error: err instanceof Error ? err.message : 'Failed',
} : q
))
}
}
const isWorking = queue.some(q => q.status === 'compressing' || q.status === 'uploading' || q.status === 'processing')
return (
<div style={styles.container}>
<div style={styles.app}>
{/* Header */}
<div style={styles.header}>
<div>
<div style={styles.brandSmall}>Invoice Upload</div>
<div style={styles.kitchenName}>{user.kitchen_name}</div>
</div>
<button onClick={logout} style={styles.logoutBtn}>Sign Out</button>
</div>
{/* Queue status */}
{queue.length > 0 && (
<div style={styles.queueArea}>
{queue.map(q => (
<div key={q.id} style={{
...styles.queueItem,
borderLeftColor: q.status === 'complete' ? '#4caf50'
: q.status === 'error' ? '#f44336' : '#2196f3',
}}>
<span style={styles.queueText}>
{q.status === 'compressing' && 'Compressing...'}
{q.status === 'uploading' && 'Uploading...'}
{q.status === 'processing' && 'Processing OCR...'}
{q.status === 'complete' && 'Uploaded successfully'}
{q.status === 'error' && (q.error || 'Upload failed')}
</span>
{(q.status === 'compressing' || q.status === 'uploading' || q.status === 'processing') && (
<div style={styles.spinner} />
)}
</div>
))}
</div>
)}
{/* Main area */}
{captures.length === 0 ? (
<div style={styles.captureArea}>
<button
onClick={() => cameraRef.current?.click()}
style={styles.cameraBtn}
disabled={isWorking}
>
<span style={styles.cameraIcon}>📷</span>
<span>Take Photo</span>
</button>
<button
onClick={() => fileRef.current?.click()}
style={styles.fileBtn}
disabled={isWorking}
>
Choose File / PDF
</button>
<input
ref={cameraRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleCapture}
style={{ display: 'none' }}
/>
<input
ref={fileRef}
type="file"
accept="image/*,application/pdf"
multiple
onChange={handleCapture}
style={{ display: 'none' }}
/>
</div>
) : (
<div style={styles.reviewArea}>
{/* Preview grid */}
<div style={styles.previewGrid}>
{captures.map((cap, idx) => (
<div key={cap.id} style={styles.previewCard}>
<img src={cap.preview} alt={`Page ${idx + 1}`} style={styles.previewImg} />
<button
onClick={() => removeCapture(cap.id)}
style={styles.removeBtn}
disabled={isWorking}
>
×
</button>
<div style={styles.pageLabel}>Page {idx + 1}</div>
</div>
))}
{/* Add Page card in grid */}
<button
onClick={() => cameraRef.current?.click()}
style={styles.addPageCard}
disabled={isWorking}
>
<span style={{ fontSize: '2.5rem' }}>📷</span>
<span style={{ fontSize: '0.8rem', fontWeight: 500 }}>Add Page</span>
</button>
</div>
{/* Actions */}
<div style={styles.actionBar}>
<button
onClick={() => setCaptures([])}
style={styles.clearBtn}
disabled={isWorking}
>
Clear All
</button>
</div>
<button
onClick={processAndUpload}
style={{
...styles.uploadBtn,
...(isWorking ? { opacity: 0.6, cursor: 'wait' } : {}),
}}
disabled={isWorking}
>
{isWorking ? 'Processing...' : `Upload ${captures.length} Page${captures.length !== 1 ? 's' : ''}`}
</button>
<input
ref={cameraRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleCapture}
style={{ display: 'none' }}
/>
<input
ref={fileRef}
type="file"
accept="image/*,application/pdf"
multiple
onChange={handleCapture}
style={{ display: 'none' }}
/>
</div>
)}
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
container: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: '#1a1a2e',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
},
app: {
width: '100%',
maxWidth: '500px',
height: '100%',
display: 'flex',
flexDirection: 'column',
background: '#16213e',
overflow: 'auto',
},
// Login
loginCard: {
background: 'white',
padding: '2rem',
borderRadius: '12px',
width: '90%',
maxWidth: '360px',
textAlign: 'center',
},
brand: {
color: '#1a1a2e',
marginBottom: '0.25rem',
fontSize: '1.4rem',
},
subtitle: {
color: '#666',
marginBottom: '1.5rem',
fontSize: '0.9rem',
},
error: {
background: '#fee',
color: '#c00',
padding: '0.6rem',
borderRadius: '6px',
marginBottom: '1rem',
fontSize: '0.85rem',
},
form: {
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
},
input: {
padding: '0.75rem 1rem',
borderRadius: '8px',
border: '1px solid #ddd',
fontSize: '1rem',
outline: 'none',
},
loginBtn: {
padding: '0.75rem',
background: '#e94560',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '1rem',
fontWeight: 'bold',
cursor: 'pointer',
},
// Header
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem 1rem',
background: '#1a1a2e',
borderBottom: '1px solid rgba(255,255,255,0.1)',
},
brandSmall: {
color: 'white',
fontWeight: 'bold',
fontSize: '1rem',
},
kitchenName: {
color: 'rgba(255,255,255,0.5)',
fontSize: '0.75rem',
},
logoutBtn: {
padding: '0.4rem 0.75rem',
background: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.7)',
border: '1px solid rgba(255,255,255,0.2)',
borderRadius: '6px',
fontSize: '0.8rem',
cursor: 'pointer',
},
// Queue
queueArea: {
padding: '0.5rem 1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
},
queueItem: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.6rem 0.75rem',
background: 'rgba(255,255,255,0.05)',
borderRadius: '8px',
borderLeft: '3px solid #2196f3',
},
queueText: {
color: 'rgba(255,255,255,0.8)',
fontSize: '0.85rem',
},
spinner: {
width: '16px',
height: '16px',
border: '2px solid rgba(255,255,255,0.2)',
borderTopColor: 'white',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
},
// Capture area (no images yet)
captureArea: {
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5rem',
padding: '2rem',
},
cameraBtn: {
width: '200px',
height: '200px',
borderRadius: '50%',
background: '#e94560',
border: 'none',
color: 'white',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
cursor: 'pointer',
fontSize: '1.2rem',
fontWeight: 'bold',
boxShadow: '0 8px 32px rgba(233,69,96,0.4)',
},
cameraIcon: {
fontSize: '3rem',
},
fileBtn: {
padding: '0.75rem 2rem',
background: 'transparent',
border: '2px solid rgba(255,255,255,0.3)',
borderRadius: '8px',
color: 'rgba(255,255,255,0.7)',
fontSize: '1rem',
cursor: 'pointer',
},
// Review area (has images)
reviewArea: {
flex: 1,
display: 'flex',
flexDirection: 'column',
padding: '1rem',
gap: '1rem',
overflow: 'auto',
},
previewGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '0.75rem',
alignContent: 'start',
},
previewCard: {
position: 'relative',
borderRadius: '8px',
overflow: 'hidden',
background: '#0f0f23',
height: '180px',
},
previewImg: {
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
},
addPageCard: {
height: '180px',
borderRadius: '8px',
border: '2px dashed rgba(255,255,255,0.25)',
background: 'rgba(255,255,255,0.05)',
color: 'rgba(255,255,255,0.7)',
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
cursor: 'pointer',
},
removeBtn: {
position: 'absolute',
top: '4px',
right: '4px',
width: '28px',
height: '28px',
borderRadius: '50%',
background: 'rgba(0,0,0,0.6)',
color: 'white',
border: 'none',
fontSize: '1.2rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
pageLabel: {
position: 'absolute',
bottom: '4px',
left: '4px',
background: 'rgba(0,0,0,0.6)',
color: 'white',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '0.7rem',
fontWeight: 'bold',
},
actionBar: {
display: 'flex',
justifyContent: 'flex-end',
},
clearBtn: {
padding: '0.5rem 1rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: '8px',
color: 'rgba(255,255,255,0.5)',
fontSize: '0.85rem',
cursor: 'pointer',
},
uploadBtn: {
padding: '1rem',
background: '#e94560',
color: 'white',
border: 'none',
borderRadius: '10px',
fontSize: '1.1rem',
fontWeight: 'bold',
cursor: 'pointer',
boxShadow: '0 4px 16px rgba(233,69,96,0.3)',
},
}

File diff suppressed because it is too large Load diff

15
frontend/src/types.ts Normal file
View file

@ -0,0 +1,15 @@
export interface User {
email: string
name: string
is_admin: boolean
caps: string[]
// Archive compat — not provided by central auth but referenced by existing components
id?: number
kitchen_id?: number
kitchen_name?: string
}
export function can(user: User | null, cap: string): boolean {
if (!user) return false
return user.is_admin || user.caps.includes(cap)
}

View file

@ -0,0 +1,96 @@
// Shared interfaces and utilities for ingredient creation/editing modals
export interface IngredientCategory {
id: number
name: string
sort_order: number
ingredient_count: number
}
export interface SimilarIngredient {
id: number
name: string
similarity: number
}
export interface LineItemResult {
product_code: string | null
description: string | null
supplier_id: number | null
supplier_name: string | null
unit: string | null
most_recent_price: number | null
total_quantity: number | null
occurrence_count: number
most_recent_invoice_id: number
most_recent_line_number: number | null
most_recent_pack_quantity: number | null
most_recent_unit_size: number | null
most_recent_unit_size_type: string | null
ingredient_id: number | null
ingredient_name: string | null
}
export interface EditingIngredient {
id: number
name: string
category_id: number | null
standard_unit: string
yield_percent: number
manual_price: number | null
notes: string | null
is_prepackaged: boolean
is_free: boolean
product_ingredients: string | null
has_label_image: boolean
}
export interface IngredientModalResult {
id: number
name: string
standard_unit: string
}
export const LI_CONVERSIONS: Record<string, Record<string, number>> = {
g: { g: 1, kg: 0.001 }, kg: { g: 1000, kg: 1 }, oz: { g: 28.3495, kg: 0.0283495 },
ml: { ml: 1, ltr: 0.001 }, cl: { ml: 10, ltr: 0.01 }, ltr: { ml: 1000, ltr: 1 },
each: { each: 1 },
}
export function calcConversionDisplay(
packQty: number, unitSize: number | null, unitSizeType: string,
standardUnit: string, unitPrice: number | null
): string {
if (!unitSize || !unitSizeType) return ''
const conv = LI_CONVERSIONS[unitSizeType]?.[standardUnit]
if (!conv) return unitSizeType !== standardUnit ? `Cannot convert ${unitSizeType} \u2192 ${standardUnit}` : ''
const totalStd = packQty * unitSize * conv
const pricePerStd = unitPrice ? (unitPrice / totalStd) : null
const packNote = packQty > 1 ? `${packQty} \u00d7 ${unitSize}${unitSizeType} = ` : ''
let display = `${packNote}${totalStd.toFixed(totalStd % 1 ? 2 : 0)} ${standardUnit}`
if (pricePerStd) {
display += ` \u2192 \u00a3${pricePerStd.toFixed(4)} per ${standardUnit}`
if (standardUnit === 'g') display += ` (\u00a3${(pricePerStd * 1000).toFixed(2)}/kg)`
else if (standardUnit === 'ml') display += ` (\u00a3${(pricePerStd * 1000).toFixed(2)}/ltr)`
}
return display
}
export function parsePackFromDescription(desc: string): { qty: number; size: string; type: string } | null {
const packMatch = desc.match(/(\d+)\s*[x\u00d7]\s*(\d+(?:\.\d+)?)\s*(g|kg|ml|ltr|l|oz|cl|gm|gms)\b/i)
if (packMatch) {
let ut = packMatch[3].toLowerCase()
if (ut === 'l') ut = 'ltr'
if (ut === 'gm' || ut === 'gms') ut = 'g'
return { qty: parseInt(packMatch[1]), size: packMatch[2], type: ut }
}
const standaloneMatch = desc.match(/(\d+(?:\.\d+)?)\s*(g|kg|ml|ltr|l|oz|cl|gm|gms|gram|grams|kilo|kilos|kilogram|litre|litres|liter)\b/i)
if (standaloneMatch) {
let ut = standaloneMatch[2].toLowerCase()
if (ut === 'l' || ut === 'litre' || ut === 'litres' || ut === 'liter') ut = 'ltr'
if (ut === 'gm' || ut === 'gms' || ut === 'gram' || ut === 'grams') ut = 'g'
if (ut === 'kilo' || ut === 'kilos' || ut === 'kilogram') ut = 'kg'
return { qty: 1, size: standaloneMatch[1], type: ut }
}
return null
}

View file

@ -0,0 +1,198 @@
/**
* Search utilities for session storage persistence and debouncing.
*/
import { useState, useEffect } from 'react'
// Session storage keys for search state persistence
export const SEARCH_STORAGE_KEYS = {
invoices: {
query: 'search-invoices-query',
includeLineItems: 'search-invoices-include-line-items',
supplier: 'search-invoices-supplier',
status: 'search-invoices-status',
dateFrom: 'search-invoices-from',
dateTo: 'search-invoices-to',
groupBy: 'search-invoices-group',
sortColumn: 'search-invoices-sort-column',
sortDirection: 'search-invoices-sort-direction',
},
lineItems: {
query: 'search-line-items-query',
supplier: 'search-line-items-supplier',
dateFrom: 'search-line-items-from',
dateTo: 'search-line-items-to',
groupBy: 'search-line-items-group',
priceChangeFilter: 'search-line-items-price-change',
mappedFilter: 'search-line-items-mapped',
sortColumn: 'search-line-items-sort-column',
sortDirection: 'search-line-items-sort-direction',
},
definitions: {
query: 'search-definitions-query',
supplier: 'search-definitions-supplier',
hasPortions: 'search-definitions-has-portions',
sortColumn: 'search-definitions-sort-column',
sortDirection: 'search-definitions-sort-direction',
},
}
/**
* Custom hook for debouncing a value.
* Used for live search to delay API calls while user is typing.
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}
/**
* Get default date range for searches (last 30 days).
*/
export function getDefaultDateRange(): { from: string; to: string } {
const today = new Date()
const threeMonthsAgo = new Date(today)
threeMonthsAgo.setDate(threeMonthsAgo.getDate() - 90)
return {
from: threeMonthsAgo.toISOString().split('T')[0],
to: today.toISOString().split('T')[0],
}
}
/**
* Format a date string for display (DD/MM/YYYY format).
*/
export function formatDateForDisplay(dateString: string | null | undefined): string {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return date.toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
} catch {
return dateString
}
}
/**
* Format currency for display.
*/
export function formatCurrency(amount: number | string | null | undefined): string {
if (amount === null || amount === undefined) return '-'
const num = typeof amount === 'string' ? parseFloat(amount) : amount
if (isNaN(num)) return '-'
return `£${num.toFixed(2)}`
}
/**
* Format percentage for display.
*/
export function formatPercent(percent: number | string | null | undefined): string {
if (percent === null || percent === undefined) return '-'
const num = typeof percent === 'string' ? parseFloat(percent) : percent
if (isNaN(num)) return '-'
const sign = num >= 0 ? '+' : ''
return `${sign}${num.toFixed(1)}%`
}
/**
* Format quantity for display.
*/
export function formatQuantity(qty: number | string | null | undefined, decimals: number = 1): string {
if (qty === null || qty === undefined) return '-'
const num = typeof qty === 'string' ? parseFloat(qty) : qty
if (isNaN(num)) return '-'
return num.toFixed(decimals)
}
/**
* Get stored value from session storage with fallback.
*/
export function getStoredValue<T>(key: string, fallback: T): T {
try {
const stored = sessionStorage.getItem(key)
if (stored === null) return fallback
return JSON.parse(stored) as T
} catch {
return fallback
}
}
/**
* Store value in session storage.
*/
export function setStoredValue<T>(key: string, value: T): void {
try {
sessionStorage.setItem(key, JSON.stringify(value))
} catch {
// Ignore storage errors
}
}
/**
* Custom hook for persisted state in session storage.
*/
export function usePersistedState<T>(
key: string,
initialValue: T
): [T, React.Dispatch<React.SetStateAction<T>>] {
const [state, setState] = useState<T>(() => getStoredValue(key, initialValue))
useEffect(() => {
setStoredValue(key, state)
}, [key, state])
return [state, setState]
}
/**
* Price status icon and color mapping.
*/
export const PRICE_STATUS_CONFIG = {
consistent: {
icon: '✓',
color: '#22c55e', // green
label: 'Price consistent',
},
amber: {
icon: '?',
color: '#f59e0b', // amber
label: 'Small price change',
},
red: {
icon: '!',
color: '#ef4444', // red
label: 'Large price change',
},
acknowledged: {
icon: '✓',
color: '#22c55e', // green (acknowledged = now consistent)
label: 'Price acknowledged',
},
no_history: {
icon: null,
color: '#9ca3af', // gray
label: 'No price history',
},
}
/**
* Get price status display config.
*/
export function getPriceStatusConfig(status: string | null | undefined) {
if (!status) return PRICE_STATUS_CONFIG.no_history
return PRICE_STATUS_CONFIG[status as keyof typeof PRICE_STATUS_CONFIG] || PRICE_STATUS_CONFIG.no_history
}