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
750
docs/archive/PLAN-search-feature.md
Normal file
750
docs/archive/PLAN-search-feature.md
Normal file
|
|
@ -0,0 +1,750 @@
|
|||
# Search Feature Implementation Plan
|
||||
|
||||
## Overview
|
||||
Add a new "Search" main menu dropdown with three search pages:
|
||||
1. **Invoices Search** - Search/filter invoices (optionally including line item content)
|
||||
2. **Line Items Search** - Consolidated unique line items with price change detection
|
||||
3. **Unit/Portion Definitions Search** - Search ProductDefinition records
|
||||
|
||||
**Plus**: Reusable price history system with:
|
||||
- Price change detection on new invoices
|
||||
- Acknowledgement system to mark price changes as reviewed
|
||||
- History modal with graphs for any line item
|
||||
|
||||
All pages share common patterns: live search, session persistence, date filtering, grouping, and links to source records.
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### Existing Models
|
||||
|
||||
**Invoice**: `id`, `invoice_number`, `invoice_date`, `total`, `net_total`, `supplier_id`, `vendor_name`, `status`, `category`, `document_type`
|
||||
|
||||
**LineItem**: `id`, `invoice_id`, `product_code`, `description`, `unit`, `quantity`, `unit_price`, `amount`, `pack_quantity`, `unit_size`, `unit_size_type`, `portions_per_unit`
|
||||
|
||||
**ProductDefinition**: `id`, `kitchen_id`, `supplier_id`, `product_code`, `description_pattern`, `pack_quantity`, `unit_size`, `unit_size_type`, `portions_per_unit`, `portion_description`, `source_invoice_id`, `updated_at`
|
||||
|
||||
### New Model: AcknowledgedPrice
|
||||
**File**: `backend/models/acknowledged_price.py` (NEW)
|
||||
|
||||
Tracks when a user acknowledges a price change so it doesn't keep flagging.
|
||||
|
||||
```python
|
||||
class AcknowledgedPrice(Base):
|
||||
__tablename__ = "acknowledged_prices"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"))
|
||||
supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"))
|
||||
|
||||
# Product identification (same logic as line item consolidation)
|
||||
product_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# The acknowledged price point
|
||||
acknowledged_price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
|
||||
acknowledged_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
acknowledged_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
|
||||
# Which invoice triggered this acknowledgement
|
||||
source_invoice_id: Mapped[int | None] = mapped_column(ForeignKey("invoices.id"))
|
||||
source_line_item_id: Mapped[int | None] = mapped_column(ForeignKey("line_items.id"))
|
||||
|
||||
# Unique: one acknowledged price per product per supplier per kitchen
|
||||
__table_args__ = (
|
||||
UniqueConstraint('kitchen_id', 'supplier_id', 'product_code', 'description',
|
||||
name='uix_acknowledged_price'),
|
||||
)
|
||||
```
|
||||
|
||||
### New Settings Fields
|
||||
**File**: `backend/models/settings.py`
|
||||
|
||||
```python
|
||||
# Price change detection settings
|
||||
price_change_lookback_days: Mapped[int] = mapped_column(Integer, default=30)
|
||||
price_change_amber_threshold: Mapped[int] = mapped_column(Integer, default=10) # %
|
||||
price_change_red_threshold: Mapped[int] = mapped_column(Integer, default=20) # %
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### 1. New Search API Router
|
||||
**File**: `backend/api/search.py` (NEW)
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
|
||||
# ============ Invoice Search ============
|
||||
@router.get("/invoices")
|
||||
async def search_invoices(
|
||||
q: str = "", # Search term (invoice_number, vendor_name)
|
||||
include_line_items: bool = False, # Also search line item product_code/description
|
||||
supplier_id: int | None = None,
|
||||
status: str | None = None,
|
||||
date_from: date | None = None, # Default: 30 days ago
|
||||
date_to: date | None = None, # Default: today
|
||||
group_by: str | None = None, # "supplier", "month", or null
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> InvoiceSearchResponse
|
||||
|
||||
# ============ Line Items Search (Consolidated) ============
|
||||
@router.get("/line-items")
|
||||
async def search_line_items(
|
||||
q: str = "", # Search term (product_code, description)
|
||||
supplier_id: int | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
group_by: str | None = None, # "supplier", "invoice", "month"
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> LineItemSearchResponse
|
||||
# Returns DISTINCT line items by (product_code OR description + supplier)
|
||||
# With: most_recent_price, price_change_status, total_qty, occurrence_count
|
||||
|
||||
# ============ Unit/Portion Definitions Search ============
|
||||
@router.get("/definitions")
|
||||
async def search_definitions(
|
||||
q: str = "", # Search term (product_code, description_pattern)
|
||||
supplier_id: int | None = None,
|
||||
has_portions: bool | None = None, # Filter by portions_per_unit is set
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> DefinitionSearchResponse
|
||||
|
||||
# ============ Line Item History (Reusable) ============
|
||||
@router.get("/line-items/history")
|
||||
async def get_line_item_history(
|
||||
product_code: str | None = None,
|
||||
description: str | None = None,
|
||||
supplier_id: int,
|
||||
date_from: date | None = None, # Default: 12 months ago
|
||||
date_to: date | None = None,
|
||||
) -> LineItemHistoryResponse
|
||||
# Returns price history, qty stats for a specific product
|
||||
|
||||
# ============ Price Acknowledgement ============
|
||||
@router.post("/line-items/acknowledge-price")
|
||||
async def acknowledge_price_change(
|
||||
product_code: str | None,
|
||||
description: str | None,
|
||||
supplier_id: int,
|
||||
new_price: Decimal,
|
||||
source_invoice_id: int | None = None,
|
||||
source_line_item_id: int | None = None,
|
||||
) -> AcknowledgePriceResponse
|
||||
# Creates/updates AcknowledgedPrice record
|
||||
```
|
||||
|
||||
### 2. Price History Service (Reusable)
|
||||
**File**: `backend/services/price_history.py` (NEW)
|
||||
|
||||
```python
|
||||
class PriceHistoryService:
|
||||
"""Reusable service for price change detection - used by search AND invoice review"""
|
||||
|
||||
async def get_price_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
kitchen_id: int,
|
||||
supplier_id: int,
|
||||
product_code: str | None,
|
||||
description: str | None,
|
||||
current_price: Decimal,
|
||||
lookback_days: int = 30,
|
||||
amber_threshold: int = 10,
|
||||
red_threshold: int = 20,
|
||||
) -> PriceStatus:
|
||||
"""
|
||||
Returns price status for a line item:
|
||||
- "consistent": Price matches history (green tick)
|
||||
- "no_history": First time seeing this item (no icon)
|
||||
- "amber": Small price change within threshold
|
||||
- "red": Large price change above threshold
|
||||
- "acknowledged": Price was flagged but user acknowledged it
|
||||
"""
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
kitchen_id: int,
|
||||
supplier_id: int,
|
||||
product_code: str | None,
|
||||
description: str | None,
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
) -> LineItemHistory:
|
||||
"""
|
||||
Returns full history for a product:
|
||||
- price_history: list of {date, price, invoice_id, invoice_number}
|
||||
- total_occurrences: int
|
||||
- total_quantity: Decimal
|
||||
- avg_qty_per_invoice: Decimal
|
||||
- avg_qty_per_week: Decimal
|
||||
- avg_qty_per_month: Decimal
|
||||
"""
|
||||
|
||||
async def acknowledge_price(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
kitchen_id: int,
|
||||
user_id: int,
|
||||
supplier_id: int,
|
||||
product_code: str | None,
|
||||
description: str | None,
|
||||
new_price: Decimal,
|
||||
source_invoice_id: int | None,
|
||||
source_line_item_id: int | None,
|
||||
) -> AcknowledgedPrice:
|
||||
"""Creates or updates acknowledged price record"""
|
||||
```
|
||||
|
||||
### 3. Response Models
|
||||
|
||||
```python
|
||||
class InvoiceSearchItem(BaseModel):
|
||||
id: int
|
||||
invoice_number: str | None
|
||||
invoice_date: date | None
|
||||
total: Decimal | None
|
||||
net_total: Decimal | None
|
||||
supplier_id: int | None
|
||||
supplier_name: str | None
|
||||
vendor_name: str | None
|
||||
status: str
|
||||
document_type: str | None
|
||||
|
||||
class InvoiceSearchResponse(BaseModel):
|
||||
items: list[InvoiceSearchItem]
|
||||
total_count: int
|
||||
grouped_by: str | None
|
||||
groups: list[GroupSummary] | None # If grouped: [{name, count, total}]
|
||||
|
||||
class LineItemSearchItem(BaseModel):
|
||||
product_code: str | None
|
||||
description: str | None
|
||||
supplier_id: int | None
|
||||
supplier_name: str | None
|
||||
unit: str | None
|
||||
# Price info
|
||||
most_recent_price: Decimal | None # Latest unit_price
|
||||
earliest_price_in_period: Decimal | None
|
||||
price_change_percent: float | None # % change from earliest to most recent
|
||||
price_change_status: str # "consistent", "amber", "red", "no_history"
|
||||
# Quantity info
|
||||
total_quantity: Decimal | None
|
||||
occurrence_count: int
|
||||
# Links
|
||||
most_recent_invoice_id: int
|
||||
most_recent_invoice_number: str | None
|
||||
most_recent_date: date | None
|
||||
# Definition info
|
||||
has_definition: bool
|
||||
portions_per_unit: int | None
|
||||
|
||||
class LineItemSearchResponse(BaseModel):
|
||||
items: list[LineItemSearchItem]
|
||||
total_count: int
|
||||
grouped_by: str | None
|
||||
groups: list[GroupSummary] | None
|
||||
|
||||
class DefinitionSearchItem(BaseModel):
|
||||
id: int
|
||||
product_code: str | None
|
||||
description_pattern: str | None
|
||||
supplier_id: int | None
|
||||
supplier_name: str | None
|
||||
pack_quantity: int | None
|
||||
unit_size: Decimal | None
|
||||
unit_size_type: str | None
|
||||
portions_per_unit: int | None
|
||||
portion_description: str | None
|
||||
source_invoice_id: int | None
|
||||
source_invoice_number: str | None
|
||||
updated_at: datetime
|
||||
|
||||
class DefinitionSearchResponse(BaseModel):
|
||||
items: list[DefinitionSearchItem]
|
||||
total_count: int
|
||||
|
||||
class GroupSummary(BaseModel):
|
||||
name: str # Group name (supplier name, month "Jan 2024", etc.)
|
||||
count: int # Number of items in group
|
||||
total: Decimal | None # Sum of totals if applicable
|
||||
|
||||
# ============ History Modal Response ============
|
||||
class PriceHistoryPoint(BaseModel):
|
||||
date: date
|
||||
price: Decimal
|
||||
invoice_id: int
|
||||
invoice_number: str | None
|
||||
|
||||
class LineItemHistoryResponse(BaseModel):
|
||||
product_code: str | None
|
||||
description: str | None
|
||||
supplier_name: str | None
|
||||
# Price history for chart
|
||||
price_history: list[PriceHistoryPoint]
|
||||
# Stats for period
|
||||
total_occurrences: int
|
||||
total_quantity: Decimal
|
||||
avg_qty_per_invoice: Decimal
|
||||
avg_qty_per_week: Decimal
|
||||
avg_qty_per_month: Decimal
|
||||
# Current status
|
||||
current_price: Decimal | None
|
||||
price_change_status: str
|
||||
```
|
||||
|
||||
### 4. Register Router
|
||||
**File**: `backend/main.py`
|
||||
|
||||
```python
|
||||
from api import search
|
||||
app.include_router(search.router)
|
||||
```
|
||||
|
||||
### 5. Update Invoice Line Items Response
|
||||
**File**: `backend/api/invoices.py`
|
||||
|
||||
Add price status to line items when fetching invoice details:
|
||||
```python
|
||||
# In get_invoice endpoint, for each line item:
|
||||
price_status = await price_history_service.get_price_status(
|
||||
db, kitchen_id, supplier_id, product_code, description, unit_price
|
||||
)
|
||||
# Return: price_status ("consistent", "amber", "red", "no_history", "acknowledged")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### 1. Add Search Dropdown to Navigation
|
||||
**File**: `frontend/src/App.tsx`
|
||||
|
||||
Add "Search" dropdown similar to "Reports" dropdown:
|
||||
|
||||
```tsx
|
||||
// In Header component, add state:
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
|
||||
// Add showSearch check:
|
||||
const showSearch = showNavItem('/search-invoices') ||
|
||||
showNavItem('/search-line-items') ||
|
||||
showNavItem('/search-definitions')
|
||||
|
||||
// Add dropdown after Invoices link:
|
||||
{showSearch && (
|
||||
<div style={styles.dropdownContainer}
|
||||
onMouseEnter={() => setSearchOpen(true)}
|
||||
onMouseLeave={() => setSearchOpen(false)}>
|
||||
<span style={styles.navLink}>Search ▾</span>
|
||||
{searchOpen && (
|
||||
<div style={styles.dropdown}>
|
||||
{showNavItem('/search-invoices') &&
|
||||
<a href="/search/invoices" style={styles.dropdownLink}>Invoices</a>}
|
||||
{showNavItem('/search-line-items') &&
|
||||
<a href="/search/line-items" style={styles.dropdownLink}>Line Items</a>}
|
||||
{showNavItem('/search-definitions') &&
|
||||
<a href="/search/definitions" style={styles.dropdownLink}>Unit/Portion Definitions</a>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Add routes:
|
||||
```tsx
|
||||
<Route path="/search/invoices" element={...} />
|
||||
<Route path="/search/line-items" element={...} />
|
||||
<Route path="/search/definitions" element={...} />
|
||||
```
|
||||
|
||||
### 2. Create Search Components
|
||||
|
||||
#### 2a. SearchInvoices.tsx (NEW)
|
||||
**File**: `frontend/src/components/SearchInvoices.tsx`
|
||||
|
||||
**Features**:
|
||||
- Text search input (debounced 300ms for live search)
|
||||
- **☑ Include line items** checkbox - when enabled, also searches line item product_code/description
|
||||
- Supplier dropdown filter
|
||||
- Status dropdown filter
|
||||
- Date range (default: last 30 days)
|
||||
- Group by dropdown: None / Supplier / Month
|
||||
- Session storage persistence for all filters
|
||||
- Results table with columns: Invoice #, Supplier, Date, Net Total, Status
|
||||
- Invoice # links to `/invoice/{id}` (opens in new tab)
|
||||
- When grouped: collapsible sections with group headers showing count/total
|
||||
|
||||
**Layout**:
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────────┐
|
||||
│ Search Invoices │
|
||||
├───────────────────────────────────────────────────────────────────────────┤
|
||||
│ [Search input...] [☑ Include line items] [Supplier ▼] [Status ▼] [Group ▼]│
|
||||
│ From: [____] To: [____] │
|
||||
├───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Invoice # │ Supplier │ Date │ Net Total │ Status │
|
||||
│ INV-001 ↗ │ Brakes │ 15/01/2026 │ £234.50 │ confirmed │
|
||||
│ INV-002 ↗ │ Brakes │ 14/01/2026 │ £156.20 │ confirmed │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 2b. SearchLineItems.tsx (NEW)
|
||||
**File**: `frontend/src/components/SearchLineItems.tsx`
|
||||
|
||||
**Features**:
|
||||
- Text search input (debounced 300ms)
|
||||
- Supplier dropdown filter
|
||||
- Date range (default: last 30 days)
|
||||
- Group by dropdown: None / Supplier / Invoice / Month
|
||||
- Session storage persistence
|
||||
- Results show consolidated items with:
|
||||
- Product Code, Description, Supplier
|
||||
- **Most Recent Price** with price change indicator:
|
||||
- 🟢 Green tick: Price consistent with history
|
||||
- 🟡 Amber ?: Small change (≤ amber threshold %)
|
||||
- 🔴 Red !: Large change (> red threshold %)
|
||||
- No icon: First time seeing this item
|
||||
- **📊 Price History button**: Opens history modal
|
||||
- Total Qty with **📦 Qty History button**: Opens history modal
|
||||
- # Occurrences
|
||||
- Most Recent Invoice # (link to invoice)
|
||||
- Portions icon if definition exists
|
||||
|
||||
**Consolidation Logic**:
|
||||
- Group by: `COALESCE(product_code, '') || '||' || COALESCE(description, '') || '||' || supplier_id`
|
||||
- Show most recent values for each unique item
|
||||
- Aggregate: SUM(quantity), COUNT(*)
|
||||
- Compare earliest vs latest price in period for change detection
|
||||
|
||||
**Layout**:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Search Line Items │
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [Search...] [Supplier ▼] [From] [To] [Group ▼] │
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Code │ Description │ Supplier │ Price 📊 │ Qty 📦 │ # │ Invoice │
|
||||
│ ABC123 │ Chicken Breast │ Brakes │ £5.50 🟢 │ 45 │ 8 │ INV-001 ↗ │
|
||||
│ XYZ789 │ Beef Mince 500g │ Brakes │ £6.20 🔴! │ 30 │ 5 │ INV-003 ↗ │
|
||||
│ - │ Mixed Salad Bag │ Booker │ £3.20 🟡? │ 120 │12 │ INV-015 ↗ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 2c. SearchDefinitions.tsx (NEW)
|
||||
**File**: `frontend/src/components/SearchDefinitions.tsx`
|
||||
|
||||
**Features**:
|
||||
- Text search input (debounced 300ms)
|
||||
- Supplier dropdown filter
|
||||
- "Has Portions Defined" checkbox filter
|
||||
- Session storage persistence
|
||||
- Results show:
|
||||
- Product Code, Description Pattern, Supplier
|
||||
- Pack Info (e.g., "120x15g")
|
||||
- Portions per Unit
|
||||
- Last Updated, Source Invoice (link)
|
||||
|
||||
**Layout**:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Search Unit/Portion Definitions │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [Search...] [Supplier ▼] [☑ Has Portions Defined] │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Code │ Description │ Supplier │ Pack │ Portions │ Source │
|
||||
│ ABC123 │ Chicken Breast │ Brakes │ 4x2.5kg │ 40 │ INV-001 ↗ │
|
||||
│ XYZ789 │ Orange Juice 1L │ Booker │ 12x1L │ 48 │ INV-022 ↗ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. Shared Search Utilities
|
||||
**File**: `frontend/src/utils/searchHelpers.ts` (NEW)
|
||||
|
||||
```typescript
|
||||
// Session storage keys
|
||||
export const SEARCH_STORAGE_KEYS = {
|
||||
invoices: {
|
||||
query: 'search-invoices-query',
|
||||
includeLineItems: 'search-invoices-include-line-items',
|
||||
supplier: 'search-invoices-supplier',
|
||||
status: 'search-invoices-status',
|
||||
dateFrom: 'search-invoices-from',
|
||||
dateTo: 'search-invoices-to',
|
||||
groupBy: 'search-invoices-group',
|
||||
},
|
||||
lineItems: { ... },
|
||||
definitions: { ... },
|
||||
}
|
||||
|
||||
// Debounce hook for live search
|
||||
export function useDebounce<T>(value: T, delay: number): T
|
||||
|
||||
// Date helpers
|
||||
export function getDefaultDateRange(): { from: string, to: string }
|
||||
export function formatDateForDisplay(date: string): string
|
||||
```
|
||||
|
||||
### 4. Line Item History Modal (Reusable)
|
||||
**File**: `frontend/src/components/LineItemHistoryModal.tsx` (NEW)
|
||||
|
||||
A reusable modal component used by:
|
||||
- SearchLineItems.tsx (📊 and 📦 buttons)
|
||||
- Review.tsx (price indicator clicks)
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface LineItemHistoryModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
productCode: string | null
|
||||
description: string | null
|
||||
supplierId: number
|
||||
supplierName: string
|
||||
currentPrice?: Decimal // For highlighting current price in chart
|
||||
onAcknowledge?: (newPrice: Decimal) => void // Callback when price acknowledged
|
||||
}
|
||||
```
|
||||
|
||||
**Layout**:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Price History: Chicken Breast (ABC123) [X] │
|
||||
│ Supplier: Brakes │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Date Range: [From: ____] [To: ____] (default: last 12 months) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📈 Price History Chart (Line chart with dates on X axis) │
|
||||
│ £6.00 ─────────────────────• │
|
||||
│ £5.50 ────•────────────────┘ │
|
||||
│ £5.00 ────┘ │
|
||||
│ Jan Feb Mar Apr May │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Stats for Period: │
|
||||
│ • Total Occurrences: 12 │
|
||||
│ • Total Quantity: 45 │
|
||||
│ • Avg Qty per Invoice: 3.75 │
|
||||
│ • Avg Qty per Week: 1.2 │
|
||||
│ • Avg Qty per Month: 5.0 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Current Price: £6.00 (🔴 +9% from previous £5.50) │
|
||||
│ │
|
||||
│ [Acknowledge Price Change] ← Only shown if price flagged │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Chart Library**: Use `recharts` (lightweight, React-native)
|
||||
|
||||
### 5. Invoice Review Price Indicators
|
||||
**File**: `frontend/src/components/Review.tsx` (MODIFY)
|
||||
|
||||
Add price status indicators next to each line item's unit price:
|
||||
|
||||
**Per Line Item**:
|
||||
- Fetch price_status from backend (included in line item response)
|
||||
- Display icon next to unit_price:
|
||||
- 🟢 ✓ (green): Price consistent with history
|
||||
- 🟡 ? (amber): Small change ≤ threshold
|
||||
- 🔴 ! (red): Large change > threshold
|
||||
- No icon: No history (first purchase)
|
||||
- Clicking icon opens LineItemHistoryModal
|
||||
- In modal, "Acknowledge Price Change" button updates AcknowledgedPrice record
|
||||
|
||||
**Layout Change in Line Items Table**:
|
||||
```
|
||||
│ Code │ Description │ Qty │ Unit Price │ Amount │
|
||||
│ ABC123 │ Chicken Breast │ 4 │ £5.50 🟢 │ £22.00 │
|
||||
│ XYZ789 │ Beef Mince 500g │ 2 │ £6.20 🔴! [📊] │ £12.40 │
|
||||
↑ Click to see history
|
||||
```
|
||||
|
||||
### 6. Search Settings Section
|
||||
**File**: `frontend/src/pages/Settings.tsx` (MODIFY)
|
||||
|
||||
Add new "Search Settings" section in Settings page:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Search Settings │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Price Change Detection │
|
||||
│ │
|
||||
│ Lookback Period: [30] days │
|
||||
│ (How far back to compare prices) │
|
||||
│ │
|
||||
│ Amber Threshold: [10] % │
|
||||
│ (Highlight as warning if change ≤ this) │
|
||||
│ │
|
||||
│ Red Threshold: [20] % │
|
||||
│ (Highlight as alert if change > amber) │
|
||||
│ │
|
||||
│ [Save] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| **Backend** | |
|
||||
| `backend/models/acknowledged_price.py` | NEW - AcknowledgedPrice model |
|
||||
| `backend/models/settings.py` | Add price change threshold settings |
|
||||
| `backend/services/price_history.py` | NEW - Reusable price history service |
|
||||
| `backend/api/search.py` | NEW - Search endpoints + history + acknowledge |
|
||||
| `backend/api/invoices.py` | Add price_status to line items response |
|
||||
| `backend/main.py` | Register search router, run migration |
|
||||
| `backend/migrations/add_price_settings.py` | NEW - Migration for new settings + acknowledged_prices table |
|
||||
| **Frontend** | |
|
||||
| `frontend/package.json` | Add recharts dependency |
|
||||
| `frontend/src/App.tsx` | Add Search dropdown + routes |
|
||||
| `frontend/src/utils/searchHelpers.ts` | NEW - Debounce hook, session storage keys |
|
||||
| `frontend/src/components/SearchInvoices.tsx` | NEW - Invoice search page |
|
||||
| `frontend/src/components/SearchLineItems.tsx` | NEW - Line items search with price flags |
|
||||
| `frontend/src/components/SearchDefinitions.tsx` | NEW - Definitions search page |
|
||||
| `frontend/src/components/LineItemHistoryModal.tsx` | NEW - Reusable history modal with chart |
|
||||
| `frontend/src/components/Review.tsx` | Add price status icons to line items |
|
||||
| `frontend/src/pages/Settings.tsx` | Add Search Settings section + Access Control paths |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: Backend Foundation
|
||||
1. Create `backend/models/acknowledged_price.py`
|
||||
2. Add price change settings to `backend/models/settings.py`
|
||||
3. Create migration `backend/migrations/add_price_settings.py`
|
||||
4. Create `backend/services/price_history.py` service
|
||||
5. Create `backend/api/search.py` with all endpoints
|
||||
6. Update `backend/api/invoices.py` to include price_status
|
||||
7. Register router in `backend/main.py`
|
||||
|
||||
### Phase 2: Frontend Search Pages
|
||||
8. Install recharts: `npm install recharts`
|
||||
9. Create `frontend/src/utils/searchHelpers.ts`
|
||||
10. Create `SearchInvoices.tsx`
|
||||
11. Create `SearchLineItems.tsx`
|
||||
12. Create `SearchDefinitions.tsx`
|
||||
|
||||
### Phase 3: History Modal & Review Integration
|
||||
13. Create `LineItemHistoryModal.tsx` with chart
|
||||
14. Update `Review.tsx` with price status icons
|
||||
15. Add "Search Settings" section to Settings.tsx
|
||||
16. Add Search access control paths to Settings.tsx
|
||||
17. Update `App.tsx` with Search dropdown and routes
|
||||
|
||||
### Phase 4: Test & Verify
|
||||
18. Rebuild containers
|
||||
19. Test all search pages
|
||||
20. Test price change detection on invoice review
|
||||
21. Test acknowledgement flow
|
||||
|
||||
---
|
||||
|
||||
## Access Control Paths
|
||||
|
||||
Add to Settings Access Control checkboxes:
|
||||
- `/search-invoices` - Invoice Search
|
||||
- `/search-line-items` - Line Items Search
|
||||
- `/search-definitions` - Unit/Portion Definitions Search
|
||||
|
||||
---
|
||||
|
||||
## Session Storage Keys
|
||||
|
||||
All search state persists during browser session:
|
||||
|
||||
**Invoices**:
|
||||
- `search-invoices-query`, `search-invoices-supplier`, `search-invoices-status`
|
||||
- `search-invoices-from`, `search-invoices-to`, `search-invoices-group`
|
||||
|
||||
**Line Items**:
|
||||
- `search-line-items-query`, `search-line-items-supplier`
|
||||
- `search-line-items-from`, `search-line-items-to`, `search-line-items-group`
|
||||
|
||||
**Definitions**:
|
||||
- `search-definitions-query`, `search-definitions-supplier`, `search-definitions-has-portions`
|
||||
|
||||
---
|
||||
|
||||
## Live Search Implementation
|
||||
|
||||
Use debounced input (300ms delay) with react-query:
|
||||
|
||||
```typescript
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const debouncedSearch = useDebounce(searchInput, 300)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['search-invoices', debouncedSearch, supplier, status, dateFrom, dateTo, groupBy],
|
||||
queryFn: () => fetchSearchResults(...)
|
||||
})
|
||||
```
|
||||
|
||||
Filter changes trigger immediate re-query (no debounce needed for dropdowns).
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Search Pages
|
||||
1. **Navigation**: Search dropdown appears between Invoices and Reports
|
||||
2. **Invoice Search**:
|
||||
- Type in search box, results filter after 300ms
|
||||
- "Include line items" checkbox finds invoices by product names
|
||||
- Filter by supplier/status works
|
||||
- Date range defaults to last 30 days
|
||||
- Group by Supplier shows collapsible sections
|
||||
- Invoice # links open invoice in new tab
|
||||
3. **Line Items Search**:
|
||||
- Consolidated view shows unique items (not duplicates)
|
||||
- Shows most recent price with change indicator (🟢/🟡/🔴)
|
||||
- 📊 button opens history modal with price chart
|
||||
- 📦 button opens history modal with qty stats
|
||||
- Links to most recent invoice
|
||||
4. **Definitions Search**:
|
||||
- Shows all ProductDefinition records
|
||||
- "Has Portions" filter works
|
||||
- Links to source invoice
|
||||
|
||||
### Price Change Detection
|
||||
5. **Invoice Review Page**:
|
||||
- Line items show price status icon next to unit price
|
||||
- 🟢 = consistent, 🟡 = small change, 🔴 = large change
|
||||
- Clicking icon opens history modal
|
||||
- "Acknowledge Price Change" button marks price as reviewed
|
||||
- After acknowledgement, icon changes to 🟢 on future invoices
|
||||
|
||||
### History Modal
|
||||
6. **Price History Chart**:
|
||||
- Line chart shows price over time
|
||||
- Default range: last 12 months
|
||||
- Date range picker works
|
||||
7. **Stats Display**:
|
||||
- Total occurrences, total qty
|
||||
- Avg qty per invoice/week/month
|
||||
|
||||
### Settings
|
||||
8. **Search Settings Section**:
|
||||
- Lookback period (default 30 days)
|
||||
- Amber threshold % (default 10%)
|
||||
- Red threshold % (default 20%)
|
||||
- Changes apply to price detection
|
||||
|
||||
### Session Persistence
|
||||
9. Enter search term, navigate away, come back - search term preserved
|
||||
10. Close tab, reopen - state cleared (session storage)
|
||||
|
||||
### Access Control
|
||||
11. Restrict search pages via Settings → Access Control
|
||||
12. Non-admin users don't see restricted search options
|
||||
Loading…
Add table
Add a link
Reference in a new issue