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

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