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:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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> από το μενού Ενεργειών.
|
||||
|
||||
@@ -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>
|
||||
{'Εργάσιμη Μέρα: '}
|
||||
|
||||
Reference in New Issue
Block a user