feat: Phase 2E — cash drawer reconciliation
Backend:
- WaiterShift: add counted_cash_end (physical cash handed over) + cash_discrepancy (computed)
- BusinessDay: add store_opening_cash, store_closing_cash, store_cash_discrepancy
- EndShiftRequest: accept counted_cash_end (optional)
- CloseBusinessDayRequest: accept store_closing_cash (optional)
- Shift end (both /end and /manager/end/{id}): compute cash_discrepancy =
counted_cash_end - (starting_cash + total_collected); negative = short, positive = over
- Business day close: compute store_cash_discrepancy = store_closing_cash - expected;
expected = store_opening_cash + sum(shift.total_collected for day)
- _enrich_shift: expose counted_cash_end + cash_discrepancy in all shift responses
- Business day summary: expose store cash fields + store_expected_closing + total_waiter_collected
- BusinessDayOut schema: expose new store cash fields
- Shifts CSV export: add counted_cash_end + cash_discrepancy columns
Frontend:
- EndShiftConfirmModal: 2-step flow — step 1 notes, step 2 cash handover with live discrepancy
(shows ✓ clean / ⚠ short / ⚠ over in real time before confirming)
- WorkdaySummaryModal close flow: new Ταμείο tab with store cash count + live discrepancy;
footer navigates overview → Ταμείο → Έλεγχοι; store_closing_cash included in close payload
- ShiftsOverview report: Ταμείο column showing counted_cash_end + discrepancy badge
- ShiftDetailModal row 2: replaces Εξ.Τραπεζιών with Παραδόθηκαν + Ταμείο discrepancy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, padding: '16px 18px', background: '#f9fafb' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>Σύνοψη Εισπράξεων Βάρδιας</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 13.5 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#6b7280' }}>Σύνολο εισπράξεων σερβιτόρων</span>
|
||||
<span style={{ fontWeight: 700, color: '#111315' }}>{fmt(data.total_waiter_collected)}</span>
|
||||
</div>
|
||||
{data.store_opening_cash != null && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#6b7280' }}>Αρχικό ταμείο</span>
|
||||
<span style={{ fontWeight: 600, color: '#111315' }}>{fmt(data.store_opening_cash)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid #e5e7eb', paddingTop: 6, marginTop: 2 }}>
|
||||
<span style={{ color: '#374151', fontWeight: 600 }}>Αναμενόμενο ταμείο τώρα</span>
|
||||
<span style={{ fontWeight: 800, color: '#111315', fontSize: 15 }}>{fmt(expected)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
|
||||
Μετρήστε το ταμείο — πόσα μετρητά υπάρχουν;
|
||||
<span style={{ fontWeight: 400, color: '#9ca3af' }}> (προαιρετικό)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={storeCash}
|
||||
onChange={e => 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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{discrepancy != null && (
|
||||
<div style={{
|
||||
padding: '14px 16px', borderRadius: 10,
|
||||
background: isClean ? '#f0fdf4' : '#fef2f2',
|
||||
border: `1.5px solid ${isClean ? '#86efac' : '#fca5a5'}`,
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: isClean ? '#16a34a' : '#dc2626', marginBottom: 4 }}>
|
||||
{isClean && '✓ Ταμείο εντάξει'}
|
||||
{isShort && `⚠ Έλλειμμα ${fmt(Math.abs(discrepancy))}`}
|
||||
{isOver && `⚠ Πλεόνασμα ${fmt(Math.abs(discrepancy))}`}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: isClean ? '#166534' : '#7f1d1d', lineHeight: 1.5 }}>
|
||||
{isClean && 'Το φυσικό ταμείο συμφωνεί με τις καταγεγραμμένες εισπράξεις.'}
|
||||
{isShort && `Το ταμείο έχει ${fmt(Math.abs(discrepancy))} λιγότερα από το αναμενόμενο. Το κενό δεν αποδίδεται σε κάποιον σερβιτόρο συγκεκριμένα.`}
|
||||
{isOver && `Το ταμείο έχει ${fmt(Math.abs(discrepancy))} περισσότερα από το αναμενόμενο.`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storeCash === '' && (
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0 }}>
|
||||
Αν παραλείψετε τη μέτρηση, η επαλήθευση δεν θα καταγραφεί.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 ? [{
|
||||
...(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' && <WaitersTab data={data} />}
|
||||
{tab === 'zones' && <ZonesTab data={data} />}
|
||||
{tab === 'printers' && <PrintersTab data={data} />}
|
||||
{tab === 'cash' && (
|
||||
<CashReconciliationTab
|
||||
data={data}
|
||||
storeCash={storeCash}
|
||||
onStoreCashChange={setStoreCash}
|
||||
/>
|
||||
)}
|
||||
{tab === 'checks' && (
|
||||
<ChecksTab
|
||||
data={data}
|
||||
@@ -666,11 +757,19 @@ export default function WorkdaySummaryModal({ dayId, onClose, showCloseAction =
|
||||
border: '1px solid #e5e7eb', background: 'white',
|
||||
fontSize: 13, fontWeight: 600, cursor: 'pointer', color: '#374151',
|
||||
}}>Ακύρωση</button>
|
||||
{tab !== 'cash' ? (
|
||||
<button onClick={() => setTab('cash')} style={{
|
||||
height: 40, padding: '0 20px', borderRadius: 9,
|
||||
background: '#111315', border: 'none', color: 'white',
|
||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
}}>Μέτρηση Ταμείου →</button>
|
||||
) : (
|
||||
<button onClick={() => setTab('checks')} style={{
|
||||
height: 40, padding: '0 20px', borderRadius: 9,
|
||||
background: '#111315', border: 'none', color: 'white',
|
||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
}}>Προχωρήστε στους Ελέγχους →</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -548,31 +548,53 @@ 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 (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div style={{ width: 40, height: 40, borderRadius: '50%', background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 18 }}>⏹</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>Τέλος βάρδιας;</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>Τέλος βάρδιας</div>
|
||||
<div style={{ fontSize: 13, color: '#5a6169', marginTop: 2 }}>{shift.waiter_name}</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
||||
{[1, 2].map(n => (
|
||||
<div key={n} style={{ width: 8, height: 8, borderRadius: '50%', background: step >= n ? '#3758c9' : '#e5e7eb' }} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ background: '#fafafa', borderRadius: 12, padding: '12px 16px', fontSize: 13, color: '#374151' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
</div>
|
||||
|
||||
{/* Shift summary always visible */}
|
||||
<div style={{ background: '#fafafa', borderRadius: 12, padding: '10px 14px', fontSize: 13, color: '#374151' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<span>Έναρξη</span><span style={{ fontWeight: 600 }}>{fmtTime(shift.started_at)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<span>Διάρκεια</span><span style={{ fontWeight: 600 }}>{fmtDuration(shift.started_at)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Είσπραξη</span><span style={{ fontWeight: 700, color: '#2f9e5e' }}>{fmtEuro(shift.total_collected)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: notes */}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
|
||||
Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
|
||||
@@ -583,18 +605,65 @@ function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
|
||||
placeholder="π.χ. παρατηρήσεις για τη βάρδια…"
|
||||
rows={2}
|
||||
style={{ width: '100%', borderRadius: 8, border: '1px solid #e5e7eb', padding: '8px 10px', fontSize: 13, color: '#111315', resize: 'vertical', outline: 'none', boxSizing: 'border-box' }}
|
||||
onFocus={e => e.target.style.borderColor = '#6366f1'}
|
||||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#8a9099' }}>Μετά την επιβεβαίωση θα εμφανιστεί η αναλυτική σύνοψη βάρδιας.</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Άκυρο</button>
|
||||
<button onClick={() => 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">
|
||||
<button onClick={() => setStep(2)} className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700">
|
||||
Συνέχεια →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2: cash handover */}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>
|
||||
Πόσα μετρητά παραδίδει ο σερβιτόρος; <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: '#6b7280', marginBottom: 6 }}>
|
||||
<span>Αναμενόμενη παράδοση:</span>
|
||||
<span style={{ fontWeight: 700, color: '#111315' }}>{fmtEuro(expectedHandover)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={countedCash}
|
||||
onChange={e => 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 && (
|
||||
<div style={{
|
||||
marginTop: 8, padding: '8px 12px', borderRadius: 8,
|
||||
background: isClean ? '#f0fdf4' : '#fef2f2',
|
||||
border: `1px solid ${isClean ? '#bbf7d0' : '#fecaca'}`,
|
||||
fontSize: 12.5, fontWeight: 600,
|
||||
color: isClean ? '#16a34a' : '#dc2626',
|
||||
}}>
|
||||
{isClean && '✓ Ταμείο εντάξει — παραδίδει το σωστό ποσό'}
|
||||
{isShort && `⚠ Ελλείμμα ${fmtEuro(Math.abs(discrepancy))} — ο σερβιτόρος παραδίδει λιγότερα`}
|
||||
{isOver && `⚠ Πλεόνασμα ${fmtEuro(Math.abs(discrepancy))} — ο σερβιτόρος παραδίδει περισσότερα`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => 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">← Πίσω</button>
|
||||
<button
|
||||
onClick={() => 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 ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: '14px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
|
||||
|
||||
@@ -124,6 +124,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
||||
<TH align="right">Αρχικά Μετρητά</TH>
|
||||
<TH align="right">Εισπράχθηκαν</TH>
|
||||
<TH align="right">Οφείλει</TH>
|
||||
<TH align="right">Ταμείο</TH>
|
||||
<TH>Κατάσταση</TH>
|
||||
<TH className="w-28" />
|
||||
</THead>
|
||||
@@ -158,6 +159,24 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
||||
<TD mono align="right">{fmtEUR(s.starting_cash)}</TD>
|
||||
<TD mono align="right">{fmtEUR(s.total_collected)}</TD>
|
||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(s.net_to_deliver)}</TD>
|
||||
<TD mono align="right">
|
||||
{s.counted_cash_end != null ? (
|
||||
<span className={
|
||||
s.cash_discrepancy == null || Math.abs(s.cash_discrepancy) < 0.005
|
||||
? 'text-emerald-600 font-semibold'
|
||||
: 'text-red-600 font-semibold'
|
||||
}>
|
||||
{fmtEUR(s.counted_cash_end)}
|
||||
{s.cash_discrepancy != null && Math.abs(s.cash_discrepancy) >= 0.005 && (
|
||||
<span className="block text-[10px] font-medium">
|
||||
{s.cash_discrepancy > 0 ? '+' : ''}{fmtEUR(s.cash_discrepancy)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-400 text-[11px]">—</span>
|
||||
)}
|
||||
</TD>
|
||||
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
|
||||
<TD align="right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
@@ -182,7 +201,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
||||
</TR>,
|
||||
isOpen && (
|
||||
<tr key={`${s.id}-detail`}>
|
||||
<td colSpan={11} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
|
||||
<td colSpan={12} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
|
||||
<div className="flex items-center gap-4 text-[12px] text-slate-500">
|
||||
<span>
|
||||
{'Εργάσιμη Μέρα: '}
|
||||
|
||||
Reference in New Issue
Block a user