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' && (
Ακύρωση
- setTab('checks')} style={{
- height: 40, padding: '0 20px', borderRadius: 9,
- background: '#111315', border: 'none', color: 'white',
- fontSize: 13, fontWeight: 700, cursor: 'pointer',
- }}>Προχωρήστε στους Ελέγχους →
+ {tab !== 'cash' ? (
+ setTab('cash')} style={{
+ height: 40, padding: '0 20px', borderRadius: 9,
+ background: '#111315', border: 'none', color: 'white',
+ fontSize: 13, fontWeight: 700, cursor: 'pointer',
+ }}>Μέτρηση Ταμείου →
+ ) : (
+ setTab('checks')} style={{
+ height: 40, padding: '0 20px', borderRadius: 9,
+ background: '#111315', border: 'none', color: 'white',
+ fontSize: 13, fontWeight: 700, cursor: 'pointer',
+ }}>Προχωρήστε στους Ελέγχους →
+ )}
)}
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)}
-
-
- Σημειώσεις (προαιρετικό)
-
-
-
Μετά την επιβεβαίωση θα εμφανιστεί η αναλυτική σύνοψη βάρδιας.
-
- Άκυρο
- onConfirm(notes)} disabled={busy}
- className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
- {busy ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'}
-
-
+
+ {/* Step 1: notes */}
+ {step === 1 && (
+ <>
+
+
+ Σημειώσεις (προαιρετικό)
+
+
+
+ Άκυρο
+ setStep(2)} className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700">
+ Συνέχεια →
+
+
+ >
+ )}
+
+ {/* Step 2: cash handover */}
+ {step === 2 && (
+ <>
+
+
+ Πόσα μετρητά παραδίδει ο σερβιτόρος; (προαιρετικό)
+
+
+ Αναμενόμενη παράδοση:
+ {fmtEuro(expectedHandover)}
+
+
setCountedCash(e.target.value)}
+ placeholder={fmtEuro(expectedHandover).replace('€', '')}
+ autoFocus
+ style={{ width: '100%', borderRadius: 8, border: '1px solid #e5e7eb', padding: '8px 10px', fontSize: 14, fontWeight: 600, color: '#111315', outline: 'none', boxSizing: 'border-box' }}
+ />
+ {/* Live discrepancy feedback */}
+ {discrepancy != null && (
+
+ {isClean && '✓ Ταμείο εντάξει — παραδίδει το σωστό ποσό'}
+ {isShort && `⚠ Ελλείμμα ${fmtEuro(Math.abs(discrepancy))} — ο σερβιτόρος παραδίδει λιγότερα`}
+ {isOver && `⚠ Πλεόνασμα ${fmtEuro(Math.abs(discrepancy))} — ο σερβιτόρος παραδίδει περισσότερα`}
+
+ )}
+
+
+ setStep(1)} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">← Πίσω
+ onConfirm(notes, countedCash !== '' ? parseFloat(countedCash) : null)}
+ disabled={busy}
+ className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60"
+ >
+ {busy ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'}
+
+
+ >
+ )}
)
@@ -1593,11 +1662,14 @@ export default function DashboardPage() {
openDayMut.mutate()
}
- async function handleEndShiftConfirm(notes) {
+ async function handleEndShiftConfirm(notes, countedCashEnd) {
if (!endShiftTarget) return
setEndShiftBusy(true)
try {
- await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, { notes: notes || null })
+ await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, {
+ notes: notes || null,
+ counted_cash_end: countedCashEnd ?? null,
+ })
qc.invalidateQueries({ queryKey: ['active-shifts'] })
const summaryTarget = { id: endShiftTarget.id, waiter_id: endShiftTarget.waiter_id }
setEndShiftTarget(null)
diff --git a/manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx b/manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx
index 513bfc2..187f59f 100644
--- a/manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx
+++ b/manager_dashboard/src/pages/reports/shared/ShiftDetailModal.jsx
@@ -192,11 +192,23 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
{ label: 'Πληρωμή Βάρδιας', value: summary.shift_pay != null ? fmtEUR(summary.shift_pay) : 'Δεν παρακολουθείται', accent: summary.shift_pay != null ? '#059669' : undefined },
{ label: 'Αρχικά Μετρητά', value: summary.starting_cash != null ? fmtEUR(summary.starting_cash) : '—' },
]
+ const discrepancy = summary.cash_discrepancy
+ const discLabel = discrepancy == null
+ ? '—'
+ : Math.abs(discrepancy) < 0.005
+ ? '✓ Εντάξει'
+ : discrepancy < 0
+ ? `Έλλειμμα ${fmtEUR(Math.abs(discrepancy))}`
+ : `Πλεόνασμα ${fmtEUR(discrepancy)}`
+ const discAccent = discrepancy == null
+ ? undefined
+ : Math.abs(discrepancy) < 0.005 ? '#16a34a' : '#dc2626'
+
const row2 = [
{ label: 'Σύνολο Παραγγελιών', value: String(totalOrders) },
{ label: 'Εισπράξεις', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' },
- { label: 'Προς Παράδοση', value: fmtEUR(summary.net_to_deliver), accent: '#3758c9' },
- { label: 'Εξ. Τραπεζιών', value: String(tablesServed) },
+ { label: 'Παραδόθηκαν', value: summary.counted_cash_end != null ? fmtEUR(summary.counted_cash_end) : '—', accent: '#3758c9' },
+ { label: 'Ταμείο', value: discLabel, accent: discAccent },
]
return (
diff --git a/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx b/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx
index 20bedd2..5faf824 100644
--- a/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx
+++ b/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx
@@ -124,6 +124,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
Αρχικά Μετρητά
Εισπράχθηκαν
Οφείλει
+
Ταμείο
Κατάσταση
@@ -158,6 +159,24 @@ export default function ShiftsOverview({ onNavigate } = {}) {
{fmtEUR(s.starting_cash)}
{fmtEUR(s.total_collected)}
{fmtEUR(s.net_to_deliver)}
+
+ {s.counted_cash_end != null ? (
+
+ {fmtEUR(s.counted_cash_end)}
+ {s.cash_discrepancy != null && Math.abs(s.cash_discrepancy) >= 0.005 && (
+
+ {s.cash_discrepancy > 0 ? '+' : ''}{fmtEUR(s.cash_discrepancy)}
+
+ )}
+
+ ) : (
+ —
+ )}
+
@@ -182,7 +201,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
,
isOpen && (
-
+
{'Εργάσιμη Μέρα: '}