feat: Phase 2B — staff payroll / hourly rate

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:55:24 +03:00
parent d0dfd63415
commit e5b8ef32be
8 changed files with 101 additions and 29 deletions

View File

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

View File

@@ -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")

View File

@@ -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")

View File

@@ -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"],

View File

@@ -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()

View File

@@ -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):

View File

@@ -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
</div>
</div>
{/* Column 3: Info + PIN note */}
{/* Column 3: Info + Payroll + PIN note */}
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, background: '#fafafa' }}>
<div>
<SectionLabel>Πληροφορίες</SectionLabel>
@@ -737,7 +738,31 @@ function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isU
</div>
</div>
<div style={{ marginTop: 8, padding: 14, background: '#f3f4f6', borderRadius: 10 }}>
{/* Phase 2B — Payroll */}
<div>
<SectionLabel>Μισθοδοσία</SectionLabel>
<FormField label="Ωριαία αμοιβή (€/ώρα)" desc="Μόνο για διαχειριστές. Δεν εμφανίζεται στον εργαζόμενο.">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number" step="0.50" min="0"
style={{ ...inputStyle, width: 110 }}
placeholder="π.χ. 5.50"
value={form.hourly_rate}
onChange={e => f('hourly_rate', e.target.value)}
/>
{form.hourly_rate !== '' && parseFloat(form.hourly_rate) > 0 && (
<span style={{ fontSize: 11.5, color: '#16a34a', fontWeight: 500 }}>
{parseFloat(form.hourly_rate).toFixed(2)}/ώρα
</span>
)}
{form.hourly_rate === '' && (
<span style={{ fontSize: 11, color: '#9ca3af' }}>Δεν παρακολουθείται</span>
)}
</div>
</FormField>
</div>
<div style={{ marginTop: 4, padding: 14, background: '#f3f4f6', borderRadius: 10 }}>
<p style={{ margin: '0 0 4px', fontSize: 11.5, fontWeight: 600, color: '#374151' }}>Κωδικός PIN</p>
<p style={{ margin: 0, fontSize: 11.5, color: '#6b7280', lineHeight: 1.5 }}>
Για αλλαγή PIN χρησιμοποιήστε την επιλογή <strong style={{ color: '#374151' }}>Επαναφορά PIN</strong> από το μενού Ενεργειών.

View File

@@ -119,6 +119,8 @@ export default function ShiftsOverview({ onNavigate } = {}) {
<TH>Έναρξη</TH>
<TH>Λήξη</TH>
<TH align="right">Διάρκεια</TH>
<TH align="right">Αμοιβή/ώρα</TH>
<TH align="right">Αποδοχές</TH>
<TH align="right">Αρχικά Μετρητά</TH>
<TH align="right">Εισπράχθηκαν</TH>
<TH align="right">Οφείλει</TH>
@@ -143,6 +145,16 @@ export default function ShiftsOverview({ onNavigate } = {}) {
: <span className="text-sky-600 font-medium"> ενεργή </span>}
</TD>
<TD mono align="right">{fmtDuration(s.started_at, s.ended_at)}</TD>
<TD mono align="right">
{s.hourly_rate_snapshot != null
? fmtEUR(s.hourly_rate_snapshot)
: <span className="text-slate-400 text-[11px]"></span>}
</TD>
<TD mono align="right">
{s.shift_pay != null
? <span className="font-semibold text-emerald-700">{fmtEUR(s.shift_pay)}</span>
: <span className="text-slate-400 text-[11px]">Δεν παρακολουθείται</span>}
</TD>
<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>
@@ -170,7 +182,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
</TR>,
isOpen && (
<tr key={`${s.id}-detail`}>
<td colSpan={9} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
<td colSpan={11} 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>
{'Εργάσιμη Μέρα: '}