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
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' },
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue