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