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:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
833
frontend/src/components/AllowancesReport.tsx
Normal file
833
frontend/src/components/AllowancesReport.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
70
frontend/src/components/AuthGate.tsx
Normal file
70
frontend/src/components/AuthGate.tsx
Normal 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>
|
||||
}
|
||||
2008
frontend/src/components/Budget.tsx
Normal file
2008
frontend/src/components/Budget.tsx
Normal file
File diff suppressed because it is too large
Load diff
438
frontend/src/components/BulkAllergens.tsx
Normal file
438
frontend/src/components/BulkAllergens.tsx
Normal 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 > 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' },
|
||||
}
|
||||
240
frontend/src/components/BulkPublishModal.tsx
Normal file
240
frontend/src/components/BulkPublishModal.tsx
Normal 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' },
|
||||
}
|
||||
1109
frontend/src/components/CostDistributionModal.tsx
Normal file
1109
frontend/src/components/CostDistributionModal.tsx
Normal file
File diff suppressed because it is too large
Load diff
538
frontend/src/components/CreateDisputeModal.tsx
Normal file
538
frontend/src/components/CreateDisputeModal.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
893
frontend/src/components/Dashboard.tsx
Normal file
893
frontend/src/components/Dashboard.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
2178
frontend/src/components/DishEditor.tsx
Normal file
2178
frontend/src/components/DishEditor.tsx
Normal file
File diff suppressed because it is too large
Load diff
1037
frontend/src/components/DishList.tsx
Normal file
1037
frontend/src/components/DishList.tsx
Normal file
File diff suppressed because it is too large
Load diff
1247
frontend/src/components/DisputeDetailModal.tsx
Normal file
1247
frontend/src/components/DisputeDetailModal.tsx
Normal file
File diff suppressed because it is too large
Load diff
488
frontend/src/components/Disputes.tsx
Normal file
488
frontend/src/components/Disputes.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
811
frontend/src/components/EventOrderEditor.tsx
Normal file
811
frontend/src/components/EventOrderEditor.tsx
Normal 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 },
|
||||
}
|
||||
189
frontend/src/components/EventOrders.tsx
Normal file
189
frontend/src/components/EventOrders.tsx
Normal 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 },
|
||||
}
|
||||
95
frontend/src/components/FoodFlagBadges.tsx
Normal file
95
frontend/src/components/FoodFlagBadges.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
1446
frontend/src/components/GPReport.tsx
Normal file
1446
frontend/src/components/GPReport.tsx
Normal file
File diff suppressed because it is too large
Load diff
266
frontend/src/components/ImageCropModal.tsx
Normal file
266
frontend/src/components/ImageCropModal.tsx
Normal 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 },
|
||||
}
|
||||
892
frontend/src/components/IngredientFlagEditor.tsx
Normal file
892
frontend/src/components/IngredientFlagEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1275
frontend/src/components/IngredientModal.tsx
Normal file
1275
frontend/src/components/IngredientModal.tsx
Normal file
File diff suppressed because it is too large
Load diff
396
frontend/src/components/Ingredients.tsx
Normal file
396
frontend/src/components/Ingredients.tsx
Normal 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' },
|
||||
}
|
||||
687
frontend/src/components/InvoiceList.tsx
Normal file
687
frontend/src/components/InvoiceList.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
123
frontend/src/components/Layout.tsx
Normal file
123
frontend/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
542
frontend/src/components/LineItemHistoryModal.tsx
Normal file
542
frontend/src/components/LineItemHistoryModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
506
frontend/src/components/LinkDisputeModal.tsx
Normal file
506
frontend/src/components/LinkDisputeModal.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
461
frontend/src/components/MapLineItemsModal.tsx
Normal file
461
frontend/src/components/MapLineItemsModal.tsx
Normal 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' },
|
||||
}
|
||||
702
frontend/src/components/MenuEditor.tsx
Normal file
702
frontend/src/components/MenuEditor.tsx
Normal 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' },
|
||||
}
|
||||
149
frontend/src/components/MenuFlagMatrix.tsx
Normal file
149
frontend/src/components/MenuFlagMatrix.tsx
Normal 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' },
|
||||
}
|
||||
375
frontend/src/components/MenuList.tsx
Normal file
375
frontend/src/components/MenuList.tsx
Normal 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' },
|
||||
}
|
||||
254
frontend/src/components/PriceImpact.tsx
Normal file
254
frontend/src/components/PriceImpact.tsx
Normal 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' },
|
||||
}
|
||||
372
frontend/src/components/PublishToMenuModal.tsx
Normal file
372
frontend/src/components/PublishToMenuModal.tsx
Normal 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' },
|
||||
}
|
||||
283
frontend/src/components/PurchaseOrderList.tsx
Normal file
283
frontend/src/components/PurchaseOrderList.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
1007
frontend/src/components/PurchaseOrderModal.tsx
Normal file
1007
frontend/src/components/PurchaseOrderModal.tsx
Normal file
File diff suppressed because it is too large
Load diff
1713
frontend/src/components/Purchases.tsx
Normal file
1713
frontend/src/components/Purchases.tsx
Normal file
File diff suppressed because it is too large
Load diff
918
frontend/src/components/PurchasesReport.tsx
Normal file
918
frontend/src/components/PurchasesReport.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
1954
frontend/src/components/RecipeEditor.tsx
Normal file
1954
frontend/src/components/RecipeEditor.tsx
Normal file
File diff suppressed because it is too large
Load diff
333
frontend/src/components/RecipeFlagMatrix.tsx
Normal file
333
frontend/src/components/RecipeFlagMatrix.tsx
Normal 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' },
|
||||
}
|
||||
728
frontend/src/components/RecipeList.tsx
Normal file
728
frontend/src/components/RecipeList.tsx
Normal 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' },
|
||||
}
|
||||
718
frontend/src/components/ReconcilePurchases.tsx
Normal file
718
frontend/src/components/ReconcilePurchases.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
5944
frontend/src/components/Review.tsx
Normal file
5944
frontend/src/components/Review.tsx
Normal file
File diff suppressed because it is too large
Load diff
448
frontend/src/components/SalesGPReport.tsx
Normal file
448
frontend/src/components/SalesGPReport.tsx
Normal 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}>£{fmt(report.mapped_revenue_net)}</div>
|
||||
</div>
|
||||
<div style={styles.statBox}>
|
||||
<div style={styles.statLabel}>Mapped Cost</div>
|
||||
<div style={styles.statValue}>£{fmt(report.mapped_total_cost)}</div>
|
||||
</div>
|
||||
<div style={styles.statBox}>
|
||||
<div style={styles.statLabel}>Total Revenue</div>
|
||||
<div style={styles.statValue}>£{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: £{fmt(course.course_revenue)}</span>
|
||||
<span style={{ marginRight: '1.5rem' }}>Cost: £{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' }}>£{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}>
|
||||
£{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' }}>£{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' },
|
||||
}
|
||||
945
frontend/src/components/SearchDefinitions.tsx
Normal file
945
frontend/src/components/SearchDefinitions.tsx
Normal 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%',
|
||||
}
|
||||
509
frontend/src/components/SearchInvoices.tsx
Normal file
509
frontend/src/components/SearchInvoices.tsx
Normal 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',
|
||||
}
|
||||
1139
frontend/src/components/SearchLineItems.tsx
Normal file
1139
frontend/src/components/SearchLineItems.tsx
Normal file
File diff suppressed because it is too large
Load diff
741
frontend/src/components/Suppliers.tsx
Normal file
741
frontend/src/components/Suppliers.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
344
frontend/src/components/SupportButton.tsx
Normal file
344
frontend/src/components/SupportButton.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
865
frontend/src/components/Upload.tsx
Normal file
865
frontend/src/components/Upload.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
413
frontend/src/components/UsageVarianceReport.tsx
Normal file
413
frontend/src/components/UsageVarianceReport.tsx
Normal 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' },
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue