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:
2026-06-08 02:47:03 +03:00
parent a675c2e091
commit 9da396b82a
12 changed files with 301 additions and 44 deletions

View File

@@ -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 ? [{
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' && <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>
<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>
{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>

View File

@@ -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 (
<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>
<div style={{ background: '#fafafa', borderRadius: 12, padding: '12px 16px', fontSize: 13, color: '#374151' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
{/* 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>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
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">
{busy ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'}
</button>
</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>
</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
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' }}
/>
</div>
<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={() => 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)

View File

@@ -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 }}>

View File

@@ -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>
{'Εργάσιμη Μέρα: '}