From 0d6ca9b9794d7ed19b5c9e099ccc6bfd2243a1be Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sun, 12 Jul 2026 20:11:41 +0000 Subject: [PATCH] Fix internal navigation links: add /kitchen/ prefix to all bare hrefs Bare tags bypass React Router basename, causing hard navigations to absolute paths with no NPM route. Added /kitchen/ prefix to all internal href values across 12 components. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/Dashboard.tsx | 1786 +- .../src/components/DisputeDetailModal.tsx | 2494 +-- frontend/src/components/GPReport.tsx | 2 +- frontend/src/components/InvoiceList.tsx | 8 +- .../src/components/LineItemHistoryModal.tsx | 2 +- frontend/src/components/Review.tsx | 11888 +++++------ frontend/src/components/SearchDefinitions.tsx | 4 +- frontend/src/components/SearchInvoices.tsx | 4 +- frontend/src/components/SearchLineItems.tsx | 2278 +-- frontend/src/components/Upload.tsx | 2 +- frontend/src/pages/NewbookData.tsx | 2 +- frontend/src/pages/Settings.tsx | 16862 ++++++++-------- 12 files changed, 17666 insertions(+), 17666 deletions(-) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index c482b6c..21b6553 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,893 +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 | 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 - 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({ - queryKey: ['resos-settings'], - queryFn: async () => { - const res = await fetch('/kitchen/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({ - queryKey: ['dashboard'], - queryFn: async () => { - const res = await fetch('/kitchen/api/reports/dashboard', { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Failed to fetch dashboard') - return res.json() - }, - }) - - const { data: resosCovers } = useQuery({ - queryKey: ['resos-dashboard-covers'], - queryFn: async () => { - const res = await fetch('/kitchen/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({ - queryKey: ['newbook-arrival-stats'], - queryFn: async () => { - const res = await fetch('/kitchen/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('/kitchen/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('/kitchen/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({ - queryKey: ['dispute-stats'], - queryFn: async () => { - const res = await fetch('/kitchen/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 = { - '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() - 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 ( -
-

- {title} - {dayData?.has_flagged_bookings && ( - - {getFlagIcons(dayData.unique_flag_types).map((icon, idx) => ( - {icon} - ))} - - )} -

- {dayData && dayData.total_covers > 0 ? ( - <> -
- {dayData.total_covers} covers -
-
- {dayData.total_bookings} bookings - {dayData.service_breakdown.map((s) => ( - - {s.period}: {s.bookings} : {s.covers} - - ))} -
- - ) : ( -

No booking data

- )} -
- ) - } - - if (isLoading) { - return
Loading dashboard...
- } - - if (error) { - return
Error loading dashboard. Please try logging in again.
- } - - const current = data?.current_period - const previous = data?.previous_period - - return ( -
-

Dashboard

- - {/* ===== GP Section ===== */} -

Gross Profit

-
-
navigate(`/purchases?from=${formatDate(dateRanges.thisWeek.start)}&to=${formatDate(dateRanges.thisWeek.end)}`)} - title="Click to view flash report for this period" - > -

This Week

-
{formatDateRange(dateRanges.thisWeek.start, dateRanges.thisWeek.end)}
- {current ? ( - <> -
{Number(current.gp_percentage).toFixed(1)}%
- {current.gp_with_allowances != null && ( -
({Number(current.gp_with_allowances).toFixed(1)}% with allowances)
- )} -
- Revenue: £{Number(current.total_revenue).toFixed(2)} - Costs: £{Number(current.total_costs).toFixed(2)} -
- - ) : ( -

No data for this period

- )} -
- -
-

This Week Forecast

-
{formatDateRange(dateRanges.thisWeek.start, dateRanges.thisWeek.end)}
- {data?.forecast_period ? ( - <> -
{Number(data.forecast_period.gp_percentage).toFixed(1)}%
-
- Revenue: £{Number(data.forecast_period.total_revenue).toFixed(2)} - Costs: £{Number(data.forecast_period.total_costs).toFixed(2)} -
- - ) : ( -

Coming soon

- )} -
- -
navigate(`/purchases?from=${formatDate(dateRanges.lastWeek.start)}&to=${formatDate(dateRanges.lastWeek.end)}`)} - title="Click to view flash report for this period" - > -

Last Week

-
{formatDateRange(dateRanges.lastWeek.start, dateRanges.lastWeek.end)}
- {previous ? ( - <> -
{Number(previous.gp_percentage).toFixed(1)}%
- {previous.gp_with_allowances != null && ( -
({Number(previous.gp_with_allowances).toFixed(1)}% with allowances)
- )} -
- Revenue: £{Number(previous.total_revenue).toFixed(2)} - Costs: £{Number(previous.total_costs).toFixed(2)} -
- - ) : ( -

No data for this period

- )} -
- -
navigate(`/purchases?from=${formatDate(dateRanges.last30Days.start)}&to=${formatDate(dateRanges.last30Days.end)}`)} - title="Click to view flash report for this period" - > -

Last 30 Days

-
{formatDateRange(dateRanges.last30Days.start, dateRanges.last30Days.end)}
- {data?.rolling_30_days ? ( - <> -
{Number(data.rolling_30_days.gp_percentage).toFixed(1)}%
- {data.rolling_30_days.gp_with_allowances != null && ( -
({Number(data.rolling_30_days.gp_with_allowances).toFixed(1)}% with allowances)
- )} -
- Revenue: £{Number(data.rolling_30_days.total_revenue).toFixed(2)} - Costs: £{Number(data.rolling_30_days.total_costs).toFixed(2)} -
- - ) : ( -

No data for this period

- )} -
-
- - {/* ===== Documents Section ===== */} -

Documents

-
- -

Upload New

-
+
-

Upload invoice

-
- -
0 ? 'pointer' : 'default' - }} - onClick={() => (data?.pending_review || 0) > 0 && navigate('/invoices?status=pending_confirmation')} - > -

Pending Confirmation

-
{data?.pending_review || 0}
-

Awaiting confirmation

-
- {(data?.pending_review || 0) > 0 && ( - Review now → - )} -
-
- -
0 ? styles.alertCard : {}), - cursor: (disputeStats?.open_disputes || 0) > 0 ? 'pointer' : 'default' - }} - onClick={() => (disputeStats?.open_disputes || 0) > 0 && navigate('/disputes')} - > -

Disputes

-
0 ? '#e94560' : '#4ade80' - }}> - {disputeStats?.open_disputes || 0} -
-

Open disputes

- {disputeStats && disputeStats.status_counts && Object.keys(disputeStats.status_counts).length > 0 && ( -
- {Object.entries(disputeStats.status_counts) - .filter(([status]) => status !== 'RESOLVED') - .filter(([, data]) => (data as any).count > 0) - .map(([status, data]) => ( - - {status.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}: {(data as any).count} - - ))} -
- )} -
- View open → -
-
- -
-

Recent Invoices

-
{data?.recent_invoices || 0}
-

Uploaded this week

-
- {(data?.recent_invoices || 0) > 0 && ( - - View all → - - )} -
-
-
- - {/* ===== Recipes & Dishes Section ===== */} - {recipeStats && (recipeStats.total_recipes > 0 || recipeStats.unmapped_ingredients > 0) && ( - <> -

Recipes & Dishes

-
-
navigate('/recipes')} - > -

Recipes

-
{recipeStats.component_recipes}
-

Component recipes

-
- View recipes → -
-
- -
navigate('/dishes')} - > -

Dishes

-
{recipeStats.dish_count}
-

Plated dishes

-
- View dishes → -
-
- - {recipeStats.dishes_missing_allergens > 0 && ( -
navigate('/allergens')} - > -

Missing Allergens

-
{recipeStats.dishes_missing_allergens}
-

Dishes with unassessed ingredients

- {recipeStats.dishes_missing_allergens_list.length > 0 && ( -
- {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`} -
- )} -
- Review allergens → -
-
- )} - - {recipeStats.unmapped_ingredients > 0 && ( -
navigate('/ingredients?unmapped=true')} - > -

Unmapped Ingredients

-
{recipeStats.unmapped_ingredients}
-

Without supplier source

-
- Map now → -
-
- )} - - {recipeStats.recipes_without_costing > 0 && ( -
navigate('/recipes')} - > -

Without Costing

-
{recipeStats.recipes_without_costing}
-

Recipes with no cost snapshot

-
- View recipes → -
-
- )} - - {recipeStats.recipes_with_price_changes > 0 && ( -
navigate('/price-impact')} - > -

Price Changes

-
{recipeStats.recipes_with_price_changes}
-

Recipes affected by ingredient price changes (14 days)

-
- View report → -
-
- )} - - {recipeStats.stale_menu_items > 0 && ( -
navigate('/menus')} - > -

Menus Need Republishing

-
{recipeStats.stale_menu_items}
-

Dish{recipeStats.stale_menu_items !== 1 ? 'es' : ''} changed since last publish

- {recipeStats.stale_menu_names.length > 0 && ( -
- {recipeStats.stale_menu_names.join(', ')} -
- )} -
- View menus → -
-
- )} -
- - )} - - {/* ===== Covers Section ===== */} -

Covers

-
- {renderCoversWidget(resosCovers?.today || null, 'Today')} - {renderCoversWidget(resosCovers?.tomorrow || null, 'Tomorrow')} - {renderCoversWidget(resosCovers?.day_after || null, resosCovers?.day_after?.day_label || 'Day After')} - -
-

Upcoming Events

- {upcomingEvents && upcomingEvents.total_count > 0 ? ( - <> -
{upcomingEvents.total_count}
-
- {upcomingEvents.upcoming_events.map((event) => ( - - {event.event_date}: {event.title} - - ))} -
- View Calendar → - - ) : ( -

No upcoming events

- )} -
-
- - {/* ===== Hotel Arrivals & Restaurant Bookings ===== */} - {arrivalStats && arrivalStats.days && arrivalStats.days.length > 0 && ( - <> -

- {arrivalStats.service_filter_name - ? `Hotel Arrivals & ${arrivalStats.service_filter_name} Bookings` - : 'Hotel Arrivals & Restaurant Bookings' - } -

-
- {arrivalStats.service_filter_name && ( - (showing {arrivalStats.service_filter_name} tables only) - )} -
- {arrivalStats.days.map((day) => ( -
-
{day.day_name}
-
-
-
{day.arrival_count}
-
arrivals
-
{day.arrival_guests} guests
-
-
-
{day.table_bookings}
-
table bookings
-
{day.table_covers} covers
-
-
-
{day.matched_arrivals}
-
have booked
-
-
-
{day.unmatched_arrivals}
-
no booking yet
-
{day.opportunity_guests} guests
-
-
-
- ))} -
-
- - )} -
- ) -} - -const styles: Record = { - 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', - }, -} +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 | 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 + 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({ + queryKey: ['resos-settings'], + queryFn: async () => { + const res = await fetch('/kitchen/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({ + queryKey: ['dashboard'], + queryFn: async () => { + const res = await fetch('/kitchen/api/reports/dashboard', { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Failed to fetch dashboard') + return res.json() + }, + }) + + const { data: resosCovers } = useQuery({ + queryKey: ['resos-dashboard-covers'], + queryFn: async () => { + const res = await fetch('/kitchen/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({ + queryKey: ['newbook-arrival-stats'], + queryFn: async () => { + const res = await fetch('/kitchen/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('/kitchen/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('/kitchen/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({ + queryKey: ['dispute-stats'], + queryFn: async () => { + const res = await fetch('/kitchen/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 = { + '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() + 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 ( +
+

+ {title} + {dayData?.has_flagged_bookings && ( + + {getFlagIcons(dayData.unique_flag_types).map((icon, idx) => ( + {icon} + ))} + + )} +

+ {dayData && dayData.total_covers > 0 ? ( + <> +
+ {dayData.total_covers} covers +
+
+ {dayData.total_bookings} bookings + {dayData.service_breakdown.map((s) => ( + + {s.period}: {s.bookings} : {s.covers} + + ))} +
+ + ) : ( +

No booking data

+ )} +
+ ) + } + + if (isLoading) { + return
Loading dashboard...
+ } + + if (error) { + return
Error loading dashboard. Please try logging in again.
+ } + + const current = data?.current_period + const previous = data?.previous_period + + return ( +
+

Dashboard

+ + {/* ===== GP Section ===== */} +

Gross Profit

+
+
navigate(`/purchases?from=${formatDate(dateRanges.thisWeek.start)}&to=${formatDate(dateRanges.thisWeek.end)}`)} + title="Click to view flash report for this period" + > +

This Week

+
{formatDateRange(dateRanges.thisWeek.start, dateRanges.thisWeek.end)}
+ {current ? ( + <> +
{Number(current.gp_percentage).toFixed(1)}%
+ {current.gp_with_allowances != null && ( +
({Number(current.gp_with_allowances).toFixed(1)}% with allowances)
+ )} +
+ Revenue: £{Number(current.total_revenue).toFixed(2)} + Costs: £{Number(current.total_costs).toFixed(2)} +
+ + ) : ( +

No data for this period

+ )} +
+ +
+

This Week Forecast

+
{formatDateRange(dateRanges.thisWeek.start, dateRanges.thisWeek.end)}
+ {data?.forecast_period ? ( + <> +
{Number(data.forecast_period.gp_percentage).toFixed(1)}%
+
+ Revenue: £{Number(data.forecast_period.total_revenue).toFixed(2)} + Costs: £{Number(data.forecast_period.total_costs).toFixed(2)} +
+ + ) : ( +

Coming soon

+ )} +
+ +
navigate(`/purchases?from=${formatDate(dateRanges.lastWeek.start)}&to=${formatDate(dateRanges.lastWeek.end)}`)} + title="Click to view flash report for this period" + > +

Last Week

+
{formatDateRange(dateRanges.lastWeek.start, dateRanges.lastWeek.end)}
+ {previous ? ( + <> +
{Number(previous.gp_percentage).toFixed(1)}%
+ {previous.gp_with_allowances != null && ( +
({Number(previous.gp_with_allowances).toFixed(1)}% with allowances)
+ )} +
+ Revenue: £{Number(previous.total_revenue).toFixed(2)} + Costs: £{Number(previous.total_costs).toFixed(2)} +
+ + ) : ( +

No data for this period

+ )} +
+ +
navigate(`/purchases?from=${formatDate(dateRanges.last30Days.start)}&to=${formatDate(dateRanges.last30Days.end)}`)} + title="Click to view flash report for this period" + > +

Last 30 Days

+
{formatDateRange(dateRanges.last30Days.start, dateRanges.last30Days.end)}
+ {data?.rolling_30_days ? ( + <> +
{Number(data.rolling_30_days.gp_percentage).toFixed(1)}%
+ {data.rolling_30_days.gp_with_allowances != null && ( +
({Number(data.rolling_30_days.gp_with_allowances).toFixed(1)}% with allowances)
+ )} +
+ Revenue: £{Number(data.rolling_30_days.total_revenue).toFixed(2)} + Costs: £{Number(data.rolling_30_days.total_costs).toFixed(2)} +
+ + ) : ( +

No data for this period

+ )} +
+
+ + {/* ===== Documents Section ===== */} +

Documents

+
+ +

Upload New

+
+
+

Upload invoice

+
+ +
0 ? 'pointer' : 'default' + }} + onClick={() => (data?.pending_review || 0) > 0 && navigate('/invoices?status=pending_confirmation')} + > +

Pending Confirmation

+
{data?.pending_review || 0}
+

Awaiting confirmation

+
+ {(data?.pending_review || 0) > 0 && ( + Review now → + )} +
+
+ +
0 ? styles.alertCard : {}), + cursor: (disputeStats?.open_disputes || 0) > 0 ? 'pointer' : 'default' + }} + onClick={() => (disputeStats?.open_disputes || 0) > 0 && navigate('/disputes')} + > +

Disputes

+
0 ? '#e94560' : '#4ade80' + }}> + {disputeStats?.open_disputes || 0} +
+

Open disputes

+ {disputeStats && disputeStats.status_counts && Object.keys(disputeStats.status_counts).length > 0 && ( +
+ {Object.entries(disputeStats.status_counts) + .filter(([status]) => status !== 'RESOLVED') + .filter(([, data]) => (data as any).count > 0) + .map(([status, data]) => ( + + {status.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}: {(data as any).count} + + ))} +
+ )} +
+ View open → +
+
+ +
+

Recent Invoices

+
{data?.recent_invoices || 0}
+

Uploaded this week

+
+ {(data?.recent_invoices || 0) > 0 && ( + + View all → + + )} +
+
+
+ + {/* ===== Recipes & Dishes Section ===== */} + {recipeStats && (recipeStats.total_recipes > 0 || recipeStats.unmapped_ingredients > 0) && ( + <> +

Recipes & Dishes

+
+
navigate('/recipes')} + > +

Recipes

+
{recipeStats.component_recipes}
+

Component recipes

+
+ View recipes → +
+
+ +
navigate('/dishes')} + > +

Dishes

+
{recipeStats.dish_count}
+

Plated dishes

+
+ View dishes → +
+
+ + {recipeStats.dishes_missing_allergens > 0 && ( +
navigate('/allergens')} + > +

Missing Allergens

+
{recipeStats.dishes_missing_allergens}
+

Dishes with unassessed ingredients

+ {recipeStats.dishes_missing_allergens_list.length > 0 && ( +
+ {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`} +
+ )} +
+ Review allergens → +
+
+ )} + + {recipeStats.unmapped_ingredients > 0 && ( +
navigate('/ingredients?unmapped=true')} + > +

Unmapped Ingredients

+
{recipeStats.unmapped_ingredients}
+

Without supplier source

+
+ Map now → +
+
+ )} + + {recipeStats.recipes_without_costing > 0 && ( +
navigate('/recipes')} + > +

Without Costing

+
{recipeStats.recipes_without_costing}
+

Recipes with no cost snapshot

+
+ View recipes → +
+
+ )} + + {recipeStats.recipes_with_price_changes > 0 && ( +
navigate('/price-impact')} + > +

Price Changes

+
{recipeStats.recipes_with_price_changes}
+

Recipes affected by ingredient price changes (14 days)

+
+ View report → +
+
+ )} + + {recipeStats.stale_menu_items > 0 && ( +
navigate('/menus')} + > +

Menus Need Republishing

+
{recipeStats.stale_menu_items}
+

Dish{recipeStats.stale_menu_items !== 1 ? 'es' : ''} changed since last publish

+ {recipeStats.stale_menu_names.length > 0 && ( +
+ {recipeStats.stale_menu_names.join(', ')} +
+ )} +
+ View menus → +
+
+ )} +
+ + )} + + {/* ===== Covers Section ===== */} +

Covers

+
+ {renderCoversWidget(resosCovers?.today || null, 'Today')} + {renderCoversWidget(resosCovers?.tomorrow || null, 'Tomorrow')} + {renderCoversWidget(resosCovers?.day_after || null, resosCovers?.day_after?.day_label || 'Day After')} + +
+

Upcoming Events

+ {upcomingEvents && upcomingEvents.total_count > 0 ? ( + <> +
{upcomingEvents.total_count}
+
+ {upcomingEvents.upcoming_events.map((event) => ( + + {event.event_date}: {event.title} + + ))} +
+ View Calendar → + + ) : ( +

No upcoming events

+ )} +
+
+ + {/* ===== Hotel Arrivals & Restaurant Bookings ===== */} + {arrivalStats && arrivalStats.days && arrivalStats.days.length > 0 && ( + <> +

+ {arrivalStats.service_filter_name + ? `Hotel Arrivals & ${arrivalStats.service_filter_name} Bookings` + : 'Hotel Arrivals & Restaurant Bookings' + } +

+
+ {arrivalStats.service_filter_name && ( + (showing {arrivalStats.service_filter_name} tables only) + )} +
+ {arrivalStats.days.map((day) => ( +
+
{day.day_name}
+
+
+
{day.arrival_count}
+
arrivals
+
{day.arrival_guests} guests
+
+
+
{day.table_bookings}
+
table bookings
+
{day.table_covers} covers
+
+
+
{day.matched_arrivals}
+
have booked
+
+
+
{day.unmatched_arrivals}
+
no booking yet
+
{day.opportunity_guests} guests
+
+
+
+ ))} +
+
+ + )} +
+ ) +} + +const styles: Record = { + 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', + }, +} diff --git a/frontend/src/components/DisputeDetailModal.tsx b/frontend/src/components/DisputeDetailModal.tsx index 6dc5a3b..f133e88 100644 --- a/frontend/src/components/DisputeDetailModal.tsx +++ b/frontend/src/components/DisputeDetailModal.tsx @@ -1,1247 +1,1247 @@ -import { useState } from 'react' -import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query' -import { useAuth } from '../App' - -interface DisputeLineItem { - id: number - product_name: string - product_code: string | null - quantity_ordered: number | null - quantity_received: number | null - quantity_difference: number | null - unit_price_quoted: number | null - unit_price_charged: number | null - price_difference: number | null - total_charged: number - total_expected: number | null - notes: string | null -} - -interface DisputeAttachment { - id: number - file_name: string - file_type: string - file_size_bytes: number - attachment_type: string - description: string | null - uploaded_at: string - uploaded_by_username: string | null - public_hash: string | null - public_url: string | null -} - -interface DisputeActivity { - id: number - activity_type: string - description: string - created_at: string - created_by_username: string | null -} - -interface DisputeDetail { - id: number - invoice_id: number - invoice_number: string | null - supplier_name: string - dispute_type: string - status: string - priority: string - title: string - description: string - disputed_amount: number - expected_amount: number | null - difference_amount: number - supplier_contacted_at: string | null - supplier_response: string | null - supplier_contact_name: string | null - resolved_amount: number | null - opened_at: string - opened_by: string - resolved_at: string | null - closed_at: string | null - tags: string[] | null - line_items: DisputeLineItem[] - attachments: DisputeAttachment[] - activity_log: DisputeActivity[] -} - -interface DisputeDetailModalProps { - disputeId: number - onClose: () => void - onUpdate?: () => void -} - -const statusOptions = [ - { value: 'new', label: 'New' }, - { value: 'contacted', label: 'Contacted' }, - { value: 'awaiting_credit', label: 'Awaiting Credit' }, - { value: 'awaiting_replacement', label: 'Awaiting Replacement' }, - { value: 'resolved', label: 'Resolved' }, -] - -const priorityOptions = [ - { value: 'low', label: 'Low' }, - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' }, - { value: 'urgent', label: 'Urgent' }, -] - -const statusColors: Record = { - new: '#e94560', - contacted: '#f0ad4e', - awaiting_credit: '#9b59b6', - awaiting_replacement: '#8b7ec8', - resolved: '#5cb85c', -} - -const priorityColors: Record = { - low: '#5cb85c', - medium: '#5bc0de', - high: '#f0ad4e', - urgent: '#e94560', -} - -export default function DisputeDetailModal({ disputeId, onClose, onUpdate }: DisputeDetailModalProps) { - const { token, user } = useAuth() - const queryClient = useQueryClient() - - const [note, setNote] = useState('') - const [showUploadAttachment, setShowUploadAttachment] = useState(false) - const [isEditing, setIsEditing] = useState(false) - const [editedTitle, setEditedTitle] = useState('') - const [editedDescription, setEditedDescription] = useState('') - const [uploadFile, setUploadFile] = useState(null) - const [uploadDescription, setUploadDescription] = useState('') - const [isUploading, setIsUploading] = useState(false) - const [copiedHash, setCopiedHash] = useState(null) - - // LLM FEATURE — see LLM-MANIFEST.md for removal instructions - const [aiEmailLoading, setAiEmailLoading] = useState(false) - const [aiEmailSubject, setAiEmailSubject] = useState('') - const [aiEmailBody, setAiEmailBody] = useState('') - const [aiEmailError, setAiEmailError] = useState(null) - - // LLM FEATURE — settings query for LLM enabled check - const { data: settings } = useQuery<{ llm_enabled?: boolean; anthropic_api_key_set?: boolean }>({ - queryKey: ['settings'], - queryFn: async () => { - const res = await fetch('/kitchen/api/settings', { headers: { Authorization: `Bearer ${token}` } }) - if (!res.ok) return {} - return res.json() - }, - enabled: !!token, - staleTime: 60000, - }) - - const { data: dispute, isLoading, error } = useQuery({ - queryKey: ['dispute', disputeId], - queryFn: async () => { - const res = await fetch(`/kitchen/api/disputes/${disputeId}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Failed to fetch dispute') - return res.json() - }, - enabled: !!token, - }) - - const updateMutation = useMutation({ - mutationFn: async (data: { status?: string; priority?: string; supplier_response?: string; supplier_contact_name?: string; title?: string; description?: string }) => { - const res = await fetch(`/kitchen/api/disputes/${disputeId}`, { - method: 'PATCH', - 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 update dispute') - } - return res.json() - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['dispute', disputeId] }) - queryClient.invalidateQueries({ queryKey: ['disputes'] }) - queryClient.invalidateQueries({ queryKey: ['dispute-stats'] }) - if (onUpdate) onUpdate() - setNote('') - }, - }) - - const deleteMutation = useMutation({ - mutationFn: async () => { - const res = await fetch(`/kitchen/api/disputes/${disputeId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${token}`, - }, - }) - if (!res.ok) { - const error = await res.json() - throw new Error(error.detail || 'Failed to delete dispute') - } - return res.json() - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['disputes'] }) - queryClient.invalidateQueries({ queryKey: ['dispute-stats'] }) - if (onUpdate) onUpdate() - onClose() - }, - }) - - const handleStatusChange = (status: string) => { - updateMutation.mutate({ status: status.toUpperCase() }) - } - - const handlePriorityChange = (priority: string) => { - updateMutation.mutate({ priority: priority.toLowerCase() }) - } - - const handleAddNote = () => { - if (!note.trim()) { - alert('Please enter a note') - return - } - updateMutation.mutate({ - supplier_response: note.trim(), - }) - } - - const handleDelete = () => { - if (!confirm(`Are you sure you want to permanently delete this dispute?\n\nDispute: ${dispute?.title}\nInvoice #${dispute?.invoice_number}\n\nThis action cannot be undone.`)) { - return - } - deleteMutation.mutate() - } - - const handleEdit = () => { - if (dispute) { - setEditedTitle(dispute.title) - setEditedDescription(dispute.description || '') - setIsEditing(true) - } - } - - const handleCancelEdit = () => { - setIsEditing(false) - setEditedTitle('') - setEditedDescription('') - } - - const handleSaveEdit = () => { - if (!editedTitle.trim()) { - alert('Title cannot be empty') - return - } - - updateMutation.mutate({ - title: editedTitle.trim(), - description: editedDescription.trim() || undefined - } as any) - setIsEditing(false) - } - - // LLM FEATURE — see LLM-MANIFEST.md for removal instructions - const handleDraftEmail = async () => { - setAiEmailLoading(true) - setAiEmailError(null) - setAiEmailSubject('') - setAiEmailBody('') - try { - const res = await fetch(`/kitchen/api/disputes/${disputeId}/draft-email`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) { - const err = await res.json() - throw new Error(err.detail || 'Failed to draft email') - } - const data = await res.json() - if (data.llm_status === 'success' || data.llm_status === 'cached') { - setAiEmailSubject(data.email_subject || '') - setAiEmailBody(data.email_body || '') - } else { - setAiEmailError(data.error || 'AI drafting unavailable') - } - } catch (err) { - setAiEmailError(err instanceof Error ? err.message : 'Failed to draft email') - } finally { - setAiEmailLoading(false) - } - } - - const handleUploadAttachment = async () => { - if (!uploadFile) { - alert('Please select a file') - return - } - - setIsUploading(true) - try { - const formData = new FormData() - formData.append('file', uploadFile) - - const params = new URLSearchParams() - params.append('attachment_type', 'photo') // Default to photo - if (uploadDescription.trim()) { - params.append('description', uploadDescription.trim()) - } - - const res = await fetch(`/kitchen/api/disputes/${disputeId}/attachments?${params}`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - }, - body: formData, - }) - - if (!res.ok) { - const error = await res.json() - throw new Error(error.detail || 'Failed to upload attachment') - } - - // Refresh dispute data - queryClient.invalidateQueries({ queryKey: ['dispute', disputeId] }) - if (onUpdate) onUpdate() - - // Reset form - setUploadFile(null) - setUploadDescription('') - setShowUploadAttachment(false) - } catch (err) { - alert(err instanceof Error ? err.message : 'Upload failed') - } finally { - setIsUploading(false) - } - } - - const copyPublicLink = (att: DisputeAttachment) => { - if (!att.public_url) return - - // Build full URL - const fullUrl = `${window.location.origin}${att.public_url}` - - navigator.clipboard.writeText(fullUrl).then(() => { - setCopiedHash(att.public_hash) - setTimeout(() => setCopiedHash(null), 2000) - }).catch(() => { - // Fallback for older browsers - const textarea = document.createElement('textarea') - textarea.value = fullUrl - document.body.appendChild(textarea) - textarea.select() - document.execCommand('copy') - document.body.removeChild(textarea) - setCopiedHash(att.public_hash) - setTimeout(() => setCopiedHash(null), 2000) - }) - } - - const viewAttachment = (att: DisputeAttachment) => { - if (att.public_url) { - window.open(att.public_url, '_blank') - } - } - - if (isLoading) { - return ( -
-
e.stopPropagation()}> -
Loading dispute details...
-
-
- ) - } - - if (error || !dispute) { - return ( -
-
e.stopPropagation()}> -
Error loading dispute: {error?.message || 'Unknown error'}
-
-
- ) - } - - return ( -
-
e.stopPropagation()}> -
-
-
- {dispute.supplier_name} - - Invoice #{dispute.invoice_number || dispute.invoice_id} - - {dispute.dispute_type.replace(/_/g, ' ').toUpperCase()} -
- {isEditing ? ( - setEditedTitle(e.target.value)} - style={{ ...styles.modalTitle, border: '2px solid #e94560', padding: '0.5rem' }} - maxLength={200} - /> - ) : ( -

{dispute.title}

- )} -
-
- {isEditing ? ( - <> - - - - ) : ( - <> - - {user?.is_admin && ( - - )} - - )} - -
-
- -
- {/* Status and Priority */} -
-
-
- {statusOptions.map((opt) => ( - - ))} -
-
- -
-
- {priorityOptions.map((opt) => ( - - ))} -
-
- -
- - {/* Financial Summary */} -
-

Financial Impact

-
-
-
Disputed Amount
-
£{Number(dispute.disputed_amount).toFixed(2)}
-
- {dispute.expected_amount != null && ( -
-
Expected Amount
-
£{Number(dispute.expected_amount).toFixed(2)}
-
- )} -
-
Difference
-
- £{Number(Math.abs(dispute.difference_amount)).toFixed(2)} -
-
-
-
- - {/* LLM FEATURE — Draft Email button — see LLM-MANIFEST.md for removal instructions */} - {settings?.llm_enabled && settings?.anthropic_api_key_set && ( -
-
-

Draft Supplier Email

- -
- {aiEmailError && ( -
- {aiEmailError} -
- )} - {aiEmailSubject && ( -
-
- - setAiEmailSubject(e.target.value)} - style={{ ...styles.input, marginBottom: 0, borderColor: '#d4c5f0' }} - /> -
-
- -