From e5b8ef32be0587e63d801425c0b8417d8f7ca08c Mon Sep 17 00:00:00 2001 From: bonamin Date: Sun, 7 Jun 2026 20:55:24 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202B=20=E2=80=94=20staff=20payrol?= =?UTF-8?q?l=20/=20hourly=20rate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Users: add hourly_rate (Float, nullable) — manager-only, not shown to waiter - WaiterShift: add hourly_rate_snapshot — copied from user.hourly_rate at shift open, never updated - Shift start (both /start and /manager/start): snapshot rate at shift creation - compute_shift_pay(): calculates worked hours (minus breaks) × rate_snapshot; returns None if unset - _enrich_shift(): now includes hourly_rate_snapshot, duration_hours, shift_pay - shifts_report in reports.py: delegates to _enrich_shift so all shift endpoints are consistent - Shifts CSV export: adds duration_hours, hourly_rate, shift_pay columns - StaffTab: hourly_rate field in waiter edit modal (Μισθοδοσία section, col 3) - ShiftsOverview report: Αμοιβή/ώρα + Αποδοχές columns; untracked shows 'Δεν παρακολουθείται' Co-Authored-By: Claude Sonnet 4.6 --- local_backend/main.py | 3 ++ local_backend/models/shift.py | 2 + local_backend/models/user.py | 4 +- local_backend/routers/reports.py | 28 +++---------- local_backend/routers/shifts.py | 42 +++++++++++++++++++ local_backend/schemas/user.py | 4 ++ manager_dashboard/src/pages/StaffTab.jsx | 33 +++++++++++++-- .../pages/reports/staff/ShiftsOverview.jsx | 14 ++++++- 8 files changed, 101 insertions(+), 29 deletions(-) diff --git a/local_backend/main.py b/local_backend/main.py index b9a1837..8beef6d 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -266,6 +266,9 @@ def _run_migrations(): "ALTER TABLE products ADD COLUMN cost_simple REAL", "ALTER TABLE products ADD COLUMN cost_breakdown TEXT", "ALTER TABLE order_items ADD COLUMN unit_cost REAL", + # Phase 2B — staff payroll + "ALTER TABLE users ADD COLUMN hourly_rate REAL", + "ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL", ] for sql in migrations: try: diff --git a/local_backend/models/shift.py b/local_backend/models/shift.py index d06a1c2..0a7a96e 100644 --- a/local_backend/models/shift.py +++ b/local_backend/models/shift.py @@ -19,6 +19,8 @@ class WaiterShift(Base): starting_cash = Column(Float, nullable=True) total_collected = Column(Float, nullable=True) # snapshot written at shift end 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) waiter = relationship("User", foreign_keys=[waiter_id]) business_day = relationship("BusinessDay", back_populates="shifts") diff --git a/local_backend/models/user.py b/local_backend/models/user.py index 7a26107..8695eeb 100644 --- a/local_backend/models/user.py +++ b/local_backend/models/user.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey from sqlalchemy.orm import relationship from datetime import datetime, timezone from database import Base @@ -24,6 +24,8 @@ class User(Base): note = Column(String, nullable=True) avatar_url = Column(String, nullable=True) created_at = Column(DateTime(timezone=True), default=_utcnow) + # Phase 2B — payroll + hourly_rate = Column(Float, nullable=True) orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener") orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer") diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py index cc55120..55be471 100644 --- a/local_backend/routers/reports.py +++ b/local_backend/routers/reports.py @@ -872,7 +872,7 @@ def shifts_report( db: Session = Depends(get_db), user: User = Depends(require_manager), ): - from routers.shifts import compute_shift_total + from routers.shifts import _enrich_shift q = db.query(WaiterShift) if waiter_id: @@ -887,28 +887,7 @@ def shifts_report( q = q.filter(WaiterShift.ended_at == None) shifts = q.order_by(WaiterShift.started_at.desc()).all() - waiters_db = {u.id: u for u in db.query(User).all()} - - result = [] - for shift in shifts: - w = waiters_db.get(shift.waiter_id) - wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}" - total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0) - result.append({ - "id": shift.id, - "waiter_id": shift.waiter_id, - "waiter_name": wname, - "business_day_id": shift.business_day_id, - "started_at": _dt(shift.started_at), - "ended_at": _dt(shift.ended_at), - "starting_cash": shift.starting_cash, - "total_collected": total, - "net_to_deliver": round(total + (shift.starting_cash or 0.0), 2), - "is_active": shift.ended_at is None, - "notes": shift.notes, - }) - - return {"shifts": result} + return {"shifts": [_enrich_shift(s, db) for s in shifts]} # --------------------------------------------------------------------------- @@ -1513,6 +1492,9 @@ def shifts_export( "waiter": s["waiter_name"], "started_at": s["started_at"], "ended_at": s["ended_at"] or "", + "duration_hours": s.get("duration_hours", ""), + "hourly_rate": s.get("hourly_rate_snapshot", ""), + "shift_pay": s.get("shift_pay", ""), "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 ae03985..524e092 100644 --- a/local_backend/routers/shifts.py +++ b/local_backend/routers/shifts.py @@ -35,10 +35,46 @@ def compute_shift_total(shift_id: int, db: Session) -> float: return round(sum(i.unit_price * i.quantity for i in items), 2) +def compute_shift_pay(shift: WaiterShift) -> dict: + """Return duration_hours and shift_pay. shift_pay is None if no rate snapshot.""" + now = datetime.now(timezone.utc) + start = shift.started_at + if start and start.tzinfo is None: + start = start.replace(tzinfo=timezone.utc) + end = shift.ended_at + if end and end.tzinfo is None: + end = end.replace(tzinfo=timezone.utc) + reference = end or now + + total_seconds = (reference - start).total_seconds() if start else 0 + + # Subtract completed breaks + break_seconds = 0 + for b in shift.breaks: + bs = b.started_at + be = b.ended_at + if bs and be: + if bs.tzinfo is None: + bs = bs.replace(tzinfo=timezone.utc) + if be.tzinfo is None: + be = be.replace(tzinfo=timezone.utc) + break_seconds += (be - bs).total_seconds() + + worked_seconds = max(0, total_seconds - break_seconds) + duration_hours = round(worked_seconds / 3600, 4) + + shift_pay = None + if shift.hourly_rate_snapshot is not None: + shift_pay = round(duration_hours * shift.hourly_rate_snapshot, 2) + + return {"duration_hours": round(duration_hours, 2), "shift_pay": shift_pay} + + def _enrich_shift(shift: WaiterShift, db: Session) -> dict: w = shift.waiter wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}" total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0) + pay_data = compute_shift_pay(shift) return { "id": shift.id, "waiter_id": shift.waiter_id, @@ -51,6 +87,9 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict: "net_to_deliver": round(total + (shift.starting_cash or 0.0), 2), "is_active": shift.ended_at is None, "notes": shift.notes, + "hourly_rate_snapshot": shift.hourly_rate_snapshot, + "duration_hours": pay_data["duration_hours"], + "shift_pay": pay_data["shift_pay"], "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 @@ -97,10 +136,12 @@ def start_shift( if existing: raise HTTPException(status_code=400, detail="Waiter already has an active shift") + target_user = db.query(User).filter(User.id == target_id).first() shift = WaiterShift( waiter_id=target_id, business_day_id=active_day.id, starting_cash=body.starting_cash, + hourly_rate_snapshot=target_user.hourly_rate if target_user else None, ) db.add(shift) db.commit() @@ -169,6 +210,7 @@ def manager_start_shift( waiter_id=body.waiter_id, business_day_id=active_day.id, starting_cash=body.starting_cash, + hourly_rate_snapshot=target.hourly_rate, ) db.add(shift) db.commit() diff --git a/local_backend/schemas/user.py b/local_backend/schemas/user.py index 6a9bdd4..4c0ecdd 100644 --- a/local_backend/schemas/user.py +++ b/local_backend/schemas/user.py @@ -14,6 +14,8 @@ class UserBase(BaseModel): note: Optional[str] = None avatar_url: Optional[str] = None email: Optional[str] = None + # Phase 2B — payroll + hourly_rate: Optional[float] = None class UserCreate(UserBase): @@ -29,6 +31,8 @@ class UserUpdate(BaseModel): mobile_phone: Optional[str] = None email: Optional[str] = None note: Optional[str] = None + # Phase 2B — payroll + hourly_rate: Optional[float] = None class WaiterZoneOut(BaseModel): diff --git a/manager_dashboard/src/pages/StaffTab.jsx b/manager_dashboard/src/pages/StaffTab.jsx index 5b11052..e9026c4 100644 --- a/manager_dashboard/src/pages/StaffTab.jsx +++ b/manager_dashboard/src/pages/StaffTab.jsx @@ -277,7 +277,7 @@ export default function WaitersPage() { const [newAvatarFile, setNewAvatarFile] = useState(null) const [newAvatarPreview, setNewAvatarPreview] = useState(null) const [editModal, setEditModal] = useState(null) - const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter' }) + const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', hourly_rate: '' }) const avatarInputRef = useRef(null) const newAvatarInputRef = useRef(null) @@ -353,7 +353,7 @@ export default function WaitersPage() { function openEdit(w) { setEditModal(w) - setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', email: w.email || '', note: w.note || '', role: w.role || 'waiter' }) + setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', email: w.email || '', note: w.note || '', role: w.role || 'waiter', hourly_rate: w.hourly_rate ?? '' }) } return ( @@ -443,6 +443,7 @@ export default function WaitersPage() { full_name: editForm.full_name || null, nickname: editForm.nickname || null, mobile_phone: editForm.mobile_phone || null, email: editForm.email || null, note: editForm.note || null, role: editForm.role, + hourly_rate: editForm.hourly_rate !== '' ? parseFloat(editForm.hourly_rate) : null, })} onUploadAvatar={file => uploadAvatar.mutate({ id: editModal.id, file })} onDeleteAvatar={() => deleteAvatar.mutate(editModal.id)} @@ -719,7 +720,7 @@ function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isU - {/* Column 3: Info + PIN note */} + {/* Column 3: Info + Payroll + PIN note */}
Πληροφορίες @@ -737,7 +738,31 @@ function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isU
-
+ {/* Phase 2B — Payroll */} +
+ Μισθοδοσία + +
+ f('hourly_rate', e.target.value)} + /> + {form.hourly_rate !== '' && parseFloat(form.hourly_rate) > 0 && ( + + €{parseFloat(form.hourly_rate).toFixed(2)}/ώρα + + )} + {form.hourly_rate === '' && ( + Δεν παρακολουθείται + )} +
+
+
+ +

Κωδικός PIN

Για αλλαγή PIN χρησιμοποιήστε την επιλογή Επαναφορά PIN από το μενού Ενεργειών. diff --git a/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx b/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx index d2aa510..20bedd2 100644 --- a/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx +++ b/manager_dashboard/src/pages/reports/staff/ShiftsOverview.jsx @@ -119,6 +119,8 @@ export default function ShiftsOverview({ onNavigate } = {}) { Έναρξη Λήξη Διάρκεια + Αμοιβή/ώρα + Αποδοχές Αρχικά Μετρητά Εισπράχθηκαν Οφείλει @@ -143,6 +145,16 @@ export default function ShiftsOverview({ onNavigate } = {}) { : — ενεργή —} {fmtDuration(s.started_at, s.ended_at)} + + {s.hourly_rate_snapshot != null + ? fmtEUR(s.hourly_rate_snapshot) + : } + + + {s.shift_pay != null + ? {fmtEUR(s.shift_pay)} + : Δεν παρακολουθείται} + {fmtEUR(s.starting_cash)} {fmtEUR(s.total_collected)} {fmtEUR(s.net_to_deliver)} @@ -170,7 +182,7 @@ export default function ShiftsOverview({ onNavigate } = {}) { , isOpen && ( - +

{'Εργάσιμη Μέρα: '}