diff --git a/local_backend/main.py b/local_backend/main.py index b6b08ce..db20c2e 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -273,6 +273,12 @@ def _run_migrations(): # Phase 2B — staff payroll "ALTER TABLE users ADD COLUMN hourly_rate REAL", "ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL", + # Phase 2E — cash drawer reconciliation + "ALTER TABLE waiter_shifts ADD COLUMN counted_cash_end REAL", + "ALTER TABLE waiter_shifts ADD COLUMN cash_discrepancy REAL", + "ALTER TABLE business_days ADD COLUMN store_opening_cash REAL", + "ALTER TABLE business_days ADD COLUMN store_closing_cash REAL", + "ALTER TABLE business_days ADD COLUMN store_cash_discrepancy REAL", # Phase 2D — expense tracking + supplier contacts """CREATE TABLE IF NOT EXISTS contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/local_backend/models/business_day.py b/local_backend/models/business_day.py index 393037d..edea5dc 100644 --- a/local_backend/models/business_day.py +++ b/local_backend/models/business_day.py @@ -18,6 +18,10 @@ class BusinessDay(Base): closed_at = Column(DateTime(timezone=True), nullable=True) closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) notes = Column(Text, nullable=True) + # Phase 2E — store-level cash reconciliation + store_opening_cash = Column(Float, nullable=True) # physical cash when day opens + store_closing_cash = Column(Float, nullable=True) # physical cash counted at close + store_cash_discrepancy = Column(Float, nullable=True) # store_closing - expected opener = relationship("User", foreign_keys=[opened_by_id]) closer = relationship("User", foreign_keys=[closed_by_id]) diff --git a/local_backend/models/shift.py b/local_backend/models/shift.py index 0a7a96e..52c7e62 100644 --- a/local_backend/models/shift.py +++ b/local_backend/models/shift.py @@ -21,6 +21,9 @@ class WaiterShift(Base): notes = Column(Text, nullable=True) # Phase 2B — payroll snapshot (copied from user.hourly_rate at shift start, never updated) hourly_rate_snapshot = Column(Float, nullable=True) + # Phase 2E — cash reconciliation + counted_cash_end = Column(Float, nullable=True) # physical cash waiter hands over + cash_discrepancy = Column(Float, nullable=True) # counted_cash_end - expected; neg = short waiter = relationship("User", foreign_keys=[waiter_id]) business_day = relationship("BusinessDay", back_populates="shifts") diff --git a/local_backend/routers/business_day.py b/local_backend/routers/business_day.py index 56e8ece..7566267 100644 --- a/local_backend/routers/business_day.py +++ b/local_backend/routers/business_day.py @@ -150,6 +150,15 @@ def close_business_day( if body.notes: day.notes = body.notes + # Phase 2E: store-level cash reconciliation + if body.store_closing_cash is not None: + day.store_closing_cash = body.store_closing_cash + # expected = store_opening_cash + sum of all waiter collected amounts this day + all_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == day.id).all() + total_collected = sum(s.total_collected or 0.0 for s in all_shifts) + expected_closing = (day.store_opening_cash or 0.0) + total_collected + day.store_cash_discrepancy = round(body.store_closing_cash - expected_closing, 2) + db.commit() db.refresh(day) @@ -409,6 +418,10 @@ def business_day_summary( for key in ("total_collected", "total_items_value", "cash", "card", "transfer", "treat", "other"): w[key] = round(w[key], 2) + # Phase 2E: store-level cash totals for summary view + total_waiter_collected = sum(s.total_collected or 0.0 for s in shifts) + store_expected_closing = (day.store_opening_cash or 0.0) + total_waiter_collected + return { "day_id": day.id, "status": day.status, @@ -425,6 +438,12 @@ def business_day_summary( "with_unpaid_items": with_unpaid_items, "active_shifts": active_shift_count, }, + # Phase 2E + "store_opening_cash": day.store_opening_cash, + "store_closing_cash": day.store_closing_cash, + "store_cash_discrepancy": day.store_cash_discrepancy, + "store_expected_closing": round(store_expected_closing, 2), + "total_waiter_collected": round(total_waiter_collected, 2), } diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py index 55be471..97ab09b 100644 --- a/local_backend/routers/reports.py +++ b/local_backend/routers/reports.py @@ -1495,6 +1495,8 @@ def shifts_export( "duration_hours": s.get("duration_hours", ""), "hourly_rate": s.get("hourly_rate_snapshot", ""), "shift_pay": s.get("shift_pay", ""), + "counted_cash_end": s.get("counted_cash_end", ""), + "cash_discrepancy": s.get("cash_discrepancy", ""), "starting_cash": s["starting_cash"], "total_collected": s["total_collected"], "net_to_deliver": s["net_to_deliver"], diff --git a/local_backend/routers/shifts.py b/local_backend/routers/shifts.py index 524e092..ecffaba 100644 --- a/local_backend/routers/shifts.py +++ b/local_backend/routers/shifts.py @@ -90,6 +90,9 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict: "hourly_rate_snapshot": shift.hourly_rate_snapshot, "duration_hours": pay_data["duration_hours"], "shift_pay": pay_data["shift_pay"], + # Phase 2E + "counted_cash_end": shift.counted_cash_end, + "cash_discrepancy": shift.cash_discrepancy, "breaks": [ {"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)} for b in shift.breaks @@ -166,10 +169,16 @@ def end_shift( raise HTTPException(status_code=404, detail="No active shift found") now = datetime.now(timezone.utc) - shift.total_collected = compute_shift_total(shift.id, db) + total = compute_shift_total(shift.id, db) + shift.total_collected = total shift.ended_at = now if body.notes: shift.notes = body.notes + # Phase 2E: cash reconciliation + if body.counted_cash_end is not None: + shift.counted_cash_end = body.counted_cash_end + expected = (shift.starting_cash or 0.0) + total + shift.cash_discrepancy = round(body.counted_cash_end - expected, 2) open_break = db.query(ShiftBreak).filter( ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None @@ -233,10 +242,16 @@ def manager_end_shift( raise HTTPException(status_code=404, detail="Active shift not found") now = datetime.now(timezone.utc) - shift.total_collected = compute_shift_total(shift.id, db) + total = compute_shift_total(shift.id, db) + shift.total_collected = total shift.ended_at = now if body.notes: shift.notes = body.notes + # Phase 2E: cash reconciliation + if body.counted_cash_end is not None: + shift.counted_cash_end = body.counted_cash_end + expected = (shift.starting_cash or 0.0) + total + shift.cash_discrepancy = round(body.counted_cash_end - expected, 2) open_break = db.query(ShiftBreak).filter( ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None diff --git a/local_backend/schemas/business_day.py b/local_backend/schemas/business_day.py index 57abc6d..482c0cc 100644 --- a/local_backend/schemas/business_day.py +++ b/local_backend/schemas/business_day.py @@ -11,6 +11,10 @@ class BusinessDayOut(BaseModel): closed_at: Optional[UTCDatetime] = None closed_by_id: Optional[int] = None notes: Optional[str] = None + # Phase 2E + store_opening_cash: Optional[float] = None + store_closing_cash: Optional[float] = None + store_cash_discrepancy: Optional[float] = None model_config = {"from_attributes": True} @@ -22,3 +26,4 @@ class OpenBusinessDayRequest(BaseModel): class CloseBusinessDayRequest(BaseModel): force: bool = False notes: Optional[str] = None + store_closing_cash: Optional[float] = None # Phase 2E: physical cash counted at close diff --git a/local_backend/schemas/shift.py b/local_backend/schemas/shift.py index 8c50658..95fd374 100644 --- a/local_backend/schemas/shift.py +++ b/local_backend/schemas/shift.py @@ -36,3 +36,4 @@ class StartShiftRequest(BaseModel): class EndShiftRequest(BaseModel): notes: Optional[str] = None + counted_cash_end: Optional[float] = None # Phase 2E: physical cash waiter hands over diff --git a/manager_dashboard/src/components/WorkdaySummaryModal.jsx b/manager_dashboard/src/components/WorkdaySummaryModal.jsx index 1701e03..edb9948 100644 --- a/manager_dashboard/src/components/WorkdaySummaryModal.jsx +++ b/manager_dashboard/src/components/WorkdaySummaryModal.jsx @@ -418,6 +418,84 @@ function PrintersTab({ data }) { ) } +function CashReconciliationTab({ data, storeCash, onStoreCashChange }) { + const expected = data.store_expected_closing ?? 0 + const counted = storeCash !== '' ? parseFloat(storeCash) : null + const discrepancy = counted != null ? counted - expected : null + const isShort = discrepancy != null && discrepancy < -0.005 + const isOver = discrepancy != null && discrepancy > 0.005 + const isClean = discrepancy != null && !isShort && !isOver + + return ( +
+
+
Σύνοψη Εισπράξεων Βάρδιας
+
+
+ Σύνολο εισπράξεων σερβιτόρων + {fmt(data.total_waiter_collected)} +
+ {data.store_opening_cash != null && ( +
+ Αρχικό ταμείο + {fmt(data.store_opening_cash)} +
+ )} +
+ Αναμενόμενο ταμείο τώρα + {fmt(expected)} +
+
+
+ +
+ + onStoreCashChange(e.target.value)} + placeholder="0.00" + style={{ + width: '100%', padding: '10px 14px', border: '1.5px solid #e5e7eb', + borderRadius: 10, fontSize: 18, fontWeight: 700, color: '#111315', + outline: 'none', boxSizing: 'border-box', background: 'white', + }} + /> +
+ + {discrepancy != null && ( +
+
+ {isClean && '✓ Ταμείο εντάξει'} + {isShort && `⚠ Έλλειμμα ${fmt(Math.abs(discrepancy))}`} + {isOver && `⚠ Πλεόνασμα ${fmt(Math.abs(discrepancy))}`} +
+
+ {isClean && 'Το φυσικό ταμείο συμφωνεί με τις καταγεγραμμένες εισπράξεις.'} + {isShort && `Το ταμείο έχει ${fmt(Math.abs(discrepancy))} λιγότερα από το αναμενόμενο. Το κενό δεν αποδίδεται σε κάποιον σερβιτόρο συγκεκριμένα.`} + {isOver && `Το ταμείο έχει ${fmt(Math.abs(discrepancy))} περισσότερα από το αναμενόμενο.`} +
+
+ )} + + {storeCash === '' && ( +

+ Αν παραλείψετε τη μέτρηση, η επαλήθευση δεν θα καταγραφεί. +

+ )} +
+ ) +} + function ChecksTab({ data, onForceClose, closing, isOpen }) { const { checks } = data const allGood = checks.open_orders === 0 @@ -524,6 +602,7 @@ function ChecksTab({ data, onForceClose, closing, isOpen }) { export default function WorkdaySummaryModal({ dayId, onClose, showCloseAction = false, onDayClosed }) { const [tab, setTab] = useState('overview') const [closing, setClosing] = useState(false) + const [storeCash, setStoreCash] = useState('') // Phase 2E: store cash count const { data, isLoading, error } = useQuery({ queryKey: ['workday-summary', dayId ?? 'current'], @@ -534,7 +613,9 @@ export default function WorkdaySummaryModal({ dayId, onClose, showCloseAction = async function handleClose(force = false) { setClosing(true) try { - await client.post('/api/business-day/close', { force }) + const payload = { force } + if (storeCash !== '') payload.store_closing_cash = parseFloat(storeCash) + await client.post('/api/business-day/close', payload) onDayClosed?.() } catch (e) { const detail = e.response?.data?.detail @@ -553,11 +634,14 @@ export default function WorkdaySummaryModal({ dayId, onClose, showCloseAction = { id: 'waiters', label: 'Προσωπικό', badge: data?.waiters?.length }, { id: 'zones', label: 'Ζώνες', badge: data?.zones?.length }, { id: 'printers', label: 'Εκτυπωτές', badge: data?.printers?.length }, - ...(showCloseAction ? [{ - id: 'checks', label: 'Έλεγχοι', - badge: data?.checks ? (data.checks.open_orders + data.checks.active_shifts) : null, - warn: data?.checks?.with_unpaid_items > 0, - }] : []), + ...(showCloseAction ? [ + { id: 'cash', label: 'Ταμείο' }, + { + id: 'checks', label: 'Έλεγχοι', + badge: data?.checks ? (data.checks.open_orders + data.checks.active_shifts) : null, + warn: data?.checks?.with_unpaid_items > 0, + }, + ] : []), ] const isOpen = data?.status === 'open' @@ -643,6 +727,13 @@ export default function WorkdaySummaryModal({ dayId, onClose, showCloseAction = {tab === 'waiters' && } {tab === 'zones' && } {tab === 'printers' && } + {tab === 'cash' && ( + + )} {tab === 'checks' && ( Ακύρωση - + {tab !== 'cash' ? ( + + ) : ( + + )} )} diff --git a/manager_dashboard/src/pages/DashboardPage.jsx b/manager_dashboard/src/pages/DashboardPage.jsx index d717536..880f938 100644 --- a/manager_dashboard/src/pages/DashboardPage.jsx +++ b/manager_dashboard/src/pages/DashboardPage.jsx @@ -548,53 +548,122 @@ function TableChip({ name, status, amount, onClick }) { // ─── End shift confirmation modal ───────────────────────────────────────────── function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) { + const [step, setStep] = useState(1) // 1 = notes, 2 = cash handover const [notes, setNotes] = useState('') + const [countedCash, setCountedCash] = useState('') + + const expectedHandover = (shift.starting_cash || 0) + (shift.total_collected || 0) + const counted = countedCash !== '' ? parseFloat(countedCash) : null + const discrepancy = counted != null ? counted - expectedHandover : null + const isShort = discrepancy != null && discrepancy < -0.005 + const isOver = discrepancy != null && discrepancy > 0.005 + const isClean = discrepancy != null && !isShort && !isOver + return (
{ if (e.target === e.currentTarget) onClose() }}>
+ {/* Header */}
-
Τέλος βάρδιας;
+
Τέλος βάρδιας
{shift.waiter_name}
+
+ {[1, 2].map(n => ( +
= n ? '#3758c9' : '#e5e7eb' }} /> + ))} +
-
-
+ + {/* Shift summary always visible */} +
+
Έναρξη{fmtTime(shift.started_at)}
-
+
Διάρκεια{fmtDuration(shift.started_at)}
Είσπραξη{fmtEuro(shift.total_collected)}
-
- -